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)