feat(export): PDF 内嵌函数图像矢量图
- 抽取 render.py 共享几何:新增 PlotGeometry + compute_geometry,render_svg 改为薄序列化层,SVG 输出与重构前逐字节一致(8 组用例回归验证) - 新增 app/plot/render_reportlab.py:消费共享几何产出 reportlab 矢量 Drawing (网格/坐标轴 Line、曲线 PolyLine、刻度/标签 String、ylabel Group 旋转), 复用 STSong-Light 渲染中文,按页面内容宽 renderScale 缩放 - pdf.py _block_function_plot 改为内嵌矢量图(解析/渲染失败或超预算回退占位, 单图失败不阻断整篇);mermaid 仍占位 - 抽取 FunctionPlotBudget + format_plot_diagnostic 到 _common.py,html/pdf 共用 - 文档同步:PDF 已内嵌函数图像,DOCX 仍占位(栅格化范围外) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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。"""
|
||||
|
||||
@@ -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'<pre class="mermaid">{html.escape(node.text)}</pre>'
|
||||
|
||||
@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'<pre class="function-plot">{html.escape(node.text)}</pre>'
|
||||
# 解析与渲染共同纳入局部异常回退:单个图像失败只回退占位 + 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'<pre class="function-plot">{html.escape(node.text)}</pre>'
|
||||
# 文档级累计复杂度预算:超出后回退占位,不再采样求值
|
||||
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'<pre class="function-plot">{html.escape(node.text)}</pre>'
|
||||
self._plot_nodes += parsed.plot.node_count
|
||||
rendered = self._plot_renderer.render_plot(parsed.plot)
|
||||
except Exception as exc:
|
||||
warnings.append(f"函数图像:解析或渲染失败,已回退占位({exc})")
|
||||
|
||||
@@ -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"]))
|
||||
|
||||
+171
-95
@@ -1,7 +1,11 @@
|
||||
"""Function Plot → 静态 SVG 渲染。
|
||||
"""Function Plot → 静态 SVG 渲染 + 共享几何计算。
|
||||
|
||||
只输出纯几何与 <text> 的 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'<polyline points="{" ".join(points)}" fill="none" stroke="{color}"/>')
|
||||
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'<polyline points="{" ".join(points)}" fill="none" stroke="{color}"/>')
|
||||
segments.append(points)
|
||||
points = []
|
||||
continue
|
||||
points.append(f"{px:.2f},{py:.2f}")
|
||||
points.append((px, py))
|
||||
if points:
|
||||
segments.append(f'<polyline points="{" ".join(points)}" fill="none" stroke="{color}"/>')
|
||||
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'<line x1="{sx(x):.2f}" y1="{sy(ymin):.2f}" x2="{sx(x):.2f}" y2="{sy(ymax):.2f}" stroke="#eaeef2"/>')
|
||||
for y in _ticks(ymin, ymax, _nice_step(ymax - ymin)):
|
||||
parts.append(f'<line x1="{sx(xmin):.2f}" y1="{sy(y):.2f}" x2="{sx(xmax):.2f}" y2="{sy(y):.2f}" stroke="#eaeef2"/>')
|
||||
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'<line x1="{sx(xmin):.2f}" y1="{sy(x_axis_y):.2f}" x2="{sx(xmax):.2f}" y2="{sy(x_axis_y):.2f}" stroke="#57606a"/>'
|
||||
)
|
||||
parts.append(
|
||||
f'<line x1="{sx(y_axis_x):.2f}" y1="{sy(ymin):.2f}" x2="{sx(y_axis_x):.2f}" y2="{sy(ymax):.2f}" stroke="#57606a"/>'
|
||||
)
|
||||
# x 轴刻度数字(画在轴下方)
|
||||
for x in _ticks(xmin, xmax, _nice_step(xmax - xmin)):
|
||||
parts.append(
|
||||
f'<text x="{sx(x):.2f}" y="{sy(x_axis_y) + 14:.2f}" text-anchor="middle" font-size="10" fill="#57606a">{html.escape(_fmt_num(x))}</text>'
|
||||
)
|
||||
# y 轴刻度数字(画在轴左侧)
|
||||
for y in _ticks(ymin, ymax, _nice_step(ymax - ymin)):
|
||||
parts.append(
|
||||
f'<text x="{sx(y_axis_x) - 6:.2f}" y="{sy(y) + 3:.2f}" text-anchor="end" font-size="10" fill="#57606a">{html.escape(_fmt_num(y))}</text>'
|
||||
)
|
||||
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'<text x="{(_WIDTH / 2):.2f}" y="{_HEIGHT - 10:.2f}" text-anchor="middle" font-size="12" fill="#1f2328">{html.escape(plot.axes.xlabel)}</text>'
|
||||
)
|
||||
if plot.axes.ylabel:
|
||||
parts.append(
|
||||
f'<text x="16" y="{(_HEIGHT / 2):.2f}" text-anchor="middle" font-size="12" fill="#1f2328" transform="rotate(-90 16 {_HEIGHT / 2:.2f})">{html.escape(plot.axes.ylabel)}</text>'
|
||||
)
|
||||
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'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {_WIDTH} {_HEIGHT}" role="img">'
|
||||
]
|
||||
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'<line x1="{sx(x):.2f}" y1="{sy(geo.ymin):.2f}" x2="{sx(x):.2f}" '
|
||||
f'y2="{sy(geo.ymax):.2f}" stroke="#eaeef2"/>'
|
||||
)
|
||||
for y in geo.yticks:
|
||||
parts.append(
|
||||
f'<line x1="{sx(geo.xmin):.2f}" y1="{sy(y):.2f}" x2="{sx(geo.xmax):.2f}" '
|
||||
f'y2="{sy(y):.2f}" stroke="#eaeef2"/>'
|
||||
)
|
||||
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'<line x1="{sx(geo.xmin):.2f}" y1="{sy(geo.x_axis_y):.2f}" x2="{sx(geo.xmax):.2f}" '
|
||||
f'y2="{sy(geo.x_axis_y):.2f}" stroke="#57606a"/>'
|
||||
)
|
||||
parts.append(
|
||||
f'<line x1="{sx(geo.y_axis_x):.2f}" y1="{sy(geo.ymin):.2f}" x2="{sx(geo.y_axis_x):.2f}" '
|
||||
f'y2="{sy(geo.ymax):.2f}" stroke="#57606a"/>'
|
||||
)
|
||||
# x 轴刻度数字(画在轴下方)
|
||||
for x in geo.xticks:
|
||||
parts.append(
|
||||
f'<text x="{sx(x):.2f}" y="{sy(geo.x_axis_y) + 14:.2f}" text-anchor="middle" '
|
||||
f'font-size="10" fill="#57606a">{html.escape(_fmt_num(x))}</text>'
|
||||
)
|
||||
# y 轴刻度数字(画在轴左侧)
|
||||
for y in geo.yticks:
|
||||
parts.append(
|
||||
f'<text x="{sx(geo.y_axis_x) - 6:.2f}" y="{sy(y) + 3:.2f}" text-anchor="end" '
|
||||
f'font-size="10" fill="#57606a">{html.escape(_fmt_num(y))}</text>'
|
||||
)
|
||||
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'<polyline points="{points}" fill="none" stroke="{color}"/>')
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _labels_svg(geo: PlotGeometry) -> str:
|
||||
parts: list[str] = []
|
||||
if geo.xlabel:
|
||||
parts.append(
|
||||
f'<text x="{geo.width / 2:.2f}" y="{geo.height - 10:.2f}" text-anchor="middle" '
|
||||
f'font-size="12" fill="#1f2328">{html.escape(geo.xlabel)}</text>'
|
||||
)
|
||||
if geo.ylabel:
|
||||
parts.append(
|
||||
f'<text x="16" y="{geo.height / 2:.2f}" text-anchor="middle" font-size="12" '
|
||||
f'fill="#1f2328" transform="rotate(-90 16 {geo.height / 2:.2f})">'
|
||||
f'{html.escape(geo.ylabel)}</text>'
|
||||
)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def render_svg(plot: FunctionPlot) -> StaticRenderResult:
|
||||
"""把已解析的 FunctionPlot 渲染为内嵌 SVG。"""
|
||||
geo = compute_geometry(plot)
|
||||
parts: list[str] = [
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {geo.width} {geo.height}" role="img">'
|
||||
]
|
||||
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("</svg>")
|
||||
|
||||
return StaticRenderResult(
|
||||
content="".join(parts),
|
||||
width=_WIDTH,
|
||||
height=_HEIGHT,
|
||||
warnings=warnings,
|
||||
width=geo.width,
|
||||
height=geo.height,
|
||||
warnings=geo.warnings,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user