fix: 修复社区暂存主题并为函数图例添加数学排版
CI / docs-check (push) Canceled after 0s
CI / backend-test (push) Canceled after 0s
CI / service-test (push) Canceled after 0s
CI / frontend-test (push) Canceled after 0s
CI / rust-core (push) Canceled after 0s

This commit is contained in:
2026-09-09 22:31:39 +08:00
parent e5d144056c
commit 51c592841d
9 changed files with 293 additions and 23 deletions
+9 -1
View File
@@ -114,6 +114,7 @@ def plot_png(plot):
"""按 SVG/PDF 共用的裁剪几何,以二倍分辨率生成 DOCX 图像。"""
from app.plot.render import compute_geometry, _sx, _sy, _fmt_num
from PIL import ImageDraw, ImageFont
from app.plot.math_label import expression_latex, render_math_mask
geo = compute_geometry(plot)
image = Image.new('RGB', (geo.width * 2, (geo.height + ((len(plot.expressions)+1)//2)*24) * 2), 'white')
draw = ImageDraw.Draw(image)
@@ -140,6 +141,13 @@ def plot_png(plot):
# 纵轴标题横排在左上边距,避免 CJK 文本在 Word 中旋转后不可读。
draw.text((24, 24), geo.ylabel, fill='#1f2328', font=font)
for index, expression in enumerate(plot.expressions):
draw.text((48+(index%2)*620,geo.height*2+index//2*48),expression.label or 'y = '+expression.expression,fill=geo.colors[index],font=font)
position = (48 + (index % 2) * 620, geo.height * 2 + 8 + (index // 2) * 48)
if expression.label:
draw.text(position, expression.label, fill=geo.colors[index], font=font)
else:
mask_width, mask_height, mask_bytes = render_math_mask(expression_latex(expression.expression))
mask = Image.frombytes('L', (mask_width, mask_height), mask_bytes)
ink = Image.new('RGB', mask.size, geo.colors[index])
image.paste(ink, position, mask)
out=BytesIO(); image.save(out,'PNG')
return out.getvalue(), geo.warnings
+189
View File
@@ -0,0 +1,189 @@
"""Safe AST-to-LaTeX conversion and vector math layout for plot labels."""
from __future__ import annotations
import ast
import html
import math
import threading
from dataclasses import dataclass
from functools import lru_cache
from matplotlib.font_manager import FontProperties
from matplotlib.mathtext import MathTextParser
from matplotlib.path import Path as MplPath
from app.plot.parser import parse_expression
_MATH_PARSER = MathTextParser("path")
_RASTER_PARSER = MathTextParser("agg")
_MATH_LOCK = threading.Lock()
def _number(value: int | float) -> str:
text = repr(value)
if "e" not in text.lower():
return text
mantissa, exponent = text.lower().split("e", 1)
return rf"{mantissa}\times 10^{{{int(exponent)}}}"
def _latex(node: ast.AST, parent_precedence: int = 0) -> str:
if isinstance(node, ast.Constant):
return _number(node.value)
if isinstance(node, ast.Name):
return r"\pi" if node.id == "pi" else node.id
if isinstance(node, ast.UnaryOp):
value = _latex(node.operand, 25)
result = ("-" if isinstance(node.op, ast.USub) else "+") + value
return rf"\left({result}\right)" if parent_precedence > 25 else result
if isinstance(node, ast.BinOp):
if isinstance(node.op, ast.Div):
return rf"\frac{{{_latex(node.left)}}}{{{_latex(node.right)}}}"
if isinstance(node.op, ast.Pow):
result = rf"{{{_latex(node.left, 30)}}}^{{{_latex(node.right)}}}"
return rf"\left({result}\right)" if parent_precedence > 30 else result
precedence = 20 if isinstance(node.op, ast.Mult) else 10
operator = r" \cdot " if isinstance(node.op, ast.Mult) else (" + " if isinstance(node.op, ast.Add) else " - ")
left = _latex(node.left, precedence)
right = _latex(node.right, precedence + (1 if isinstance(node.op, ast.Sub) else 0))
result = left + operator + right
return rf"\left({result}\right)" if parent_precedence > precedence else result
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
argument = _latex(node.args[0])
name = node.func.id
if name == "sqrt":
return rf"\sqrt{{{argument}}}"
if name == "abs":
return rf"\left|{argument}\right|"
if name in {"log10", "log2"}:
return rf"\log_{{{name[3:]}}}\left({argument}\right)"
if name in {"asin", "acos", "atan"}:
return rf"\{name[1:]}^{{-1}}\left({argument}\right)"
command = "log" if name == "ln" else name
return rf"\{command}\left({argument}\right)"
raise ValueError(f"Unsupported validated expression node: {type(node).__name__}")
def expression_latex(expression: str) -> str:
"""Convert one already-supported function expression to MathText-compatible LaTeX."""
return "y = " + _latex(parse_expression(expression).body)
@dataclass(frozen=True)
class VectorPath:
commands: tuple[tuple[str, tuple[float, ...]], ...]
@dataclass(frozen=True)
class MathLayout:
width: float
height: float
depth: float
paths: tuple[VectorPath, ...]
rects: tuple[tuple[float, float, float, float], ...]
def _offset(values: tuple[float, ...], x: float, y: float) -> tuple[float, ...]:
return tuple(value + (x if index % 2 == 0 else y) for index, value in enumerate(values))
@lru_cache(maxsize=256)
def math_layout(latex: str, size: float = 12.0) -> MathLayout:
"""Lay out LaTeX as reusable vector paths; calls are cached and serialized for FT2Font."""
with _MATH_LOCK:
parsed = _MATH_PARSER.parse(f"${latex}$", dpi=72, prop=FontProperties(size=size))
paths: list[VectorPath] = []
for font, font_size, _character, glyph, offset_x, offset_y in parsed.glyphs:
font.set_size(font_size, 72)
font.load_glyph(glyph)
vertices, codes = font.get_path()
commands: list[tuple[str, tuple[float, ...]]] = []
for values, code in MplPath(vertices, codes).iter_segments(curves=True, simplify=False):
command = {
MplPath.MOVETO: "M",
MplPath.LINETO: "L",
MplPath.CURVE3: "Q",
MplPath.CURVE4: "C",
MplPath.CLOSEPOLY: "Z",
}[code]
points = () if command == "Z" else _offset(tuple(float(value) for value in values), float(offset_x), float(offset_y))
commands.append((command, points))
paths.append(VectorPath(tuple(commands)))
rects = tuple(tuple(float(value) for value in rect) for rect in parsed.rects)
return MathLayout(float(parsed.width), float(parsed.height), float(parsed.depth), tuple(paths), rects)
def _svg_number(value: float) -> str:
if math.isclose(value, round(value), abs_tol=1e-8):
return str(int(round(value)))
return f"{value:.4f}".rstrip("0").rstrip(".")
def _svg_path(path: VectorPath) -> str:
return " ".join(command + (" " + " ".join(_svg_number(value) for value in values) if values else "") for command, values in path.commands)
def render_math_svg(latex: str, *, x: float, top: float, class_name: str, color: str) -> str:
"""Return a script-free SVG group containing MathText vector glyphs."""
layout = math_layout(latex)
baseline = top + layout.height - layout.depth
accessible = html.escape(latex, quote=True)
parts = [
f'<g class="{class_name} plot-math-label" fill="{color}" '
f'transform="translate({_svg_number(x)} {_svg_number(baseline)}) scale(1 -1)" '
f'aria-label="{accessible}" data-latex="{accessible}">'
]
parts.extend(f'<path d="{_svg_path(path)}"/>' for path in layout.paths)
for rx, ry, width, height in layout.rects:
parts.append(
f'<path d="M {_svg_number(rx)} {_svg_number(ry)} h {_svg_number(width)} '
f'v {_svg_number(height)} h -{_svg_number(width)} Z"/>'
)
parts.append("</g>")
return "".join(parts)
def render_math_reportlab(latex: str, *, x: float, visual_top: float, color: object):
"""Return a reportlab Group containing the same LaTeX glyph geometry as the SVG."""
from reportlab.graphics.shapes import Group, Path, Rect
layout = math_layout(latex)
baseline = visual_top - (layout.height - layout.depth)
group = Group()
for vector in layout.paths:
path = Path(fillColor=color, strokeColor=None)
current = (0.0, 0.0)
start = current
for command, values in vector.commands:
if command == "M":
current = (values[0], values[1]); start = current
path.moveTo(*current)
elif command == "L":
current = (values[0], values[1]); path.lineTo(*current)
elif command == "Q":
control, end = (values[0], values[1]), (values[2], values[3])
first = (current[0] + 2 * (control[0] - current[0]) / 3,
current[1] + 2 * (control[1] - current[1]) / 3)
second = (end[0] + 2 * (control[0] - end[0]) / 3,
end[1] + 2 * (control[1] - end[1]) / 3)
path.curveTo(*first, *second, *end); current = end
elif command == "C":
path.curveTo(*values); current = (values[4], values[5])
else:
path.closePath(); current = start
group.add(path)
for rx, ry, width, height in layout.rects:
group.add(Rect(rx, ry, width, height, fillColor=color, strokeColor=None))
group.translate(x, baseline)
return group
@lru_cache(maxsize=256)
def render_math_mask(latex: str, size: float = 12.0, dpi: float = 144.0) -> tuple[int, int, bytes]:
"""Rasterize LaTeX to an 8-bit alpha mask for DOCX/PNG export."""
with _MATH_LOCK:
parsed = _RASTER_PARSER.parse(f"${latex}$", dpi=dpi, prop=FontProperties(size=size))
image = parsed.image
height, width = image.shape
return int(width), int(height), image.tobytes()
+8 -3
View File
@@ -16,6 +16,7 @@ import re
from dataclasses import dataclass
from app.plot.model import FunctionPlot, StaticRenderResult
from app.plot.math_label import expression_latex, render_math_svg
from app.plot.parser import PlotParseError, evaluate, parse_expression
_WIDTH = 640
@@ -491,9 +492,13 @@ def render_svg(plot: FunctionPlot, theme_id: str = 'light', unlimited: bool = Fa
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>')
top = geo.height + 4 + (index // 2) * 24
if expression.label:
label = html.escape(expression.label)
parts.append(f'<text x="{x}" y="{top + 14}" font-size="12" fill="{geo.colors[index]}" class="plot-legend-{index % 6}">{label}</text>')
else:
parts.append(render_math_svg(expression_latex(expression.expression), x=x, top=top,
class_name=f"plot-legend-{index % 6}", color=geo.colors[index]))
parts.append("</svg>")
return StaticRenderResult(
+9 -2
View File
@@ -15,6 +15,7 @@ from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
from app.plot.model import FunctionPlot
from app.plot.math_label import expression_latex, render_math_reportlab
from app.plot.render import PlotGeometry, _fmt_num, _sx, _sy, compute_geometry
from app.export.fonts import FONT as _FONT
@@ -125,8 +126,14 @@ def render_drawing(plot: FunctionPlot, width: float | None = None, palette=None,
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])))
x = 24 + (index % 2) * 310
visual_top = drawing.height - 4 - (index // 2) * 24
if expression.label:
drawing.add(String(x, visual_top - 12, expression.label, fontName=_FONT, fontSize=12,
fillColor=HexColor(geo.colors[index])))
else:
drawing.add(render_math_reportlab(expression_latex(expression.expression), x=x,
visual_top=visual_top, color=HexColor(geo.colors[index])))
if width is not None and width > 0:
drawing.renderScale = min(1.0, width / geo.width, max_height / drawing.height if max_height else 1.0)
return drawing
+29
View File
@@ -116,6 +116,25 @@ def test_render_svg_multiple_functions() -> None:
assert rendered.content.count("<polyline") >= 2
def test_render_svg_uses_latex_vector_paths_for_expression_legends() -> None:
plot = parse_source("y = sin(x)\ny = x^2 / 5").plot
svg = render_svg(plot).content
assert svg.count("plot-math-label") == 2
assert 'data-latex="y = \\sin\\left(x\\right)"' in svg
assert 'data-latex="y = \\frac{{x}^{2}}{5}"' in svg
assert "<path" in svg
assert ">y = sin(x)<" not in svg
@pytest.mark.parametrize("expression", [
"asin(x)", "acos(x)", "atan(x)", "sinh(x)", "cosh(x)", "tanh(x)",
"exp(x)", "ln(x)", "log10(x)", "log2(x)", "sqrt(x)", "abs(x)",
])
def test_render_svg_latex_supports_every_plot_function(expression: str) -> None:
plot = parse_source(f"domain: 0.1, 1\ny = {expression}").plot
assert "plot-math-label" in render_svg(plot).content
def test_render_svg_labels() -> None:
plot = parse_source("xlabel: 时间\nylabel: 数值\ny = x").plot
rendered = render_svg(plot)
@@ -369,6 +388,16 @@ def test_render_reportlab_builds_drawing() -> None:
assert "数值" in group_texts
def test_render_reportlab_uses_vector_latex_for_expression_legend() -> None:
from reportlab.graphics.shapes import Group, Path
from app.plot.render_reportlab import render_drawing
drawing = render_drawing(parse_source("y = x^2 / 5").plot)
math_groups = [item for item in drawing.contents if isinstance(item, Group)
and any(isinstance(child, Path) for child in item.contents)]
assert math_groups
def test_render_reportlab_curves_are_finite_and_bounded() -> None:
from reportlab.graphics.shapes import PolyLine