Complete phase two benchmarks, plot previews and static export workflow
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user