diff --git a/backend/app/export/exporters/_common.py b/backend/app/export/exporters/_common.py
index f26ca5e..cbf41ed 100644
--- a/backend/app/export/exporters/_common.py
+++ b/backend/app/export/exporters/_common.py
@@ -1,6 +1,7 @@
-"""导出器共享工具:URL 协议校验与占位 warning 文案。
+"""导出器共享工具:URL 协议校验、函数图像预算与占位 warning 文案。
-html / pdf / docx 三个导出器共用同一套安全规则,避免各写一份导致行为漂移。
+html / pdf / docx 三个导出器共用同一套安全规则与函数图像资源预算,避免各写一份
+导致行为漂移。
"""
from __future__ import annotations
@@ -13,9 +14,50 @@ ALLOWED_URL_SCHEMES = frozenset({"http", "https", "mailto"})
MERMAID_WARNING = "mermaid 需前端渲染,已保留为占位代码块"
RAW_HTML_WARNING = "原始 HTML 已按纯文本转义保留"
-# PDF/DOCX 暂不支持静态渲染函数图像,统一回退源码占位
+# DOCX 暂不支持静态渲染函数图像,统一回退源码占位
PLOT_PLACEHOLDER_WARNING = "函数图像:该格式暂不支持静态渲染,已保留为源码占位"
+# 单篇文档允许的函数图像数量上限,超出部分回退占位,防止多图块并发采样耗尽内存/线程
+MAX_FUNCTION_PLOTS = 16
+# 单篇文档允许的函数图像累计 AST 节点预算,超出部分回退占位,防止组合复杂度(多图块
+# × 多表达式 × 深表达式)在采样求值时长时间占满 CPU
+MAX_TOTAL_PLOT_NODES = 8000
+
+
+class FunctionPlotBudget:
+ """函数图像文档级资源预算:数量上限 + 累计 AST 节点上限。
+
+ HTML 与 PDF 导出器在渲染每个 function-plot 图块前先问预算,超限即回退源码占位,
+ 不解析不采样,避免多图块组合复杂度耗尽内存/CPU。
+ """
+
+ def __init__(self, max_plots: int | None = None, max_total_nodes: int | None = None) -> None:
+ # 默认读模块常量(便于测试 monkeypatch 常量后重新生效)
+ self.max_plots = MAX_FUNCTION_PLOTS if max_plots is None else max_plots
+ self.max_total_nodes = MAX_TOTAL_PLOT_NODES if max_total_nodes is None else max_total_nodes
+ self.count = 0
+ self.total_nodes = 0
+
+ def check_count(self) -> str | None:
+ """图块数量 +1;超限返回 warning 文案,否则返回 None。"""
+ self.count += 1
+ if self.count > self.max_plots:
+ return f"函数图像:文档内函数图像数量超过上限 {self.max_plots},已回退为源码占位"
+ return None
+
+ def check_nodes(self, node_count: int) -> str | None:
+ """累计节点预算校验;超限返回 warning 文案(不累加),否则累加并返回 None。"""
+ if self.total_nodes + node_count > self.max_total_nodes:
+ return f"函数图像:文档内函数图像累计复杂度超过上限 {self.max_total_nodes} 节点,已回退为源码占位"
+ self.total_nodes += node_count
+ return None
+
+
+def format_plot_diagnostic(diag) -> str:
+ """把解析诊断格式化为面向用户的 warning 文案。"""
+ loc = f"(第 {diag.line} 行)" if diag.line else ""
+ return f"函数图像:{diag.message}{loc}"
+
def safe_url(url: str) -> str | None:
"""校验 URL 协议;安全返回原串,不安全返回 None。"""
diff --git a/backend/app/export/exporters/html.py b/backend/app/export/exporters/html.py
index c052ef6..54af119 100644
--- a/backend/app/export/exporters/html.py
+++ b/backend/app/export/exporters/html.py
@@ -13,6 +13,7 @@ from urllib.parse import urlparse
from app.contracts import ExportOptions
from app.export.document import Document, DocumentNode, ExportResult
+from app.export.exporters._common import FunctionPlotBudget, format_plot_diagnostic
from app.plot.renderer import FunctionPlotStaticRenderer, StaticRenderRequest
_MERMAID_WARNING = "mermaid 需前端渲染,已保留为占位代码块"
@@ -21,12 +22,6 @@ _RAW_HTML_WARNING = "原始 HTML 已按纯文本转义保留"
# 链接/图片地址允许的协议;无 scheme 的相对地址视为安全,其余协议一律降级
_ALLOWED_URL_SCHEMES = frozenset({"http", "https", "mailto"})
-# 单篇文档允许的函数图像数量上限,超出部分回退占位,防止多图块并发采样耗尽内存/线程
-_MAX_FUNCTION_PLOTS = 16
-# 单篇文档允许的函数图像累计 AST 节点预算,超出部分回退占位,防止组合复杂度(多图块
-# × 多表达式 × 深表达式)在采样求值时长时间占满 CPU
-_MAX_TOTAL_PLOT_NODES = 8000
-
def _safe_url(url: str) -> str | None:
"""校验 URL 协议;安全返回原串,不安全返回 None。"""
@@ -74,8 +69,7 @@ class HtmlExporter:
def render(self, document: Document, options: ExportOptions) -> ExportResult:
"""同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。"""
self._options = options
- self._plot_count = 0
- self._plot_nodes = 0
+ self._plot_budget = FunctionPlotBudget()
self._plot_renderer = FunctionPlotStaticRenderer()
warnings: list[str] = []
body = self._render_children(document.children, warnings)
@@ -205,18 +199,11 @@ class HtmlExporter:
warnings.append(_MERMAID_WARNING)
return f'
{html.escape(node.text)} '
- @staticmethod
- def _format_plot_diagnostic(diag) -> str:
- loc = f"(第 {diag.line} 行)" if diag.line else ""
- return f"函数图像:{diag.message}{loc}"
-
def _render_function_plot(self, node: DocumentNode, warnings: list[str]) -> str:
# 文档级数量上限:超出部分直接回退占位,不解析不采样,防止海量图像耗尽资源
- self._plot_count += 1
- if self._plot_count > _MAX_FUNCTION_PLOTS:
- warnings.append(
- f"函数图像:文档内函数图像数量超过上限 {_MAX_FUNCTION_PLOTS},已回退为源码占位"
- )
+ over = self._plot_budget.check_count()
+ if over is not None:
+ warnings.append(over)
return f'{html.escape(node.text)} '
# 解析与渲染共同纳入局部异常回退:单个图像失败只回退占位 + warning,
# 绝不阻断整篇导出(含复杂表达式触发的 RecursionError 等异常)。
@@ -226,16 +213,14 @@ class HtmlExporter:
)
parsed = self._plot_renderer.parse(request)
for diag in parsed.diagnostics:
- warnings.append(self._format_plot_diagnostic(diag))
+ warnings.append(format_plot_diagnostic(diag))
if parsed.plot is None:
return f'{html.escape(node.text)} '
# 文档级累计复杂度预算:超出后回退占位,不再采样求值
- if self._plot_nodes + parsed.plot.node_count > _MAX_TOTAL_PLOT_NODES:
- warnings.append(
- f"函数图像:文档内函数图像累计复杂度超过上限 {_MAX_TOTAL_PLOT_NODES} 节点,已回退为源码占位"
- )
+ over = self._plot_budget.check_nodes(parsed.plot.node_count)
+ if over is not None:
+ warnings.append(over)
return f'{html.escape(node.text)} '
- self._plot_nodes += parsed.plot.node_count
rendered = self._plot_renderer.render_plot(parsed.plot)
except Exception as exc:
warnings.append(f"函数图像:解析或渲染失败,已回退占位({exc})")
diff --git a/backend/app/export/exporters/pdf.py b/backend/app/export/exporters/pdf.py
index 24125ce..f073904 100644
--- a/backend/app/export/exporters/pdf.py
+++ b/backend/app/export/exporters/pdf.py
@@ -31,11 +31,14 @@ from app.contracts import ExportOptions
from app.export.document import Document, DocumentNode, ExportResult
from app.export.exporters._common import (
MERMAID_WARNING,
- PLOT_PLACEHOLDER_WARNING,
RAW_HTML_WARNING,
+ FunctionPlotBudget,
format_meta_value,
+ format_plot_diagnostic,
safe_url,
)
+from app.plot.render_reportlab import render_drawing
+from app.plot.renderer import FunctionPlotStaticRenderer, StaticRenderRequest
_FONT = "STSong-Light"
pdfmetrics.registerFont(UnicodeCIDFont(_FONT))
@@ -118,6 +121,11 @@ class PdfExporter:
warnings: list[str] = []
page = _PAGE_SIZES.get((options.page_size or "A4").lower(), A4)
+ self._options = options
+ self._plot_budget = FunctionPlotBudget()
+ self._plot_renderer = FunctionPlotStaticRenderer()
+ # 内容区宽度(左右各 20mm 边距),供函数图像缩放适配页面
+ self._plot_width = page[0] - 40 * mm
buf = BytesIO()
doc = SimpleDocTemplate(
buf,
@@ -292,8 +300,36 @@ class PdfExporter:
story.append(Preformatted(node.text, self._styles["code"]))
def _block_function_plot(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
- warnings.append(PLOT_PLACEHOLDER_WARNING)
- story.append(Preformatted(node.text, self._styles["code"]))
+ # 文档级数量上限:超出部分直接回退占位,不解析不采样,防止海量图像耗尽资源
+ over = self._plot_budget.check_count()
+ if over is not None:
+ warnings.append(over)
+ story.append(Preformatted(node.text, self._styles["code"]))
+ return
+ # 解析与渲染共同纳入局部异常回退:单个图像失败只回退占位 + warning,
+ # 绝不阻断整篇导出(含复杂表达式触发的 RecursionError 等异常)。
+ try:
+ request = StaticRenderRequest(
+ kind="function_plot", source=node.text, theme=self._options.theme_id
+ )
+ parsed = self._plot_renderer.parse(request)
+ for diag in parsed.diagnostics:
+ warnings.append(format_plot_diagnostic(diag))
+ if parsed.plot is None:
+ story.append(Preformatted(node.text, self._styles["code"]))
+ return
+ # 文档级累计复杂度预算:超出后回退占位,不再采样求值
+ over = self._plot_budget.check_nodes(parsed.plot.node_count)
+ if over is not None:
+ warnings.append(over)
+ story.append(Preformatted(node.text, self._styles["code"]))
+ return
+ # Drawing 本身即 Flowable,缩放后追加到 story,与 HTML 视觉一致
+ drawing = render_drawing(parsed.plot, width=self._plot_width)
+ story.append(drawing)
+ except Exception as exc:
+ warnings.append(f"函数图像:解析或渲染失败,已回退占位({exc})")
+ story.append(Preformatted(node.text, self._styles["code"]))
def _block_math_block(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
story.append(Paragraph(f"$${_html.escape(node.text)}$$", self._styles["math"]))
diff --git a/backend/app/plot/render.py b/backend/app/plot/render.py
index 0637324..b47aa14 100644
--- a/backend/app/plot/render.py
+++ b/backend/app/plot/render.py
@@ -1,7 +1,11 @@
-"""Function Plot → 静态 SVG 渲染。
+"""Function Plot → 静态 SVG 渲染 + 共享几何计算。
只输出纯几何与 的 SVG(无 script/foreignObject/内联事件),可安全内嵌 HTML。
所有文本与颜色都经过转义/校验,不把用户输入直接拼进标记。
+
+几何计算(范围解析、采样、刻度、非有限点分段)统一收敛到 ``compute_geometry``,
+返回像素坐标的 ``PlotGeometry``;``render_svg`` 只做 SVG 序列化,reportlab 后端
+(``render_reportlab.py``)消费同一份几何,保证 PDF 与 SVG 视觉一致。
"""
from __future__ import annotations
@@ -9,7 +13,7 @@ from __future__ import annotations
import html
import math
import re
-from typing import Callable
+from dataclasses import dataclass
from app.plot.model import FunctionPlot, StaticRenderResult
from app.plot.parser import PlotParseError, evaluate, parse_expression
@@ -102,17 +106,51 @@ def _compute_range(
return lo - pad, hi + pad
-def _polyline(
+def _sx(x: float, xmin: float, xmax: float) -> float:
+ """数据 x → 像素 x(SVG y-down 约定,原点左上)。"""
+ return _MARGIN + (x - xmin) / (xmax - xmin) * (_WIDTH - 2 * _MARGIN)
+
+
+def _sy(y: float, ymin: float, ymax: float) -> float:
+ """数据 y → 像素 y(SVG y-down 约定,原点左上)。"""
+ return _HEIGHT - _MARGIN - (y - ymin) / (ymax - ymin) * (_HEIGHT - 2 * _MARGIN)
+
+
+@dataclass
+class PlotGeometry:
+ """已解析的几何:范围、轴位置、刻度、曲线像素点段、标签与 warnings。
+
+ 像素坐标统一为 SVG y-down 约定;reportlab 后端(y-up)自行翻转 y。
+ """
+
+ width: int
+ height: int
+ xmin: float
+ xmax: float
+ ymin: float
+ ymax: float
+ x_axis_y: float # 数据空间里 x 轴所在 y(过原点则 0,否则贴边)
+ y_axis_x: float # 数据空间里 y 轴所在 x(过原点则 0,否则贴边)
+ xticks: list[float]
+ yticks: list[float]
+ polylines: list[list[list[tuple[float, float]]]] # 按表达式分组:段 → 像素点
+ colors: list[str] # 与 polylines 对齐
+ xlabel: str | None
+ ylabel: str | None
+ grid: bool
+ warnings: list[str]
+
+
+def _sample_segments(
tree: object,
xmin: float,
xmax: float,
- sx: Callable[[float], float],
- sy: Callable[[float], float],
- color: str,
-) -> str:
- """采样并把非有限点处断开成多段 polyline,避免画穿渐近线。"""
- segments: list[str] = []
- points: list[str] = []
+ 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):
x = xmin + (xmax - xmin) * i / _SAMPLES
try:
@@ -121,85 +159,25 @@ def _polyline(
y = math.nan
if not isinstance(y, (int, float)) or not math.isfinite(y):
if points:
- segments.append(f' ')
+ segments.append(points)
points = []
continue
- px = sx(x)
- py = sy(y)
+ px = _sx(x, xmin, xmax)
+ py = _sy(y, ymin, ymax)
# 映射后的坐标必须有限:显式 range 下极端 y 值可能让像素坐标溢出为 inf
if not (math.isfinite(px) and math.isfinite(py)):
if points:
- segments.append(f' ')
+ segments.append(points)
points = []
continue
- points.append(f"{px:.2f},{py:.2f}")
+ points.append((px, py))
if points:
- segments.append(f' ')
- return "".join(segments)
+ segments.append(points)
+ return segments
-def _grid(
- xmin: float,
- xmax: float,
- ymin: float,
- ymax: float,
- sx: Callable[[float], float],
- sy: Callable[[float], float],
-) -> str:
- parts: list[str] = []
- for x in _ticks(xmin, xmax, _nice_step(xmax - xmin)):
- parts.append(f' ')
- for y in _ticks(ymin, ymax, _nice_step(ymax - ymin)):
- parts.append(f' ')
- return "".join(parts)
-
-
-def _axes(
- xmin: float,
- xmax: float,
- ymin: float,
- ymax: float,
- sx: Callable[[float], float],
- sy: Callable[[float], float],
-) -> str:
- parts: list[str] = []
- # 坐标轴:过原点则画在原点,否则贴边,保证始终有参照系
- x_axis_y = 0.0 if ymin <= 0 <= ymax else ymin
- y_axis_x = 0.0 if xmin <= 0 <= xmax else xmin
- parts.append(
- f' '
- )
- parts.append(
- f' '
- )
- # x 轴刻度数字(画在轴下方)
- for x in _ticks(xmin, xmax, _nice_step(xmax - xmin)):
- parts.append(
- f'{html.escape(_fmt_num(x))} '
- )
- # y 轴刻度数字(画在轴左侧)
- for y in _ticks(ymin, ymax, _nice_step(ymax - ymin)):
- parts.append(
- f'{html.escape(_fmt_num(y))} '
- )
- return "".join(parts)
-
-
-def _labels(plot: FunctionPlot, sx: Callable[[float], float], sy: Callable[[float], float]) -> str:
- parts: list[str] = []
- if plot.axes.xlabel:
- parts.append(
- f'{html.escape(plot.axes.xlabel)} '
- )
- if plot.axes.ylabel:
- parts.append(
- f'{html.escape(plot.axes.ylabel)} '
- )
- return "".join(parts)
-
-
-def render_svg(plot: FunctionPlot) -> StaticRenderResult:
- """把已解析的 FunctionPlot 渲染为内嵌 SVG。"""
+def compute_geometry(plot: FunctionPlot) -> PlotGeometry:
+ """解析并计算几何,供 SVG 与 reportlab 后端复用。"""
warnings: list[str] = []
xmin, xmax = plot.domain
if not _valid_span(xmin, xmax):
@@ -232,27 +210,125 @@ def render_svg(plot: FunctionPlot) -> StaticRenderResult:
warnings.append("y 范围跨度无法表示,回退到 [-10, 10]")
ymin, ymax = -10.0, 10.0
- def sx(x: float) -> float:
- return _MARGIN + (x - xmin) / (xmax - xmin) * (_WIDTH - 2 * _MARGIN)
+ x_axis_y = 0.0 if ymin <= 0 <= ymax else ymin
+ y_axis_x = 0.0 if xmin <= 0 <= xmax else xmin
+ xticks = _ticks(xmin, xmax, _nice_step(xmax - xmin))
+ yticks = _ticks(ymin, ymax, _nice_step(ymax - ymin))
- def sy(y: float) -> float:
- return _HEIGHT - _MARGIN - (y - ymin) / (ymax - ymin) * (_HEIGHT - 2 * _MARGIN)
-
- parts: list[str] = [
- f''
- ]
- if plot.axes.grid:
- parts.append(_grid(xmin, xmax, ymin, ymax, sx, sy))
- parts.append(_axes(xmin, xmax, ymin, ymax, sx, sy))
+ polylines: list[list[list[tuple[float, float]]]] = []
+ colors: list[str] = []
for i, (expr, tree) in enumerate(fns):
color = _safe_color(expr.color, _PALETTE[i % len(_PALETTE)])
- parts.append(_polyline(tree, xmin, xmax, sx, sy, color))
- parts.append(_labels(plot, sx, sy))
+ colors.append(color)
+ polylines.append(_sample_segments(tree, xmin, xmax, ymin, ymax))
+
+ return PlotGeometry(
+ width=_WIDTH,
+ height=_HEIGHT,
+ xmin=xmin,
+ xmax=xmax,
+ ymin=ymin,
+ ymax=ymax,
+ x_axis_y=x_axis_y,
+ y_axis_x=y_axis_x,
+ xticks=xticks,
+ yticks=yticks,
+ polylines=polylines,
+ colors=colors,
+ xlabel=plot.axes.xlabel,
+ ylabel=plot.axes.ylabel,
+ grid=plot.axes.grid,
+ warnings=warnings,
+ )
+
+
+# --- SVG 序列化(与 compute_geometry 共用,保证字节级稳定) ---
+def _grid_svg(geo: PlotGeometry) -> str:
+ sx = lambda x: _sx(x, geo.xmin, geo.xmax)
+ sy = lambda y: _sy(y, geo.ymin, geo.ymax)
+ parts: list[str] = []
+ for x in geo.xticks:
+ parts.append(
+ f' '
+ )
+ for y in geo.yticks:
+ parts.append(
+ f' '
+ )
+ return "".join(parts)
+
+
+def _axes_svg(geo: PlotGeometry) -> str:
+ sx = lambda x: _sx(x, geo.xmin, geo.xmax)
+ sy = lambda y: _sy(y, geo.ymin, geo.ymax)
+ parts: list[str] = []
+ # 坐标轴:过原点则画在原点,否则贴边,保证始终有参照系
+ parts.append(
+ f' '
+ )
+ parts.append(
+ f' '
+ )
+ # x 轴刻度数字(画在轴下方)
+ for x in geo.xticks:
+ parts.append(
+ f'{html.escape(_fmt_num(x))} '
+ )
+ # y 轴刻度数字(画在轴左侧)
+ for y in geo.yticks:
+ parts.append(
+ f'{html.escape(_fmt_num(y))} '
+ )
+ return "".join(parts)
+
+
+def _polylines_svg(geo: PlotGeometry) -> str:
+ parts: list[str] = []
+ for segments, color in zip(geo.polylines, geo.colors):
+ for seg in segments:
+ points = " ".join(f"{px:.2f},{py:.2f}" for px, py in seg)
+ parts.append(f' ')
+ return "".join(parts)
+
+
+def _labels_svg(geo: PlotGeometry) -> str:
+ parts: list[str] = []
+ if geo.xlabel:
+ parts.append(
+ f'{html.escape(geo.xlabel)} '
+ )
+ if geo.ylabel:
+ parts.append(
+ f''
+ f'{html.escape(geo.ylabel)} '
+ )
+ return "".join(parts)
+
+
+def render_svg(plot: FunctionPlot) -> StaticRenderResult:
+ """把已解析的 FunctionPlot 渲染为内嵌 SVG。"""
+ geo = compute_geometry(plot)
+ parts: list[str] = [
+ f''
+ ]
+ if geo.grid:
+ parts.append(_grid_svg(geo))
+ parts.append(_axes_svg(geo))
+ parts.append(_polylines_svg(geo))
+ parts.append(_labels_svg(geo))
parts.append(" ")
return StaticRenderResult(
content="".join(parts),
- width=_WIDTH,
- height=_HEIGHT,
- warnings=warnings,
+ width=geo.width,
+ height=geo.height,
+ warnings=geo.warnings,
)
diff --git a/backend/app/plot/render_reportlab.py b/backend/app/plot/render_reportlab.py
new file mode 100644
index 0000000..03a6819
--- /dev/null
+++ b/backend/app/plot/render_reportlab.py
@@ -0,0 +1,116 @@
+"""Function Plot → reportlab 矢量 Drawing(供 PDF 内嵌)。
+
+消费 ``render.compute_geometry`` 的共享几何,产出 ``reportlab.graphics.shapes.Drawing``:
+网格/坐标轴用 ``Line``、曲线用 ``PolyLine``、刻度数字与轴标签用 ``String``。
+reportlab 原点在左下(y-up),与 SVG 的 y-down 相反,故对几何里的像素 y 统一翻转;
+轴标签(ylabel)用 ``Group.rotate`` 旋转为竖向文本。中文字体复用内置 STSong-Light,
+guarded 注册避免与 pdf.py 重复注册。
+"""
+
+from __future__ import annotations
+
+from reportlab.graphics.shapes import Drawing, Group, Line, PolyLine, String
+from reportlab.lib.colors import HexColor
+from reportlab.pdfbase import pdfmetrics
+from reportlab.pdfbase.cidfonts import UnicodeCIDFont
+
+from app.plot.model import FunctionPlot
+from app.plot.render import PlotGeometry, _fmt_num, _sx, _sy, compute_geometry
+
+_FONT = "STSong-Light"
+if _FONT not in pdfmetrics.getRegisteredFontNames():
+ pdfmetrics.registerFont(UnicodeCIDFont(_FONT))
+
+_GRID_COLOR = HexColor("#eaeef2")
+_AXIS_COLOR = HexColor("#57606a")
+_LABEL_COLOR = HexColor("#1f2328")
+_TICK_FONT_SIZE = 10
+_LABEL_FONT_SIZE = 12
+
+
+def _build_drawing(geo: PlotGeometry) -> Drawing:
+ """由共享几何构建矢量 Drawing(坐标翻转后仍沿用 SVG 的像素布局)。"""
+ drawing = Drawing(geo.width, geo.height)
+
+ # SVG y-down → reportlab y-up:翻转像素 y
+ def sx(x: float) -> float:
+ return _sx(x, geo.xmin, geo.xmax)
+
+ def sy(y: float) -> float:
+ return geo.height - _sy(y, geo.ymin, geo.ymax)
+
+ # 网格
+ if geo.grid:
+ for x in geo.xticks:
+ drawing.add(
+ Line(sx(x), sy(geo.ymin), sx(x), sy(geo.ymax), strokeColor=_GRID_COLOR, strokeWidth=0.5)
+ )
+ for y in geo.yticks:
+ drawing.add(
+ Line(sx(geo.xmin), sy(y), sx(geo.xmax), sy(y), strokeColor=_GRID_COLOR, strokeWidth=0.5)
+ )
+
+ # 坐标轴(过原点画在原点,否则贴边,与 SVG 一致)
+ drawing.add(
+ Line(sx(geo.xmin), sy(geo.x_axis_y), sx(geo.xmax), sy(geo.x_axis_y), strokeColor=_AXIS_COLOR, strokeWidth=0.7)
+ )
+ drawing.add(
+ Line(sx(geo.y_axis_x), sy(geo.ymin), sx(geo.y_axis_x), sy(geo.ymax), strokeColor=_AXIS_COLOR, strokeWidth=0.7)
+ )
+
+ # 刻度数字(x 轴下方、y 轴左侧)
+ for x in geo.xticks:
+ drawing.add(
+ String(
+ sx(x), sy(geo.x_axis_y) - 14, _fmt_num(x),
+ fontName=_FONT, fontSize=_TICK_FONT_SIZE, fillColor=_AXIS_COLOR, textAnchor="middle",
+ )
+ )
+ for y in geo.yticks:
+ drawing.add(
+ String(
+ sx(geo.y_axis_x) - 6, sy(y) - 3, _fmt_num(y),
+ fontName=_FONT, fontSize=_TICK_FONT_SIZE, fillColor=_AXIS_COLOR, textAnchor="end",
+ )
+ )
+
+ # 曲线(非有限点处已由几何断成多段)
+ for segments, color in zip(geo.polylines, geo.colors):
+ for seg in segments:
+ flipped = [(px, geo.height - py) for px, py in seg]
+ drawing.add(PolyLine(flipped, strokeColor=HexColor(color), strokeWidth=1.4))
+
+ # 轴标签
+ if geo.xlabel:
+ drawing.add(
+ String(
+ geo.width / 2, 10, geo.xlabel,
+ fontName=_FONT, fontSize=_LABEL_FONT_SIZE, fillColor=_LABEL_COLOR, textAnchor="middle",
+ )
+ )
+ if geo.ylabel:
+ # 竖向标签:rotate(90) 在 y-up 坐标下等价于 SVG 的 rotate(-90)
+ label = Group()
+ label.add(
+ String(
+ 16, geo.height / 2, geo.ylabel,
+ fontName=_FONT, fontSize=_LABEL_FONT_SIZE, fillColor=_LABEL_COLOR, textAnchor="middle",
+ )
+ )
+ label.rotate(90, 16, geo.height / 2)
+ drawing.add(label)
+
+ return drawing
+
+
+def render_drawing(plot: FunctionPlot, width: float | None = None) -> Drawing:
+ """把已解析的 FunctionPlot 渲染为 reportlab Drawing(可直接追加到 platypus story)。
+
+ ``width`` 为目标输出宽度(点),用于把 640px 的几何缩放到页面内容宽;省略则按
+ 原始尺寸输出。缩放只影响 PDF 渲染,不改动共享几何。
+ """
+ geo = compute_geometry(plot)
+ drawing = _build_drawing(geo)
+ if width is not None and width > 0:
+ drawing.renderScale = min(1.0, width / geo.width)
+ return drawing
diff --git a/backend/tests/test_export.py b/backend/tests/test_export.py
index 2e3b353..cef356a 100644
--- a/backend/tests/test_export.py
+++ b/backend/tests/test_export.py
@@ -311,16 +311,50 @@ def test_export_docx_completes_with_zip_magic_bytes() -> None:
assert path.read_bytes()[:2] == b"PK"
-def test_pdf_exporter_marks_plot_and_mermaid_as_placeholders() -> None:
+def test_pdf_exporter_embeds_function_plot_and_marks_mermaid() -> None:
from app.export.exporters.pdf import PdfExporter
md = "```mermaid\ngraph LR\n```\n\n```function_plot\ny = x\n```"
result = asyncio.run(PdfExporter().export(parse_document(md), ExportOptions()))
assert result.content[:4] == b"%PDF"
assert any("mermaid" in w for w in result.warnings)
+ # function_plot 已内嵌为矢量图,不再产生「函数图像占位」warning
+ assert not any("函数图像" in w for w in result.warnings)
+ # 绘图用 STSong-Light 渲染刻度/标签,字体应嵌入 PDF
+ assert b"STSong-Light" in result.content
+
+
+def test_pdf_exporter_function_plot_fallback_on_error() -> None:
+ from app.export.exporters.pdf import PdfExporter
+
+ # 解析失败(不安全表达式)应回退源码占位并记 warning,不阻断整篇导出
+ md = "```function_plot\ny = os.system('x')\n```"
+ result = asyncio.run(PdfExporter().export(parse_document(md), ExportOptions()))
+ assert result.content[:4] == b"%PDF"
assert any("函数图像" in w for w in result.warnings)
+def test_pdf_exporter_limits_function_plot_count() -> None:
+ from app.export.exporters.pdf import PdfExporter
+
+ blocks = "\n\n".join("```function-plot\ny = x\n```" for _ in range(20))
+ result = asyncio.run(PdfExporter().export(parse_document(blocks), ExportOptions()))
+ assert result.content[:4] == b"%PDF"
+ # 超出数量上限的图块回退占位并记 warning
+ assert any("数量超过上限" in w for w in result.warnings)
+
+
+def test_pdf_exporter_limits_total_plot_nodes(monkeypatch) -> None:
+ import app.export.exporters._common as common_mod
+ from app.export.exporters.pdf import PdfExporter
+
+ monkeypatch.setattr(common_mod, "MAX_TOTAL_PLOT_NODES", 5)
+ md = "```function-plot\ny = x\n```\n\n```function-plot\ny = x + x + x + x\n```"
+ result = asyncio.run(PdfExporter().export(parse_document(md), ExportOptions()))
+ assert result.content[:4] == b"%PDF"
+ assert any("累计复杂度" in w for w in result.warnings)
+
+
def test_docx_exporter_marks_plot_and_mermaid_as_placeholders() -> None:
from app.export.exporters.docx import DocxExporter
diff --git a/backend/tests/test_plot.py b/backend/tests/test_plot.py
index 7d5da77..358af3a 100644
--- a/backend/tests/test_plot.py
+++ b/backend/tests/test_plot.py
@@ -278,9 +278,9 @@ def test_html_exporter_limits_function_plot_count() -> None:
def test_html_exporter_limits_total_plot_nodes(monkeypatch) -> None:
# P1:文档级累计 AST 节点预算超限后,后续图像回退占位,防止组合复杂度耗尽 CPU
- import app.export.exporters.html as html_mod
+ import app.export.exporters._common as common_mod
- monkeypatch.setattr(html_mod, "_MAX_TOTAL_PLOT_NODES", 5)
+ monkeypatch.setattr(common_mod, "MAX_TOTAL_PLOT_NODES", 5)
# 第一个图块 y=x(1 节点)在预算内;第二个图块 y=x+x+x+x(7 节点)累计超限
md = "```function-plot\ny = x\n```\n\n```function-plot\ny = x + x + x + x\n```"
result = asyncio.run(HtmlExporter().export(parse_document(md), ExportOptions()))
@@ -322,3 +322,64 @@ def test_mermaid_static_renderer_returns_placeholder() -> None:
result = renderer.render(StaticRenderRequest(kind="mermaid", source="graph LR"))
assert result.content == ""
assert any("mermaid" in w for w in result.warnings)
+
+
+# --------------------------------------------------------------------------- #
+# 共享几何与 reportlab 后端(PDF 内嵌函数图像)
+# --------------------------------------------------------------------------- #
+def test_compute_geometry_shares_pixel_segments() -> None:
+ from app.plot.render import compute_geometry
+
+ plot = parse_source("y = x^2\ny = sin(x)").plot
+ geo = compute_geometry(plot)
+ assert geo.width == 640
+ assert geo.height == 480
+ assert len(geo.polylines) == 2
+ assert geo.colors == ["#0969da", "#d1242f"]
+ assert geo.xticks and geo.yticks
+ for segments in geo.polylines:
+ assert segments
+ for seg in segments:
+ assert seg
+ for px, py in seg:
+ assert math.isfinite(px) and math.isfinite(py)
+ assert 0 <= px <= geo.width
+ assert 0 <= py <= geo.height
+
+
+def test_render_reportlab_builds_drawing() -> None:
+ from reportlab.graphics.shapes import Drawing, Group, Line, PolyLine, String
+
+ from app.plot.render_reportlab import render_drawing
+
+ plot = parse_source("xlabel: 时间\nylabel: 数值\ny = x^2").plot
+ drawing = render_drawing(plot, width=480)
+ assert isinstance(drawing, Drawing)
+ assert drawing.renderScale == 0.75 # 480 / 640
+ kinds = {type(c).__name__ for c in drawing.contents}
+ assert {"Line", "PolyLine", "String", "Group"} <= kinds
+ strings = [c for c in drawing.contents if isinstance(c, String)]
+ assert any(s.fontName == "STSong-Light" for s in strings)
+ assert any(s.text == "时间" for s in strings)
+ # ylabel 在旋转 Group 内
+ groups = [c for c in drawing.contents if isinstance(c, Group)]
+ assert groups
+ group_texts = [s.text for g in groups for s in g.contents if isinstance(s, String)]
+ assert "数值" in group_texts
+
+
+def test_render_reportlab_curves_are_finite_and_bounded() -> None:
+ from reportlab.graphics.shapes import PolyLine
+
+ from app.plot.render_reportlab import render_drawing
+
+ plot = parse_source("y = x").plot
+ drawing = render_drawing(plot)
+ polylines = [c for c in drawing.contents if isinstance(c, PolyLine)]
+ assert polylines
+ for pl in polylines:
+ pts = pl.points # 扁平 [x0,y0,x1,y1,...]
+ for x, y in zip(pts[0::2], pts[1::2]):
+ assert math.isfinite(x) and math.isfinite(y)
+ assert 0 <= x <= 640
+ assert 0 <= y <= 480
diff --git a/docs/contracts/第二阶段接口契约-开发版.md b/docs/contracts/第二阶段接口契约-开发版.md
index 55ce34e..39f3547 100644
--- a/docs/contracts/第二阶段接口契约-开发版.md
+++ b/docs/contracts/第二阶段接口契约-开发版.md
@@ -74,7 +74,7 @@
| Export | GET | `/api/exports/{job_id}/file` | 已实现 | 下载已完成产物 |
| Export | POST | `/api/exports/{job_id}/cancel` | 已实现 | 取消导出任务 |
| Theme | Host Contract | `ThemePackageService` | 计划新增 | 导入、预览、启停和卸载主题包 |
-| Renderer | 内部 Contract | `StaticRenderer` | 已实现 | Function Plot 后端静态 SVG 渲染;Mermaid 返回占位;PDF/DOCX 中两者保留源码占位 |
+| Renderer | 内部 Contract | `StaticRenderer` | 已实现 | Function Plot 后端静态 SVG 渲染 + PDF 矢量内嵌(共享几何);Mermaid 返回占位;DOCX 保留源码占位 |
---
@@ -1080,7 +1080,7 @@ VECTOR_INDEX_REBUILD_REQUIRED
## 10. Export Service
-> 实现状态:HTML / PDF / DOCX 导出均已实现(`backend/app/export/`),`format` 支持 `html`/`pdf`/`docx` 三格式。`function-plot` 已支持静态 SVG 内嵌(`backend/app/plot/`),解析或渲染失败时回退为源码占位并记录 warning;Mermaid 目前仍以占位代码块保留并记 warning。PDF/DOCX 为文本优先 v1,`function-plot` 与 Mermaid 保留源码占位并记 warning。
+> 实现状态:HTML / PDF / DOCX 导出均已实现(`backend/app/export/`),`format` 支持 `html`/`pdf`/`docx` 三格式。`function-plot` 已支持静态 SVG 内嵌(HTML)与矢量图内嵌(PDF,经 `backend/app/plot/render_reportlab.py` 复用共享几何),解析或渲染失败时回退为源码占位并记录 warning;Mermaid 目前仍以占位代码块保留并记 warning。DOCX 为文本优先 v1,`function-plot` 与 Mermaid 保留源码占位并记 warning。
### 10.1 创建导出任务
diff --git a/docs/development/Export开发说明.md b/docs/development/Export开发说明.md
index e8711d8..249e440 100644
--- a/docs/development/Export开发说明.md
+++ b/docs/development/Export开发说明.md
@@ -15,16 +15,17 @@ backend/app/export/
├── markdown.py mistune 'ast' renderer → Document AST
├── exporters/
│ ├── __init__.py
-│ ├── _common.py 共享工具(URL 协议校验 + 占位 warning 文案 + 元数据格式化)
+│ ├── _common.py 共享工具(URL 协议校验 + 函数图像预算 + 占位 warning 文案 + 元数据格式化)
│ ├── html.py HtmlExporter(Document AST → 完整 HTML5)
│ ├── pdf.py PdfExporter(Document AST → PDF,reportlab)
│ └── docx.py DocxExporter(Document AST → DOCX,python-docx)
└── service.py ExportService(注册表 + 后台渲染 + 取消 + 产物生命周期)
backend/app/plot/
-├── parser.py 函数图像表达式解析(白名单 AST)
-├── render.py FunctionPlot → 静态 SVG
-└── renderer.py StaticRenderer 内部契约(§10.4)
+├── parser.py 函数图像表达式解析(白名单 AST)
+├── render.py FunctionPlot → 共享几何(compute_geometry)+ 静态 SVG
+├── render_reportlab.py FunctionPlot → reportlab 矢量 Drawing(PDF 内嵌)
+└── renderer.py StaticRenderer 内部契约(§10.4)
```
HTTP DTO(`ExportStatus` / `ExportFormat` / `ExportSource` / `ExportOptions` / `ExportJob` 等)放在 [app/contracts.py](../../backend/app/contracts.py),与 Benchmark DTO 同层;`DocumentNode` / `ExportResult` 属导出器内部协议,放在 `export/document.py`,不进入 HTTP 契约。
@@ -62,9 +63,10 @@ fenced code 按语言分流:`mermaid` → `mermaid` 节点、`function_plot`/`
## PDF / DOCX 导出器(v1 文本优先)
-`PdfExporter`(reportlab platypus)与 `DocxExporter`(python-docx)实现与 HtmlExporter 一致的同步 `render(document, options) -> ExportResult` + 异步 `export`。v1 为文本优先,覆盖标题/段落/行内强调与链接/列表/引用/表格/代码块/数学文本;`function_plot` 与 `mermaid` 保留源码占位并记 warning(与现有 Mermaid 处理一致)。
+`PdfExporter`(reportlab platypus)与 `DocxExporter`(python-docx)实现与 HtmlExporter 一致的同步 `render(document, options) -> ExportResult` + 异步 `export`。v1 为文本优先,覆盖标题/段落/行内强调与链接/列表/引用/表格/代码块/数学文本;`mermaid` 保留源码占位并记 warning。`function_plot` 在 PDF 中已内嵌为矢量图,在 DOCX 中仍保留源码占位并记 warning(DOCX 内嵌需栅格化,本轮范围外)。
- PDF 中文字体用 reportlab 内置 `STSong-Light` CID 字体,无外部字体依赖;CID 字体无独立 bold/italic 字重,行内强调退化为普通文本(内容不丢、样式简化),标题靠字号区分层级。
+- PDF 的 `function_plot` 经 `render_reportlab` 消费 `compute_geometry` 的共享几何,产出矢量 `Drawing`(网格/坐标轴 `Line`、曲线 `PolyLine`、刻度/标签 `String`,ylabel 用 `Group` 旋转),再按页面内容宽缩放追加到 story,与 HTML 的 SVG 视觉一致;解析/渲染失败或超预算时回退源码占位并记 warning,单图失败不阻断整篇。
- DOCX 通过 Normal 样式挂载 `w:eastAsia=宋体` 保证中文显示,bold/italic 由 Word 原生渲染;链接写入可点击的 `w:hyperlink` run。
- 扩展名/MIME:html→`.html`/`text/html`,pdf→`.pdf`/`application/pdf`,docx→`.docx`/`application/vnd.openxmlformats-officedocument.wordprocessingml.document`;路由 `FileResponse` 按 `mime_type` + `file_name` 通用化,无需改路由。
@@ -121,10 +123,11 @@ cd backend
uv run pytest -q
```
-`tests/test_export.py` 覆盖 Markdown 解析(标题/行内/列表/代码分流/表格/数学)、HTML 渲染(标签 + 转义 + warning)、Service 端到端(note 源与 markdown 源、PDF/DOCX 魔法字节与 CJK 字体、引用块正文与嵌套列表顺序等结构内容回归、排队任务取消、未知 note、取消、list/get、过期 410)与 `ExportSource` 契约校验。`tests/test_plot.py` 覆盖表达式解析/求值、SVG 渲染与 `StaticRenderer` 契约(函数图像渲染、Mermaid 占位)。
+`tests/test_export.py` 覆盖 Markdown 解析(标题/行内/列表/代码分流/表格/数学)、HTML 渲染(标签 + 转义 + warning)、Service 端到端(note 源与 markdown 源、PDF/DOCX 魔法字节与 CJK 字体、引用块正文与嵌套列表顺序等结构内容回归、排队任务取消、未知 note、取消、list/get、过期 410)与 `ExportSource` 契约校验。`tests/test_plot.py` 覆盖表达式解析/求值、SVG 渲染、共享几何 `compute_geometry`、`render_reportlab` 矢量 Drawing(Line/PolyLine/String/Group、CJK 字体、y 翻转、缩放)与 `StaticRenderer` 契约(函数图像渲染、Mermaid 占位)。
## 范围外(后续 PR)
-- PDF 内嵌函数图像与 Mermaid 渲染(v1 仅源码占位)。
+- Mermaid 静态渲染(后端无渲染能力,HTML/PDF/DOCX 均保留源码占位)。
+- DOCX 内嵌函数图像(需栅格化为 PNG,本轮范围外,仅 PDF 内嵌矢量图)。
- 函数图像交互预览与缩放(前端 JS Renderer 负责,后端仅提供静态 SVG)。
- 代码语法高亮(当前仅 CSS class 占位)。