From edc41fdade18a6222a547a57172c58b0fe12ef80 Mon Sep 17 00:00:00 2001 From: yxx <2412119399@qq.com> Date: Sun, 6 Sep 2026 22:28:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(export):=20=E8=A3=81=E5=89=AA=E8=B6=85?= =?UTF-8?q?=E5=87=BA=E8=8C=83=E5=9B=B4=E7=9A=84=E6=9B=B2=E7=BA=BF=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20PDF=20=E7=BA=B5=E8=BD=B4=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 共享几何将曲线裁剪到绘图矩形,避免超出显式 range 的曲线覆盖 PDF 其他内容 - PDF 纵轴标签改为组内局部坐标 + 先平移后旋转,标签边界落回 Drawing 范围内 - 更新 pdf.py 模块说明:function_plot 已内嵌矢量图 - 新增曲线裁剪与纵轴标签边界回归测试 Co-Authored-By: Claude Code --- backend/app/export/exporters/pdf.py | 6 +- backend/app/plot/render.py | 88 +++++++++++++++++++++++++++- backend/app/plot/render_reportlab.py | 10 +++- backend/tests/test_plot.py | 42 +++++++++++++ 4 files changed, 138 insertions(+), 8 deletions(-) diff --git a/backend/app/export/exporters/pdf.py b/backend/app/export/exporters/pdf.py index f073904..e15f742 100644 --- a/backend/app/export/exporters/pdf.py +++ b/backend/app/export/exporters/pdf.py @@ -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 diff --git a/backend/app/plot/render.py b/backend/app/plot/render.py index b47aa14..e49700e 100644 --- a/backend/app/plot/render.py +++ b/backend/app/plot/render.py @@ -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: diff --git a/backend/app/plot/render_reportlab.py b/backend/app/plot/render_reportlab.py index 03a6819..3bd5452 100644 --- a/backend/app/plot/render_reportlab.py +++ b/backend/app/plot/render_reportlab.py @@ -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 diff --git a/backend/tests/test_plot.py b/backend/tests/test_plot.py index 358af3a..514aeda 100644 --- a/backend/tests/test_plot.py +++ b/backend/tests/test_plot.py @@ -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