feat(export): 多格式后台导出、主题与警告框渲染 #43

Merged
Kronecker merged 21 commits from feat/export-service into main 2026-09-07 01:38:36 +08:00
4 changed files with 96 additions and 17 deletions
Showing only changes of commit 04f36524b1 - Show all commits
+5 -1
View File
@@ -209,7 +209,11 @@ class HtmlExporter:
warnings.append(self._format_plot_diagnostic(diag))
if parsed.plot is None:
return f'<pre class="function-plot">{html.escape(node.text)}</pre>'
rendered = render_svg(parsed.plot)
try:
rendered = render_svg(parsed.plot)
except Exception as exc: # 渲染异常回退占位,绝不阻断整篇导出
warnings.append(f"函数图像:渲染失败,已回退占位({exc}")
return f'<pre class="function-plot">{html.escape(node.text)}</pre>'
warnings.extend(rendered.warnings)
return f'<figure class="function-plot">{rendered.content}</figure>'
+6
View File
@@ -162,6 +162,9 @@ def _check_node(node: ast.AST) -> None:
_unsafe(f"不支持的函数调用 {ast.dump(node.func)!r}")
if node.keywords:
_unsafe("函数调用不支持关键字参数")
# 白名单内所有函数均恰取 1 个参数,提前校验避免求值期 TypeError
if len(node.args) != 1:
_unsafe(f"{node.func.id} 需要 1 个参数,实际 {len(node.args)}")
for arg in node.args:
_check_node(arg)
return
@@ -206,6 +209,9 @@ def _eval_node(node: ast.AST, x: float) -> float:
return left * right
if isinstance(node.op, ast.Div):
return left / right
# 负数底 + 非整数指数会得到复数,数学绘图不支持,抛 ValueError 让采样点作为断点处理
if left < 0 and not right.is_integer():
raise ValueError("negative base with fractional exponent")
return left**right
if isinstance(node, ast.UnaryOp):
value = _eval_node(node.operand, x)
+30 -16
View File
@@ -36,6 +36,8 @@ def _fmt_num(v: float) -> str:
def _nice_step(span: float, target_ticks: int = 6) -> float:
raw = abs(span) / target_ticks
if not math.isfinite(raw) or raw <= 0:
return 1.0 # 兜底步长,避免 span 为 0/inf 时产生非法刻度
mag = 10 ** math.floor(math.log10(raw))
for m in (1, 2, 5, 10):
if raw <= m * mag:
@@ -44,34 +46,40 @@ def _nice_step(span: float, target_ticks: int = 6) -> float:
def _ticks(lo: float, hi: float, step: float) -> list[float]:
# 防御:非法步长直接返回空,避免除零
if not math.isfinite(step) or step <= 0:
return []
first = math.ceil(lo / step) * step
values: list[float] = []
v = first
while v <= hi + step * 1e-9:
# 有上限的整数索引推进 + 步长推进校验,防止浮点精度导致 v+step==v 的死循环
for _ in range(1000):
if v > hi + step * 1e-9:
break
values.append(v)
v += step
nxt = v + step
if nxt <= v:
break # 步长小于当前数值的浮点精度,已无法推进
v = nxt
return values
def _compute_range(
plot: FunctionPlot,
fns: list[tuple[object, object]],
xmin: float,
xmax: float,
) -> tuple[float, float]:
"""采样确定 y 范围;指定 range 则优先,否则取有限样本的 min/max 加 5% 余量。"""
if plot.range is not None:
return float(plot.range[0]), float(plot.range[1])
"""采样确定 y 范围;取有限样本的 min/max 加 5% 余量。"""
ys: list[float] = []
for _expr, tree in fns:
for i in range(_SAMPLES + 1):
x = xmin + (xmax - xmin) * i / _SAMPLES
try:
y = evaluate(tree, x) # type: ignore[arg-type]
except (ValueError, ZeroDivisionError, OverflowError):
except (ValueError, ZeroDivisionError, OverflowError, TypeError):
continue
if math.isfinite(y):
# 复数等非实数结果直接跳过,不参与范围统计
if isinstance(y, (int, float)) and math.isfinite(y):
ys.append(y)
if not ys:
@@ -99,9 +107,9 @@ def _polyline(
x = xmin + (xmax - xmin) * i / _SAMPLES
try:
y = evaluate(tree, x) # type: ignore[arg-type]
except (ValueError, ZeroDivisionError, OverflowError):
except (ValueError, ZeroDivisionError, OverflowError, TypeError):
y = math.nan
if not math.isfinite(y):
if not isinstance(y, (int, float)) or not math.isfinite(y):
if points:
segments.append(f'<polyline points="{" ".join(points)}" fill="none" stroke="{color}"/>')
points = []
@@ -178,7 +186,7 @@ def render_svg(plot: FunctionPlot) -> StaticRenderResult:
"""把已解析的 FunctionPlot 渲染为内嵌 SVG。"""
warnings: list[str] = []
xmin, xmax = plot.domain
if xmin >= xmax:
if not (math.isfinite(xmin) and math.isfinite(xmax)) or xmin >= xmax:
warnings.append("domain 无效,回退到 [-10, 10]")
xmin, xmax = -10.0, 10.0
@@ -192,10 +200,16 @@ def render_svg(plot: FunctionPlot) -> StaticRenderResult:
continue
fns.append((expr, tree))
ymin, ymax = _compute_range(plot, fns, xmin, xmax)
if plot.range is not None and plot.range[0] >= plot.range[1]:
warnings.append("range 无效,改用自动范围")
ymin, ymax = _compute_range(plot, fns, xmin, xmax)
# 纵轴范围:显式 range 有效则用之;无效(退化/非有限)丢弃并自动采样重算
if plot.range is not None:
lo, hi = float(plot.range[0]), float(plot.range[1])
if math.isfinite(lo) and math.isfinite(hi) and lo < hi:
ymin, ymax = lo, hi
else:
warnings.append("range 无效,改用自动范围")
ymin, ymax = _compute_range(fns, xmin, xmax)
else:
ymin, ymax = _compute_range(fns, xmin, xmax)
def sx(x: float) -> float:
return _MARGIN + (x - xmin) / (xmax - xmin) * (_WIDTH - 2 * _MARGIN)
+55
View File
@@ -137,3 +137,58 @@ def test_html_exporter_function_plot_fallback_on_error() -> None:
assert '<pre class="function-plot">' in html
assert "<svg" not in html
assert any("函数图像" in w for w in result.warnings)
# --------------------------------------------------------------------------- #
# 审阅回归:浮点刻度 / 求值异常 / 无效范围
# --------------------------------------------------------------------------- #
def test_render_svg_huge_domain_ticks_bounded() -> None:
# P1:巨大 domain 下步长受浮点精度限制无法推进,刻度应有限而非死循环
plot = parse_source("domain: 10000000000000000, 10000000000000002\nrange: -1, 1\ny = 0").plot
rendered = render_svg(plot)
assert "<svg" in rendered.content
def test_parse_expression_rejects_wrong_arg_count() -> None:
# P2sin() / sin(1, 2) 应在解析期拒绝,而非求值期 TypeError
with pytest.raises(PlotParseError):
parse_expression("sin()")
with pytest.raises(PlotParseError):
parse_expression("sin(1, 2)")
def test_render_svg_nonreal_samples_are_break_points() -> None:
# P2:x^0.5 在负数域产生复数,应作为断点处理,正半轴仍可绘制
plot = parse_source("domain: -4, 4\ny = x^0.5").plot
rendered = render_svg(plot)
assert "<polyline" in rendered.content
def test_render_svg_invalid_range_falls_back() -> None:
# P2:退化 range(1, 1)应丢弃并自动采样,而非 ZeroDivisionError
plot = parse_source("range: 1, 1\ny = x").plot
rendered = render_svg(plot)
assert "<polyline" in rendered.content
assert any("range" in w for w in rendered.warnings)
def test_render_svg_nonfinite_range_falls_back() -> None:
# P2:非有限 range 端点应丢弃并自动采样
plot = parse_source("range: nan, 1\ny = x").plot
rendered = render_svg(plot)
assert "<polyline" in rendered.content
def test_html_exporter_function_plot_render_error_falls_back(monkeypatch) -> None:
# P2:渲染异常不阻断整篇导出,回退占位并记 warning
import app.export.exporters.html as html_mod
def boom(plot):
raise RuntimeError("boom")
monkeypatch.setattr(html_mod, "render_svg", boom)
md = "```function-plot\ny = x\n```"
result = asyncio.run(HtmlExporter().export(parse_document(md), ExportOptions()))
html = result.content.decode("utf-8")
assert '<pre class="function-plot">' in html
assert any("渲染失败" in w for w in result.warnings)