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 "