feat(export): 多格式后台导出、主题与警告框渲染 #43
@@ -1,9 +1,9 @@
|
||||
"""PdfExporter:Document AST → PDF(reportlab platypus)。
|
||||
|
||||
v1 为文本优先:标题/段落/行内强调与链接/列表/引用/表格/代码块/数学文本均可导出;
|
||||
function_plot 与 mermaid 保留源码占位并记 warning。中文字体用 reportlab 内置
|
||||
STSong-Light CID 字体,避免外部字体依赖。CID 字体无独立 bold/italic 字重,
|
||||
故行内强调退化为普通文本(内容不丢、样式简化),标题靠字号区分层级。
|
||||
function_plot 内嵌为矢量图(reportlab Drawing),mermaid 保留源码占位并记 warning。
|
||||
中文字体用 reportlab 内置 STSong-Light CID 字体,避免外部字体依赖。CID 字体无独立
|
||||
bold/italic 字重,故行内强调退化为普通文本(内容不丢、样式简化),标题靠字号区分层级。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -24,6 +24,11 @@ _MARGIN = 52 # 四周留白,放轴刻度与标签
|
||||
_SAMPLES = 400
|
||||
_PALETTE = ["#0969da", "#d1242f", "#1a7f37", "#8250df", "#bf8700", "#e36209"]
|
||||
_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$")
|
||||
# 绘图矩形(像素,SVG y-down):曲线与坐标轴所在区域,坐标轴/网格均在此范围内
|
||||
_PLOT_X0 = _MARGIN
|
||||
_PLOT_Y0 = _MARGIN
|
||||
_PLOT_X1 = _WIDTH - _MARGIN
|
||||
_PLOT_Y1 = _HEIGHT - _MARGIN
|
||||
|
||||
|
||||
def _safe_color(color: str | None, fallback: str) -> str:
|
||||
@@ -141,6 +146,79 @@ class PlotGeometry:
|
||||
warnings: list[str]
|
||||
|
||||
|
||||
def _clip_segment(
|
||||
p0: tuple[float, float],
|
||||
p1: tuple[float, float],
|
||||
x0: float,
|
||||
y0: float,
|
||||
x1: float,
|
||||
y1: float,
|
||||
) -> tuple[tuple[float, float], tuple[float, float]] | None:
|
||||
"""Liang-Barsky:把线段裁剪到轴对齐矩形 [x0,x1]×[y0,y1],完全在外返回 None。"""
|
||||
dx = p1[0] - p0[0]
|
||||
dy = p1[1] - p0[1]
|
||||
p = (-dx, dx, -dy, dy)
|
||||
q = (p0[0] - x0, x1 - p0[0], p0[1] - y0, y1 - p0[1])
|
||||
u1, u2 = 0.0, 1.0
|
||||
for pk, qk in zip(p, q):
|
||||
if pk == 0:
|
||||
if qk < 0:
|
||||
return None
|
||||
else:
|
||||
r = qk / pk
|
||||
if pk < 0:
|
||||
if r > u2:
|
||||
return None
|
||||
if r > u1:
|
||||
u1 = r
|
||||
else:
|
||||
if r < u1:
|
||||
return None
|
||||
if r < u2:
|
||||
u2 = r
|
||||
if u1 > u2:
|
||||
return None
|
||||
return (p0[0] + u1 * dx, p0[1] + u1 * dy), (p0[0] + u2 * dx, p0[1] + u2 * dy)
|
||||
|
||||
|
||||
def _points_close(
|
||||
a: tuple[float, float], b: tuple[float, float], eps: float = 1e-9
|
||||
) -> bool:
|
||||
return abs(a[0] - b[0]) < eps and abs(a[1] - b[1]) < eps
|
||||
|
||||
|
||||
def _clip_polyline(
|
||||
points: list[tuple[float, float]],
|
||||
x0: float,
|
||||
y0: float,
|
||||
x1: float,
|
||||
y1: float,
|
||||
) -> list[list[tuple[float, float]]]:
|
||||
"""把折线裁剪到矩形,返回若干连续子段;相邻点不衔接处自动断段。"""
|
||||
if not points:
|
||||
return []
|
||||
segments: list[list[tuple[float, float]]] = []
|
||||
current: list[tuple[float, float]] = []
|
||||
for i in range(len(points) - 1):
|
||||
clipped = _clip_segment(points[i], points[i + 1], x0, y0, x1, y1)
|
||||
if clipped is None:
|
||||
if current:
|
||||
segments.append(current)
|
||||
current = []
|
||||
continue
|
||||
a, b = clipped
|
||||
# 共享点被裁剪修改(折线短暂越界后折返)时,a 与上一段末点不衔接,需断段
|
||||
if current and not _points_close(a, current[-1]):
|
||||
segments.append(current)
|
||||
current = []
|
||||
if not current:
|
||||
current.append(a)
|
||||
current.append(b)
|
||||
if current:
|
||||
segments.append(current)
|
||||
return segments
|
||||
|
||||
|
||||
def _sample_segments(
|
||||
tree: object,
|
||||
xmin: float,
|
||||
@@ -148,7 +226,7 @@ def _sample_segments(
|
||||
ymin: float,
|
||||
ymax: float,
|
||||
) -> list[list[tuple[float, float]]]:
|
||||
"""采样并映射为像素点段;非有限点处断段,避免画穿渐近线。"""
|
||||
"""采样并映射为像素点段,再裁剪到绘图矩形;非有限点处断段,避免画穿渐近线。"""
|
||||
segments: list[list[tuple[float, float]]] = []
|
||||
points: list[tuple[float, float]] = []
|
||||
for i in range(_SAMPLES + 1):
|
||||
@@ -173,7 +251,13 @@ def _sample_segments(
|
||||
points.append((px, py))
|
||||
if points:
|
||||
segments.append(points)
|
||||
return segments
|
||||
|
||||
# 裁剪到绘图矩形:reportlab 无 SVG viewport 那样的自动裁剪,超出显式 range 的
|
||||
# 曲线会覆盖页面其他内容,故在共享几何层统一裁剪(SVG 也一并收敛到绘图区)。
|
||||
clipped: list[list[tuple[float, float]]] = []
|
||||
for seg in segments:
|
||||
clipped.extend(_clip_polyline(seg, _PLOT_X0, _PLOT_Y0, _PLOT_X1, _PLOT_Y1))
|
||||
return clipped
|
||||
|
||||
|
||||
def compute_geometry(plot: FunctionPlot) -> PlotGeometry:
|
||||
|
||||
@@ -89,15 +89,19 @@ def _build_drawing(geo: PlotGeometry) -> Drawing:
|
||||
)
|
||||
)
|
||||
if geo.ylabel:
|
||||
# 竖向标签:rotate(90) 在 y-up 坐标下等价于 SVG 的 rotate(-90)
|
||||
# 竖向标签:Group.rotate(90) 在 y-up 坐标下等价于 SVG 的 rotate(-90)。
|
||||
# 文本放在组内局部坐标 (0,0),先平移后旋转得到 T·R(先绕原点旋转、再平移到
|
||||
# 目标位置),避免用绝对坐标定位又用相同坐标当旋转中心造成的重复变换,
|
||||
# 后者会把标签甩到画布之外(负 x 区域)。
|
||||
label = Group()
|
||||
label.add(
|
||||
String(
|
||||
16, geo.height / 2, geo.ylabel,
|
||||
0, 0, geo.ylabel,
|
||||
fontName=_FONT, fontSize=_LABEL_FONT_SIZE, fillColor=_LABEL_COLOR, textAnchor="middle",
|
||||
)
|
||||
)
|
||||
label.rotate(90, 16, geo.height / 2)
|
||||
label.translate(16, geo.height / 2)
|
||||
label.rotate(90)
|
||||
drawing.add(label)
|
||||
|
||||
return drawing
|
||||
|
||||
@@ -383,3 +383,45 @@ def test_render_reportlab_curves_are_finite_and_bounded() -> None:
|
||||
assert math.isfinite(x) and math.isfinite(y)
|
||||
assert 0 <= x <= 640
|
||||
assert 0 <= y <= 480
|
||||
|
||||
|
||||
def test_compute_geometry_clips_curves_to_plot_rect() -> None:
|
||||
# P2:显式 range 外的曲线应裁剪到绘图矩形,避免 PDF 中曲线覆盖页面其他内容
|
||||
from app.plot.render import (
|
||||
_PLOT_X0,
|
||||
_PLOT_X1,
|
||||
_PLOT_Y0,
|
||||
_PLOT_Y1,
|
||||
compute_geometry,
|
||||
)
|
||||
|
||||
plot = parse_source("range: -1, 1\ny = 10*x").plot
|
||||
geo = compute_geometry(plot)
|
||||
assert geo.polylines
|
||||
assert any(geo.polylines) # 曲线穿越 range 后在绘图区内仍有可见段
|
||||
for segments in geo.polylines:
|
||||
for seg in segments:
|
||||
assert seg
|
||||
for px, py in seg:
|
||||
assert _PLOT_X0 <= px <= _PLOT_X1
|
||||
assert _PLOT_Y0 <= py <= _PLOT_Y1
|
||||
|
||||
|
||||
def test_render_reportlab_ylabel_within_drawing_bounds() -> None:
|
||||
# P2:纵轴标签旋转后边界应落在 Drawing 范围内,不能甩到负 x 区域
|
||||
from reportlab.graphics.shapes import Group, String
|
||||
|
||||
from app.plot.render_reportlab import render_drawing
|
||||
|
||||
plot = parse_source("ylabel: 数值\ny = x").plot
|
||||
drawing = render_drawing(plot)
|
||||
groups = [c for c in drawing.contents if isinstance(c, Group)]
|
||||
ylabel_groups = [
|
||||
g
|
||||
for g in groups
|
||||
if any(isinstance(s, String) and s.text == "数值" for s in g.contents)
|
||||
]
|
||||
assert ylabel_groups
|
||||
x0, y0, x1, y1 = ylabel_groups[0].getBounds()
|
||||
assert 0 <= x0 <= x1 <= 640
|
||||
assert 0 <= y0 <= y1 <= 480
|
||||
|
||||
Reference in New Issue
Block a user