feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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="桌面已暂存包">
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user