feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45

Merged
Kronecker merged 151 commits from feat/phase3-completion into main 2026-09-10 01:16:31 +08:00
9 changed files with 293 additions and 23 deletions
Showing only changes of commit 51c592841d - Show all commits
+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
@@ -124,7 +124,7 @@ function toggleSource() {
</div>
</section>
<DesktopPackages v-if="isDesktop()" :refresh-key="stagedRefresh" />
<section v-if="candidates.length">
<section v-if="candidates.length" class="saved-candidates panel">
<h2>已保存的声明式候选</h2><p>这些候选尚未应用到人设MCP 或模型运行配置</p>
<details v-for="item in candidates" :key="item.key"><summary>{{ item.key.replace('community-candidate:', '') }}</summary><pre>{{ item.value }}</pre><button class="btn" @click="removeCandidate(item.key)">删除候选</button></details>
</section>
@@ -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%; }
</style>
@@ -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: '<section><slot /></section>' } } } }) }
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 }],
@@ -53,20 +53,31 @@ onBeforeUnmount(() => ++generation)
</script>
<template>
<section class="desktop-packages" aria-label="桌面已暂存包">
<h2>桌面已暂存包</h2>
<p>暂存包保存在桌面安装库中重启后仍可查看暂存不代表已经安装或获得运行权限</p>
<button class="btn" :disabled="busy" @click="refresh">刷新暂存列表</button>
<p v-if="error" role="alert">{{ error }}</p>
<p v-if="busy" role="status">正在检查</p>
<p v-if="!busy && !packages.length">本页没有暂存包</p>
<ul><li v-for="item in packages" :key="item.package_key">
<strong>{{ item.namespace }}/{{ item.package_id }} · {{ item.version }}</strong>
<p>{{ item.source }}</p><button class="btn" :disabled="busy" @click="choose(item)">查看安装预览</button>
</li></ul>
<button class="btn" :disabled="busy || page === 0" @click="page--; refresh()">上一页</button>
<span> {{ page + 1 }} </span>
<button class="btn" :disabled="busy || packages.length < 20" @click="page++; refresh()">下一页</button>
<section class="desktop-packages panel" aria-label="桌面已暂存包">
<header class="section-heading">
<div>
<h2>桌面已暂存包</h2>
<p>暂存包保存在桌面安装库中重启后仍可查看暂存不代表已经安装或获得运行权限</p>
</div>
<button class="btn" :disabled="busy" @click="refresh">刷新暂存列表</button>
</header>
<p v-if="error" class="notice-banner error-message" role="alert">{{ error }}</p>
<p v-if="busy" class="notice-banner" role="status">正在检查</p>
<div v-if="!busy && !packages.length" class="empty-state">
<strong>本页没有暂存包</strong>
<span>从社区目录校验并暂存包后可在这里查看安装预览</span>
</div>
<ul v-if="packages.length" class="package-list">
<li v-for="item in packages" :key="item.package_key" class="item-card">
<div><strong>{{ item.namespace }}/{{ item.package_id }} · {{ item.version }}</strong><p>{{ item.source }}</p></div>
<button class="btn" :disabled="busy" @click="choose(item)">查看安装预览</button>
</li>
</ul>
<nav class="pagination" aria-label="暂存包分页">
<button class="btn" :disabled="busy || page === 0" @click="page--; refresh()">上一页</button>
<span> {{ page + 1 }} </span>
<button class="btn" :disabled="busy || packages.length < 20" @click="page++; refresh()">下一页</button>
</nav>
<AppDialog v-if="selected" label="桌面安装预览" @close="close">
<h2>{{ selected.package_id }} · {{ selected.version }}</h2>
<p v-if="!workspace.vaultId">请先打开要使用此包的笔记库</p>
@@ -88,9 +99,16 @@ onBeforeUnmount(() => ++generation)
</template>
<style scoped>
.desktop-packages { margin-block: var(--space-xl); }
li { margin-block: var(--space-md); overflow-wrap: anywhere; }
.desktop-packages { display: grid; gap: var(--space-lg); margin-block: var(--space-xl); padding: var(--space-xl); border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); background: var(--color-surface-primary); }
.section-heading { display: flex; align-items: start; justify-content: space-between; gap: var(--space-lg); }
.section-heading h2, .section-heading p, .item-card p { margin: 0; }
.section-heading p, .item-card p, .empty-state span { margin-top: var(--space-xs); color: var(--color-text-secondary); }
.package-list { display: grid; gap: var(--space-md); margin: 0; padding: 0; list-style: none; }
.item-card { display: flex; align-items: center; justify-content: space-between; gap: var(--space-lg); 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; }
.empty-state { min-height: 120px; }
.pagination { display: flex; align-items: center; gap: var(--space-sm); color: var(--color-text-secondary); }
label { display: grid; gap: var(--space-xs); }
textarea { width: 100%; color: var(--color-text-primary); background: var(--color-background-secondary); }
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
@media (max-width: 700px) { .section-heading, .item-card { align-items: stretch; flex-direction: column; } }
</style>
@@ -84,6 +84,7 @@ y = x^2 / 8
<span class="step">03</span>
<h2>{{ t('可用数学语法', 'Supported math syntax') }}</h2>
<p>{{ 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.') }}</p>
<p>{{ t('图例会自动使用 LaTeX 数学排版:幂显示为上标,除法显示为分式,函数名使用数学字体。', 'Legends are typeset automatically with LaTeX: powers use superscripts, division uses fractions, and function names use mathematical typography.') }}</p>
<div class="tag-list function-list">
<code v-for="name in ['sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'sinh', 'cosh', 'tanh', 'exp', 'log', 'ln', 'log10', 'log2', 'sqrt', 'abs']" :key="name">{{ name }}(x)</code>
</div>