From b87f94551b70af344abe49bf6e5fe439475c4e88 Mon Sep 17 00:00:00 2001 From: yxx <2412119399@qq.com> Date: Sat, 5 Sep 2026 22:49:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(plot):=20=E4=BF=AE=E5=A4=8D=E5=A4=8D?= =?UTF-8?q?=E5=AE=A1=E9=97=AE=E9=A2=98=EF=BC=882=20P2=20+=201=20P3?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P2 复杂表达式绕过异常回退:解析与渲染共同纳入局部异常回退; AST 深度/节点数上限拦截 RecursionError - P2 极端有限范围生成 nan SVG:校验坐标跨度有限且 >0,回退安全范围; _polyline 拒绝非有限像素坐标 - P3 更新接口契约文档:function-plot 静态 SVG 已实现 Co-Authored-By: Claude Code --- backend/app/export/exporters/html.py | 17 +++---- backend/app/plot/parser.py | 37 ++++++++++++--- backend/app/plot/render.py | 27 +++++++++-- backend/tests/test_plot.py | 55 +++++++++++++++++++++++ docs/contracts/第二阶段接口契约-开发版.md | 2 +- 5 files changed, 120 insertions(+), 18 deletions(-) diff --git a/backend/app/export/exporters/html.py b/backend/app/export/exporters/html.py index 941474e..26d53ae 100644 --- a/backend/app/export/exporters/html.py +++ b/backend/app/export/exporters/html.py @@ -203,16 +203,17 @@ class HtmlExporter: return f"函数图像:{diag.message}{loc}" def _render_function_plot(self, node: DocumentNode, warnings: list[str]) -> str: - # 解析 fenced 源码:有合法 plot 且无 error → 内嵌静态 SVG;否则回退占位并转诊断 - parsed = parse_source(node.text) - for diag in parsed.diagnostics: - warnings.append(self._format_plot_diagnostic(diag)) - if parsed.plot is None: - return f'
{html.escape(node.text)}
' + # 解析与渲染共同纳入局部异常回退:单个图像失败只回退占位 + warning, + # 绝不阻断整篇导出(含复杂表达式触发的 RecursionError 等异常)。 try: + parsed = parse_source(node.text) + for diag in parsed.diagnostics: + warnings.append(self._format_plot_diagnostic(diag)) + if parsed.plot is None: + return f'
{html.escape(node.text)}
' rendered = render_svg(parsed.plot) - except Exception as exc: # 渲染异常回退占位,绝不阻断整篇导出 - warnings.append(f"函数图像:渲染失败,已回退占位({exc})") + 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 974b4ca..30f9be9 100644 --- a/backend/app/plot/parser.py +++ b/backend/app/plot/parser.py @@ -47,6 +47,11 @@ _ALLOWED_UNARY = (ast.UAdd, ast.USub) _DIRECTIVE_KEYS = frozenset({"domain", "range", "xlabel", "ylabel", "grid"}) _NUMBER_RE = re.compile(r"^(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$") +# 表达式复杂度上限:深层嵌套或海量节点在递归校验/求值时会触发 RecursionError, +# 用白名单校验提前拦截,保证失败走正常诊断路径而不是异常逃逸出导出链路。 +_MAX_AST_DEPTH = 200 +_MAX_AST_NODES = 1000 + class PlotParseError(Exception): """表达式解析/校验失败,携带可定位诊断。""" @@ -136,8 +141,19 @@ def _preprocess(expr: str) -> str: return _insert_implicit_multiplication(expr.replace("^", "**")) -def _check_node(node: ast.AST) -> None: - """白名单校验:任何越界节点都抛 FUNCTION_PLOT_EXPRESSION_UNSAFE。""" +def _check_node(node: ast.AST, depth: int = 0, counter: list[int] | None = None) -> None: + """白名单校验:任何越界节点都抛 FUNCTION_PLOT_EXPRESSION_UNSAFE。 + + 同时限制 AST 深度与节点总数,避免超长/超深表达式在递归校验或求值时触发 + RecursionError 而绕过解析失败路径。 + """ + if counter is None: + counter = [0] + if depth > _MAX_AST_DEPTH: + _unsafe(f"表达式嵌套过深(超过 {_MAX_AST_DEPTH} 层)") + counter[0] += 1 + if counter[0] > _MAX_AST_NODES: + _unsafe(f"表达式过于复杂(节点数超过 {_MAX_AST_NODES})") if isinstance(node, ast.Constant): if isinstance(node.value, bool) or not isinstance(node.value, (int, float)): _unsafe(f"不支持的常量 {node.value!r}") @@ -149,13 +165,13 @@ def _check_node(node: ast.AST) -> None: if isinstance(node, ast.BinOp): if not isinstance(node.op, _ALLOWED_BINOPS): _unsafe(f"不支持的运算符 {type(node.op).__name__}") - _check_node(node.left) - _check_node(node.right) + _check_node(node.left, depth + 1, counter) + _check_node(node.right, depth + 1, counter) return if isinstance(node, ast.UnaryOp): if not isinstance(node.op, _ALLOWED_UNARY): _unsafe(f"不支持的运算符 {type(node.op).__name__}") - _check_node(node.operand) + _check_node(node.operand, depth + 1, counter) return if isinstance(node, ast.Call): if not isinstance(node.func, ast.Name) or node.func.id not in _FUNCTIONS: @@ -166,7 +182,7 @@ def _check_node(node: ast.AST) -> None: if len(node.args) != 1: _unsafe(f"{node.func.id} 需要 1 个参数,实际 {len(node.args)} 个") for arg in node.args: - _check_node(arg) + _check_node(arg, depth + 1, counter) return _unsafe(f"不支持的语法 {type(node).__name__}") @@ -184,6 +200,15 @@ def parse_expression(expr: str) -> ast.Expression: message=f"表达式语法错误:{exc.msg}", ) ) from exc + except RecursionError as exc: + # 极深嵌套可能在 ast.parse 阶段就触发 RecursionError,转为可定位诊断 + raise PlotParseError( + PlotDiagnostic( + severity="error", + code="FUNCTION_PLOT_PARSE_FAILED", + message="表达式嵌套过深,无法解析", + ) + ) from exc _check_node(tree.body) return tree diff --git a/backend/app/plot/render.py b/backend/app/plot/render.py index 0b91015..0637324 100644 --- a/backend/app/plot/render.py +++ b/backend/app/plot/render.py @@ -26,6 +26,16 @@ def _safe_color(color: str | None, fallback: str) -> str: return color.strip() if color and _COLOR_RE.match(color.strip()) else fallback +def _valid_span(lo: float, hi: float) -> bool: + """范围跨度有效:端点有限、跨度有限且大于零。 + + 端点相减可能溢出为 ``inf``(如 ``-1e308`` 到 ``1e308``),需单独校验跨度, + 否则后续坐标换算会生成含 ``nan`` 的 SVG。 + """ + span = hi - lo + return math.isfinite(lo) and math.isfinite(hi) and math.isfinite(span) and span > 0 + + def _fmt_num(v: float) -> str: if v == 0: return "0" @@ -116,6 +126,12 @@ def _polyline( continue px = sx(x) py = sy(y) + # 映射后的坐标必须有限:显式 range 下极端 y 值可能让像素坐标溢出为 inf + if not (math.isfinite(px) and math.isfinite(py)): + if points: + segments.append(f'') + points = [] + continue points.append(f"{px:.2f},{py:.2f}") if points: segments.append(f'') @@ -186,7 +202,7 @@ def render_svg(plot: FunctionPlot) -> StaticRenderResult: """把已解析的 FunctionPlot 渲染为内嵌 SVG。""" warnings: list[str] = [] xmin, xmax = plot.domain - if not (math.isfinite(xmin) and math.isfinite(xmax)) or xmin >= xmax: + if not _valid_span(xmin, xmax): warnings.append("domain 无效,回退到 [-10, 10]") xmin, xmax = -10.0, 10.0 @@ -200,10 +216,10 @@ def render_svg(plot: FunctionPlot) -> StaticRenderResult: continue fns.append((expr, tree)) - # 纵轴范围:显式 range 有效则用之;无效(退化/非有限)丢弃并自动采样重算 + # 纵轴范围:显式 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: + if _valid_span(lo, hi): ymin, ymax = lo, hi else: warnings.append("range 无效,改用自动范围") @@ -211,6 +227,11 @@ def render_svg(plot: FunctionPlot) -> StaticRenderResult: else: ymin, ymax = _compute_range(fns, xmin, xmax) + # 最终防线:自动范围在极端样本下也可能溢出,坐标映射前必须保证跨度有限且大于零 + if not _valid_span(ymin, ymax): + warnings.append("y 范围跨度无法表示,回退到 [-10, 10]") + ymin, ymax = -10.0, 10.0 + 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 f7ceb86..8c7e48b 100644 --- a/backend/tests/test_plot.py +++ b/backend/tests/test_plot.py @@ -192,3 +192,58 @@ def test_html_exporter_function_plot_render_error_falls_back(monkeypatch) -> Non html = result.content.decode("utf-8") assert '
' in html
     assert any("渲染失败" in w for w in result.warnings)
+
+
+# --------------------------------------------------------------------------- #
+# 审阅回归:复杂表达式 / 极端数值范围
+# --------------------------------------------------------------------------- #
+def test_parse_expression_rejects_excessive_depth() -> None:
+    # P2:超长加法链的 AST 深度超限,应拒绝为 PlotParseError 而非触发 RecursionError
+    expr = "+".join(["1"] * 300)
+    with pytest.raises(PlotParseError) as exc:
+        parse_expression(expr)
+    assert exc.value.diagnostic.code == "FUNCTION_PLOT_EXPRESSION_UNSAFE"
+
+
+def test_parse_expression_rejects_excessive_nodes() -> None:
+    # P2:浅层但节点超限的表达式(满二叉树)应被节点数上限拦截
+    def balanced(depth: int) -> str:
+        if depth == 0:
+            return "x"
+        return f"({balanced(depth - 1)}+{balanced(depth - 1)})"
+
+    expr = balanced(10)  # ~2047 个节点,深度仅 ~10
+    with pytest.raises(PlotParseError) as exc:
+        parse_expression(expr)
+    assert exc.value.diagnostic.code == "FUNCTION_PLOT_EXPRESSION_UNSAFE"
+
+
+def test_html_exporter_function_plot_deep_expression_falls_back() -> None:
+    # P2:复杂表达式解析失败应回退占位,不阻断整篇导出
+    expr = "+".join(["1"] * 300)
+    md = f"```function-plot\ny = {expr}\n```"
+    result = asyncio.run(HtmlExporter().export(parse_document(md), ExportOptions()))
+    html = result.content.decode("utf-8")
+    assert '
' in html
+    assert " None:
+    # P2:有限但跨度溢出的 domain 应回退安全范围,SVG 不得含 nan/inf
+    plot = parse_source("domain: -1e308, 1e308\nrange: -1, 1\ny = 0").plot
+    rendered = render_svg(plot)
+    assert " None:
+    # P2:有限但跨度溢出的 range 应回退自动范围,SVG 不得含 nan/inf
+    plot = parse_source("domain: -1, 1\nrange: -1e308, 1e308\ny = x").plot
+    rendered = render_svg(plot)
+    assert " 实现状态:HTML 导出已实现(`backend/app/export/`),`pdf`/`docx` 暂缓——请求这两个格式返回 `EXPORT_FORMAT_UNSUPPORTED`。函数图像与 Mermaid 在 HTML 中以占位代码块保留并记 warning,静态渲染由 §10.4 的 Render Contract 在后续 PR 补齐。
+> 实现状态:HTML 导出已实现(`backend/app/export/`),`pdf`/`docx` 暂缓——请求这两个格式返回 `EXPORT_FORMAT_UNSUPPORTED`。`function-plot` 已支持静态 SVG 内嵌(`backend/app/plot/`),解析或渲染失败时回退为源码占位并记录 warning;Mermaid 目前仍以占位代码块保留并记 warning。
 
 ### 10.1 创建导出任务