fix(plot): 修复函数图像审阅问题(1 P1 + 2 P2)
- P1 浮点刻度死循环:_ticks 改为有上限的整数索引推进并校验步长推进 - P2 求值异常:白名单函数校验参数数量;负数底非整数指数按断点处理;采样容错复数 - P2 无效纵轴范围:退化/非有限 range 丢弃并自动采样重算;渲染异常回退占位不阻断导出 - 补 6 个回归测试 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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>'
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
# 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 "<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)
|
||||
|
||||
Reference in New Issue
Block a user