From 51c592841d730f9549070480c06e775ad8517f3f Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Wed, 9 Sep 2026 22:31:39 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=A4=BE=E5=8C=BA?= =?UTF-8?q?=E6=9A=82=E5=AD=98=E4=B8=BB=E9=A2=98=E5=B9=B6=E4=B8=BA=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E5=9B=BE=E4=BE=8B=E6=B7=BB=E5=8A=A0=E6=95=B0=E5=AD=A6?= =?UTF-8?q?=E6=8E=92=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/export/assets.py | 10 +- backend/app/plot/math_label.py | 189 ++++++++++++++++++ backend/app/plot/render.py | 11 +- backend/app/plot/render_reportlab.py | 11 +- backend/tests/test_plot.py | 29 +++ .../src/features/community/CommunityView.vue | 3 +- .../community/DesktopPackages.spec.ts | 12 ++ .../features/community/DesktopPackages.vue | 50 +++-- .../features/help/FunctionPlotHelpView.vue | 1 + 9 files changed, 293 insertions(+), 23 deletions(-) create mode 100644 backend/app/plot/math_label.py diff --git a/backend/app/export/assets.py b/backend/app/export/assets.py index 2026272..b25f3e7 100644 --- a/backend/app/export/assets.py +++ b/backend/app/export/assets.py @@ -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 diff --git a/backend/app/plot/math_label.py b/backend/app/plot/math_label.py new file mode 100644 index 0000000..8960c14 --- /dev/null +++ b/backend/app/plot/math_label.py @@ -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'' + ] + parts.extend(f'' for path in layout.paths) + for rx, ry, width, height in layout.rects: + parts.append( + f'' + ) + parts.append("") + 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() diff --git a/backend/app/plot/render.py b/backend/app/plot/render.py index fa79112..699936f 100644 --- a/backend/app/plot/render.py +++ b/backend/app/plot/render.py @@ -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'{label}') + top = geo.height + 4 + (index // 2) * 24 + if expression.label: + label = html.escape(expression.label) + parts.append(f'{label}') + 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("") return StaticRenderResult( diff --git a/backend/app/plot/render_reportlab.py b/backend/app/plot/render_reportlab.py index e6b0f7a..f6888fa 100644 --- a/backend/app/plot/render_reportlab.py +++ b/backend/app/plot/render_reportlab.py @@ -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 diff --git a/backend/tests/test_plot.py b/backend/tests/test_plot.py index 9b6571f..c248270 100644 --- a/backend/tests/test_plot.py +++ b/backend/tests/test_plot.py @@ -116,6 +116,25 @@ def test_render_svg_multiple_functions() -> None: assert rendered.content.count("= 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 "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 diff --git a/frontend/src/features/community/CommunityView.vue b/frontend/src/features/community/CommunityView.vue index 2397552..c232110 100644 --- a/frontend/src/features/community/CommunityView.vue +++ b/frontend/src/features/community/CommunityView.vue @@ -124,7 +124,7 @@ function toggleSource() { -
+

已保存的声明式候选

这些候选尚未应用到人设、MCP 或模型运行配置。

{{ item.key.replace('community-candidate:', '') }}
{{ item.value }}
@@ -163,5 +163,6 @@ label { display: grid; gap: var(--space-xs); } input, select { color: var(--color-text-primary); background: var(--color-background-secondary); border: 1px solid var(--color-border-subtle); padding: var(--space-sm); } .community-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--space-md); } .community-card { display: grid; gap: var(--space-sm); text-align: left; padding: var(--space-lg); color: var(--color-text-primary); background: var(--color-background-secondary); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); overflow-wrap: anywhere; } +.saved-candidates { margin-top: var(--space-xl); } pre, dd { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 100%; } diff --git a/frontend/src/features/community/DesktopPackages.spec.ts b/frontend/src/features/community/DesktopPackages.spec.ts index f748e2d..c22342e 100644 --- a/frontend/src/features/community/DesktopPackages.spec.ts +++ b/frontend/src/features/community/DesktopPackages.spec.ts @@ -12,6 +12,18 @@ import DesktopPackages from './DesktopPackages.vue' const item = { package_key: 'package-key', source: 'https://example.com/', namespace: 'examples', package_id: 'reviewer', version: '1.0.0', state: 'staged' } beforeEach(() => { native.invoke.mockReset(); native.workspace.vaultId = 'vault-one' }) function component() { return mount(DesktopPackages, { props: { refreshKey: 0 }, global: { stubs: { AppDialog: { template: '
' } } } }) } +it('uses themed surfaces for the section, empty state, and staged package rows', async () => { + native.invoke.mockResolvedValueOnce([]) + const empty = component(); await flushPromises() + expect(empty.get('section.desktop-packages').classes()).toContain('panel') + expect(empty.get('.empty-state').text()).toContain('本页没有暂存包') + empty.unmount() + + native.invoke.mockResolvedValueOnce([item]) + const populated = component(); await flushPromises() + expect(populated.get('.package-list > li').classes()).toContain('item-card') + populated.unmount() +}) it('loads durable staged metadata and previews without issuing install commands', async () => { native.invoke.mockImplementation(async command => command === 'extension_staged' ? [item] : { fingerprint: 'fingerprint', dependencies: { packages: [{ ...item, permissions: ['notes.read'] }] }, changes: [{ target: { configuration: {} }, expected_revision: null }], diff --git a/frontend/src/features/community/DesktopPackages.vue b/frontend/src/features/community/DesktopPackages.vue index 49fae03..99c6e1c 100644 --- a/frontend/src/features/community/DesktopPackages.vue +++ b/frontend/src/features/community/DesktopPackages.vue @@ -53,20 +53,31 @@ onBeforeUnmount(() => ++generation) diff --git a/frontend/src/features/help/FunctionPlotHelpView.vue b/frontend/src/features/help/FunctionPlotHelpView.vue index 19d9d23..fea84d5 100644 --- a/frontend/src/features/help/FunctionPlotHelpView.vue +++ b/frontend/src/features/help/FunctionPlotHelpView.vue @@ -84,6 +84,7 @@ y = x^2 / 8 03

{{ t('可用数学语法', 'Supported math syntax') }}

{{ t('支持 +、-、*、/、^,变量 x,常量 pi、e,以及以下单参数函数。2x、2(x+1) 等隐式乘法也可使用。', 'Use +, -, *, /, ^, variable x, constants pi and e, and the single-argument functions below. Implicit multiplication such as 2x and 2(x+1) is also supported.') }}

+

{{ t('图例会自动使用 LaTeX 数学排版:幂显示为上标,除法显示为分式,函数名使用数学字体。', 'Legends are typeset automatically with LaTeX: powers use superscripts, division uses fractions, and function names use mathematical typography.') }}

{{ name }}(x)