Complete phase two benchmarks, plot previews and static export workflow

This commit is contained in:
2026-09-07 02:54:52 +08:00
parent 95095197df
commit 89df10bc4e
59 changed files with 2535 additions and 83 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
"""Function Plot 内部数据模型。
契约 §12.2 的 FunctionPlot 结构与 §10.4 的 StaticRenderResult 只在导出链路的后端内部
流转,不进入 HTTP 契约,因此与 Document AST 一样放在独立包内,不进 contracts.py
FunctionPlot 供预览和导出共享;StaticRenderResult 同时是交互预览端点的响应内容。
模型保留在独立包内,由 plot_routes 中的请求与响应类型注册 OpenAPI
"""
from __future__ import annotations
+31 -10
View File
@@ -413,12 +413,12 @@ def _grid_svg(geo: PlotGeometry) -> 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"/>'
f'y2="{sy(geo.ymax):.2f}" stroke="#eaeef2" class="plot-grid"/>'
)
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"/>'
f'y2="{sy(y):.2f}" stroke="#eaeef2" class="plot-grid"/>'
)
return "".join(parts)
@@ -430,11 +430,11 @@ def _axes_svg(geo: PlotGeometry) -> 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"/>'
f'y2="{sy(geo.x_axis_y):.2f}" stroke="#57606a" class="plot-axis"/>'
)
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"/>'
f'y2="{sy(geo.ymax):.2f}" stroke="#57606a" class="plot-axis"/>'
)
# x 轴刻度数字(画在轴下方)
for x in geo.xticks:
@@ -453,10 +453,10 @@ def _axes_svg(geo: PlotGeometry) -> str:
def _polylines_svg(geo: PlotGeometry) -> str:
parts: list[str] = []
for segments, color in zip(geo.polylines, geo.colors):
for index, (segments, color) in enumerate(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}"/>')
parts.append(f'<polyline points="{points}" fill="none" stroke="{color}" class="plot-curve-{index % 6}"/>')
return "".join(parts)
@@ -476,22 +476,43 @@ def _labels_svg(geo: PlotGeometry) -> str:
return "".join(parts)
def render_svg(plot: FunctionPlot) -> StaticRenderResult:
def render_svg(plot: FunctionPlot, theme_id: str = 'light') -> StaticRenderResult:
"""把已解析的 FunctionPlot 渲染为内嵌 SVG。"""
geo = compute_geometry(plot)
legend_height = ((len(plot.expressions) + 1) // 2) * 24
height = geo.height + legend_height
parts: list[str] = [
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {geo.width} {geo.height}" role="img">'
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {geo.width} {height}" role="img" class="function-plot-svg">'
]
if geo.grid:
parts.append(_grid_svg(geo))
parts.append(_axes_svg(geo))
parts.append(_polylines_svg(geo))
parts.append(_labels_svg(geo))
for index, expression in enumerate(plot.expressions):
x = 24 + (index % 2) * 310
y = geo.height + 18 + (index // 2) * 24
label = html.escape(expression.label or ('y = ' + expression.expression))
parts.append(f'<text x="{x}" y="{y}" font-size="12" fill="{geo.colors[index]}" class="plot-legend-{index % 6}">{label}</text>')
parts.append("</svg>")
return StaticRenderResult(
content="".join(parts),
content=theme_svg("".join(parts), theme_id),
width=geo.width,
height=geo.height,
height=height,
warnings=geo.warnings,
)
def theme_svg(svg: str, theme_id: str) -> str:
from app.export.themes import PALETTES
palette = PALETTES.get(theme_id, PALETTES['light'])
for source, target in [('#eaeef2', palette[5]), ('#57606a', palette[3]), ('#1f2328', palette[2])]:
svg = svg.replace(source, target)
if theme_id in {'dark', 'midnight-purple'}:
for source, target in zip(_PALETTE, ['#79c0ff','#ff9b9b','#7ee787','#d2a8ff','#f2cc60','#ffa657']):
svg = svg.replace(source, target)
background = '<rect width="100%" height="100%" fill="' + palette[1] + '"/>'
if re.search(r'<rect width="100%" height="100%" fill="[^"]*"/>', svg):
return re.sub(r'<rect width="100%" height="100%" fill="[^"]*"/>', background, svg, count=1)
return svg.replace('role="img" class="function-plot-svg">', 'role="img" class="function-plot-svg">' + background)
+6 -3
View File
@@ -17,9 +17,7 @@ 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))
from app.export.fonts import FONT as _FONT
_GRID_COLOR = HexColor("#eaeef2")
_AXIS_COLOR = HexColor("#57606a")
@@ -115,6 +113,11 @@ def render_drawing(plot: FunctionPlot, width: float | None = None) -> Drawing:
"""
geo = compute_geometry(plot)
drawing = _build_drawing(geo)
legend_height = ((len(plot.expressions)+1)//2)*24
drawing.height += legend_height
for index, expression in enumerate(plot.expressions):
drawing.add(String(24+(index%2)*310,geo.height+legend_height-18-(index//2)*24,
expression.label or 'y = '+expression.expression,fontName=_FONT,fontSize=12,fillColor=HexColor(geo.colors[index])))
if width is not None and width > 0:
drawing.renderScale = min(1.0, width / geo.width)
return drawing
+1 -1
View File
@@ -47,7 +47,7 @@ class FunctionPlotStaticRenderer:
parsed = self.parse(request)
if parsed.plot is None:
raise ValueError("function-plot source has no valid plot")
return self.render_plot(parsed.plot)
return render_svg(parsed.plot, request.theme or 'light')
def render_plot(self, plot: FunctionPlot) -> StaticRenderResult:
return render_svg(plot)