fix(export): 裁剪超出范围的曲线并修正 PDF 纵轴标签
- 共享几何将曲线裁剪到绘图矩形,避免超出显式 range 的曲线覆盖 PDF 其他内容 - PDF 纵轴标签改为组内局部坐标 + 先平移后旋转,标签边界落回 Drawing 范围内 - 更新 pdf.py 模块说明:function_plot 已内嵌矢量图 - 新增曲线裁剪与纵轴标签边界回归测试 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
"""PdfExporter:Document AST → PDF(reportlab platypus)。
|
"""PdfExporter:Document AST → PDF(reportlab platypus)。
|
||||||
|
|
||||||
v1 为文本优先:标题/段落/行内强调与链接/列表/引用/表格/代码块/数学文本均可导出;
|
v1 为文本优先:标题/段落/行内强调与链接/列表/引用/表格/代码块/数学文本均可导出;
|
||||||
function_plot 与 mermaid 保留源码占位并记 warning。中文字体用 reportlab 内置
|
function_plot 内嵌为矢量图(reportlab Drawing),mermaid 保留源码占位并记 warning。
|
||||||
STSong-Light CID 字体,避免外部字体依赖。CID 字体无独立 bold/italic 字重,
|
中文字体用 reportlab 内置 STSong-Light CID 字体,避免外部字体依赖。CID 字体无独立
|
||||||
故行内强调退化为普通文本(内容不丢、样式简化),标题靠字号区分层级。
|
bold/italic 字重,故行内强调退化为普通文本(内容不丢、样式简化),标题靠字号区分层级。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ _MARGIN = 52 # 四周留白,放轴刻度与标签
|
|||||||
_SAMPLES = 400
|
_SAMPLES = 400
|
||||||
_PALETTE = ["#0969da", "#d1242f", "#1a7f37", "#8250df", "#bf8700", "#e36209"]
|
_PALETTE = ["#0969da", "#d1242f", "#1a7f37", "#8250df", "#bf8700", "#e36209"]
|
||||||
_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$")
|
_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:
|
def _safe_color(color: str | None, fallback: str) -> str:
|
||||||
@@ -141,6 +146,79 @@ class PlotGeometry:
|
|||||||
warnings: list[str]
|
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(
|
def _sample_segments(
|
||||||
tree: object,
|
tree: object,
|
||||||
xmin: float,
|
xmin: float,
|
||||||
@@ -148,7 +226,7 @@ def _sample_segments(
|
|||||||
ymin: float,
|
ymin: float,
|
||||||
ymax: float,
|
ymax: float,
|
||||||
) -> list[list[tuple[float, float]]]:
|
) -> list[list[tuple[float, float]]]:
|
||||||
"""采样并映射为像素点段;非有限点处断段,避免画穿渐近线。"""
|
"""采样并映射为像素点段,再裁剪到绘图矩形;非有限点处断段,避免画穿渐近线。"""
|
||||||
segments: list[list[tuple[float, float]]] = []
|
segments: list[list[tuple[float, float]]] = []
|
||||||
points: list[tuple[float, float]] = []
|
points: list[tuple[float, float]] = []
|
||||||
for i in range(_SAMPLES + 1):
|
for i in range(_SAMPLES + 1):
|
||||||
@@ -173,7 +251,13 @@ def _sample_segments(
|
|||||||
points.append((px, py))
|
points.append((px, py))
|
||||||
if points:
|
if points:
|
||||||
segments.append(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:
|
def compute_geometry(plot: FunctionPlot) -> PlotGeometry:
|
||||||
|
|||||||
@@ -89,15 +89,19 @@ def _build_drawing(geo: PlotGeometry) -> Drawing:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if geo.ylabel:
|
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 = Group()
|
||||||
label.add(
|
label.add(
|
||||||
String(
|
String(
|
||||||
16, geo.height / 2, geo.ylabel,
|
0, 0, geo.ylabel,
|
||||||
fontName=_FONT, fontSize=_LABEL_FONT_SIZE, fillColor=_LABEL_COLOR, textAnchor="middle",
|
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)
|
drawing.add(label)
|
||||||
|
|
||||||
return drawing
|
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 math.isfinite(x) and math.isfinite(y)
|
||||||
assert 0 <= x <= 640
|
assert 0 <= x <= 640
|
||||||
assert 0 <= y <= 480
|
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