From 04f36524b116e94ab03f7820a7c30e42bf3c81d7 Mon Sep 17 00:00:00 2001 From: yxx <2412119399@qq.com> Date: Sat, 5 Sep 2026 21:14:50 +0800 Subject: [PATCH] =?UTF-8?q?fix(plot):=20=E4=BF=AE=E5=A4=8D=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E5=9B=BE=E5=83=8F=E5=AE=A1=E9=98=85=E9=97=AE=E9=A2=98?= =?UTF-8?q?=EF=BC=881=20P1=20+=202=20P2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1 浮点刻度死循环:_ticks 改为有上限的整数索引推进并校验步长推进 - P2 求值异常:白名单函数校验参数数量;负数底非整数指数按断点处理;采样容错复数 - P2 无效纵轴范围:退化/非有限 range 丢弃并自动采样重算;渲染异常回退占位不阻断导出 - 补 6 个回归测试 Co-Authored-By: Claude Code --- backend/app/export/exporters/html.py | 6 ++- backend/app/plot/parser.py | 6 +++ backend/app/plot/render.py | 46 +++++++++++++++-------- backend/tests/test_plot.py | 55 ++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 17 deletions(-) diff --git a/backend/app/export/exporters/html.py b/backend/app/export/exporters/html.py index 3e3c74a..941474e 100644 --- a/backend/app/export/exporters/html.py +++ b/backend/app/export/exporters/html.py @@ -209,7 +209,11 @@ class HtmlExporter: warnings.append(self._format_plot_diagnostic(diag)) if parsed.plot is None: return f'
{html.escape(node.text)}
' - rendered = render_svg(parsed.plot) + try: + rendered = render_svg(parsed.plot) + except Exception as exc: # 渲染异常回退占位,绝不阻断整篇导出 + warnings.append(f"函数图像:渲染失败,已回退占位({exc})") + return f'
{html.escape(node.text)}
' warnings.extend(rendered.warnings) return f'
{rendered.content}
' diff --git a/backend/app/plot/parser.py b/backend/app/plot/parser.py index fc74f9e..974b4ca 100644 --- a/backend/app/plot/parser.py +++ b/backend/app/plot/parser.py @@ -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) diff --git a/backend/app/plot/render.py b/backend/app/plot/render.py index 4c3dd78..0b91015 100644 --- a/backend/app/plot/render.py +++ b/backend/app/plot/render.py @@ -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'') 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) diff --git a/backend/tests/test_plot.py b/backend/tests/test_plot.py index ce9d727..f7ceb86 100644 --- a/backend/tests/test_plot.py +++ b/backend/tests/test_plot.py @@ -137,3 +137,58 @@ def test_html_exporter_function_plot_fallback_on_error() -> None: assert '
' in html
     assert " None:
+    # P1:巨大 domain 下步长受浮点精度限制无法推进,刻度应有限而非死循环
+    plot = parse_source("domain: 10000000000000000, 10000000000000002\nrange: -1, 1\ny = 0").plot
+    rendered = render_svg(plot)
+    assert " None:
+    # P2:sin() / 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 " None:
+    # P2:退化 range(1, 1)应丢弃并自动采样,而非 ZeroDivisionError
+    plot = parse_source("range: 1, 1\ny = x").plot
+    rendered = render_svg(plot)
+    assert " None:
+    # P2:非有限 range 端点应丢弃并自动采样
+    plot = parse_source("range: nan, 1\ny = x").plot
+    rendered = render_svg(plot)
+    assert " 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 '
' in html
+    assert any("渲染失败" in w for w in result.warnings)