From f49d1245a161a2311381a75eadb6180eb54534df Mon Sep 17 00:00:00 2001 From: yxx <2412119399@qq.com> Date: Fri, 4 Sep 2026 22:41:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(export):=20=E5=87=BD=E6=95=B0=E5=9B=BE?= =?UTF-8?q?=E5=83=8F=E9=9D=99=E6=80=81=E6=B8=B2=E6=9F=93=EF=BC=88function-?= =?UTF-8?q?plot=20=E2=86=92=20SVG=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 app/plot 包:白名单表达式解析(ast 无 eval)+ FunctionPlot 模型 + 静态 SVG 渲染 - HtmlExporter 的 function_plot 节点解析并内嵌 SVG,解析失败回退占位并转诊断 - 新增 test_plot.py(13 个测试)覆盖表达式安全、指令解析、SVG 输出与导出链路集成 Co-Authored-By: Claude Code --- backend/app/export/exporters/html.py | 26 +- backend/app/plot/__init__.py | 7 + backend/app/plot/model.py | 55 ++++ backend/app/plot/parser.py | 359 +++++++++++++++++++++++++++ backend/app/plot/render.py | 223 +++++++++++++++++ backend/tests/test_plot.py | 139 +++++++++++ 6 files changed, 804 insertions(+), 5 deletions(-) create mode 100644 backend/app/plot/__init__.py create mode 100644 backend/app/plot/model.py create mode 100644 backend/app/plot/parser.py create mode 100644 backend/app/plot/render.py create mode 100644 backend/tests/test_plot.py diff --git a/backend/app/export/exporters/html.py b/backend/app/export/exporters/html.py index ca8436f..3e3c74a 100644 --- a/backend/app/export/exporters/html.py +++ b/backend/app/export/exporters/html.py @@ -1,7 +1,8 @@ """HtmlExporter:Document AST → 完整 HTML5 文档(内嵌基础 CSS)。 -对无法静态表达的节点(mermaid / function_plot)渲染为占位代码块并记 warning,不静默丢失; -严重内容缺失由 service 层以 EXPORT_UNSUPPORTED_CONTENT 判定,本层只负责逐节点渲染。 +mermaid 等无法静态表达的节点渲染为占位代码块并记 warning,不静默丢失;function_plot +解析为静态 SVG 内嵌(解析失败回退占位并转诊断);严重内容缺失由 service 层以 +EXPORT_UNSUPPORTED_CONTENT 判定,本层只负责逐节点渲染。 """ from __future__ import annotations @@ -12,9 +13,10 @@ from urllib.parse import urlparse from app.contracts import ExportOptions from app.export.document import Document, DocumentNode, ExportResult +from app.plot.parser import parse_source +from app.plot.render import render_svg _MERMAID_WARNING = "mermaid 需前端渲染,已保留为占位代码块" -_FUNCTION_PLOT_WARNING = "函数图像渲染将在后续版本提供,已保留为占位代码块" _RAW_HTML_WARNING = "原始 HTML 已按纯文本转义保留" # 链接/图片地址允许的协议;无 scheme 的相对地址视为安全,其余协议一律降级 @@ -44,6 +46,8 @@ pre { background: #f6f8fa; padding: 14px 16px; border-radius: 6px; overflow-x: a pre.code-theme-github-dark { background: #0d1117; color: #c9d1d9; } pre code { background: none; padding: 0; } pre.mermaid, pre.function-plot { border: 1px dashed #d0d7de; } +figure.function-plot { margin: 1em 0; text-align: center; } +figure.function-plot svg { max-width: 100%; height: auto; } blockquote { margin: 0.8em 0; padding: 0.2em 1em; border-left: 4px solid #d0d7de; color: #57606a; } img { max-width: 100%; } table { border-collapse: collapse; margin: 0.8em 0; } @@ -193,9 +197,21 @@ class HtmlExporter: warnings.append(_MERMAID_WARNING) return f'
{html.escape(node.text)}
' + @staticmethod + def _format_plot_diagnostic(diag) -> str: + loc = f"(第 {diag.line} 行)" if diag.line else "" + return f"函数图像:{diag.message}{loc}" + def _render_function_plot(self, node: DocumentNode, warnings: list[str]) -> str: - warnings.append(_FUNCTION_PLOT_WARNING) - return f'
{html.escape(node.text)}
' + # 解析 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)}
' + rendered = render_svg(parsed.plot) + warnings.extend(rendered.warnings) + return f'
{rendered.content}
' def _render_math_block(self, node: DocumentNode, warnings: list[str]) -> str: return f'
$${html.escape(node.text)}$$
' diff --git a/backend/app/plot/__init__.py b/backend/app/plot/__init__.py new file mode 100644 index 0000000..2bf1044 --- /dev/null +++ b/backend/app/plot/__init__.py @@ -0,0 +1,7 @@ +"""Function Plot:函数图像的白名单表达式解析与静态 SVG 渲染。 + +模块划分: +- model.py FunctionPlot 等内部数据模型(不进 contracts.py,同 Document AST) +- parser.py function-plot 源码与表达式解析(ast 白名单,绝不 eval/exec) +- render.py 把 FunctionPlot 渲染为内嵌 SVG(纯几何 + ,无脚本) +""" diff --git a/backend/app/plot/model.py b/backend/app/plot/model.py new file mode 100644 index 0000000..979577e --- /dev/null +++ b/backend/app/plot/model.py @@ -0,0 +1,55 @@ +"""Function Plot 内部数据模型。 + +契约 §12.2 的 FunctionPlot 结构与 §10.4 的 StaticRenderResult 只在导出链路的后端内部 +流转,不进入 HTTP 契约,因此与 Document AST 一样放在独立包内,不进 contracts.py。 +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + + +class FunctionPlotExpression(BaseModel): + """单条函数表达式;expression 为数学表达式文本(不含 ``y =`` 前缀)。""" + + expression: str + label: str | None = None + color: str | None = None + + +class PlotAxes(BaseModel): + xlabel: str | None = None + ylabel: str | None = None + grid: bool = True + + +class FunctionPlot(BaseModel): + version: int = 1 + expressions: list[FunctionPlotExpression] + domain: tuple[float, float] = (-10.0, 10.0) + range: tuple[float, float] | None = None + axes: PlotAxes = Field(default_factory=PlotAxes) + + +class PlotDiagnostic(BaseModel): + severity: Literal["warning", "error"] + code: str + message: str + line: int | None = None + + +class FunctionPlotParseResult(BaseModel): + """解析结果:任一表达式 error 时 plot 为 None(整块回退占位),仅 warning 时 plot 有效。""" + + plot: FunctionPlot | None = None + diagnostics: list[PlotDiagnostic] = Field(default_factory=list) + + +class StaticRenderResult(BaseModel): + content: str + mime_type: str = "image/svg+xml" + width: int + height: int + warnings: list[str] = Field(default_factory=list) diff --git a/backend/app/plot/parser.py b/backend/app/plot/parser.py new file mode 100644 index 0000000..fc74f9e --- /dev/null +++ b/backend/app/plot/parser.py @@ -0,0 +1,359 @@ +"""Function Plot 表达式解析:白名单数学语法,绝不执行 eval / 函数构造器 / 属性访问。 + +安全模型:先用 ``ast.parse(mode='eval')`` 把表达式变成纯 AST(这一步不执行任何代码), +再逐节点白名单校验(只允许数字、变量 ``x``、常量 ``pi/e``、白名单函数调用与四则/幂 +运算),最后用递归解释器直接计算数值——全程不 ``compile``/``exec`` 字符串。 +""" + +from __future__ import annotations + +import ast +import math +import re +from typing import NoReturn + +from app.plot.model import ( + FunctionPlot, + FunctionPlotExpression, + FunctionPlotParseResult, + PlotAxes, + PlotDiagnostic, +) + +# 白名单函数(ln 是 log 的别名);abs 用内置函数,其余映射到 math +_FUNCTION_IMPL: dict[str, object] = { + "sin": math.sin, + "cos": math.cos, + "tan": math.tan, + "asin": math.asin, + "acos": math.acos, + "atan": math.atan, + "sinh": math.sinh, + "cosh": math.cosh, + "tanh": math.tanh, + "exp": math.exp, + "log": math.log, + "ln": math.log, + "log10": math.log10, + "log2": math.log2, + "sqrt": math.sqrt, + "abs": abs, +} +_FUNCTIONS = frozenset(_FUNCTION_IMPL) +_CONSTANTS: dict[str, float] = {"pi": math.pi, "e": math.e} + +_ALLOWED_BINOPS = (ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow) +_ALLOWED_UNARY = (ast.UAdd, ast.USub) +_DIRECTIVE_KEYS = frozenset({"domain", "range", "xlabel", "ylabel", "grid"}) +_NUMBER_RE = re.compile(r"^(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$") + + +class PlotParseError(Exception): + """表达式解析/校验失败,携带可定位诊断。""" + + def __init__(self, diagnostic: PlotDiagnostic) -> None: + super().__init__(diagnostic.message) + self.diagnostic = diagnostic + + +def _unsafe(message: str) -> NoReturn: + raise PlotParseError( + PlotDiagnostic(severity="error", code="FUNCTION_PLOT_EXPRESSION_UNSAFE", message=message) + ) + + +def _is_number(tok: str) -> bool: + return bool(_NUMBER_RE.match(tok)) + + +def _tokenize(s: str) -> list[str]: + """把预处理后的表达式切成数字/标识符/运算符/括号 token。""" + tokens: list[str] = [] + i = 0 + n = len(s) + while i < n: + ch = s[i] + if ch.isspace(): + i += 1 + continue + if ch.isdigit() or ch == ".": + j = i + while j < n and (s[j].isdigit() or s[j] == "."): + j += 1 + # 科学计数法:数字后紧跟 e/E[+-]数字 视为同一数字 + if j < n and s[j] in "eE": + k = j + 1 + if k < n and s[k] in "+-": + k += 1 + if k < n and s[k].isdigit(): + while k < n and s[k].isdigit(): + k += 1 + j = k + tokens.append(s[i:j]) + i = j + continue + if ch.isalpha() or ch == "_": + j = i + while j < n and (s[j].isalnum() or s[j] == "_"): + j += 1 + tokens.append(s[i:j]) + i = j + continue + if ch == "*" and i + 1 < n and s[i + 1] == "*": + tokens.append("**") + i += 2 + continue + tokens.append(ch) + i += 1 + return tokens + + +def _is_value_end(tok: str) -> bool: + """该 token 之后允许补乘号(数字/右括号/变量 x/常量)。""" + return tok == ")" or _is_number(tok) or tok == "x" or tok in _CONSTANTS + + +def _is_value_start(tok: str) -> bool: + """该 token 可作为乘号右侧起点(左括号/数字/任意标识符,含函数名)。""" + return tok == "(" or _is_number(tok) or (tok and (tok[0].isalpha() or tok[0] == "_")) + + +def _insert_implicit_multiplication(s: str) -> str: + """补隐式乘法:2x、2(x+1)、(x+1)(x-1)、x sin(x) 等;函数名后的 ``(`` 是调用不补。""" + tokens = _tokenize(s) + out: list[str] = [] + prev: str | None = None + for tok in tokens: + if prev is not None and _is_value_end(prev) and _is_value_start(tok): + out.append("*") + out.append(tok) + prev = tok + return "".join(out) + + +def _preprocess(expr: str) -> str: + """``^`` 视为幂,补隐式乘法后再交给 ast.parse。""" + return _insert_implicit_multiplication(expr.replace("^", "**")) + + +def _check_node(node: ast.AST) -> None: + """白名单校验:任何越界节点都抛 FUNCTION_PLOT_EXPRESSION_UNSAFE。""" + if isinstance(node, ast.Constant): + if isinstance(node.value, bool) or not isinstance(node.value, (int, float)): + _unsafe(f"不支持的常量 {node.value!r}") + return + if isinstance(node, ast.Name): + if node.id == "x" or node.id in _CONSTANTS: + return + _unsafe(f"未知标识符 {node.id!r}") + 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) + return + if isinstance(node, ast.UnaryOp): + if not isinstance(node.op, _ALLOWED_UNARY): + _unsafe(f"不支持的运算符 {type(node.op).__name__}") + _check_node(node.operand) + return + if isinstance(node, ast.Call): + if not isinstance(node.func, ast.Name) or node.func.id not in _FUNCTIONS: + _unsafe(f"不支持的函数调用 {ast.dump(node.func)!r}") + if node.keywords: + _unsafe("函数调用不支持关键字参数") + for arg in node.args: + _check_node(arg) + return + _unsafe(f"不支持的语法 {type(node).__name__}") + + +def parse_expression(expr: str) -> ast.Expression: + """把数学表达式解析为已通过白名单校验的 AST(可直接交给 evaluate)。""" + preprocessed = _preprocess(expr) + try: + tree = ast.parse(preprocessed, mode="eval") + except SyntaxError as exc: + raise PlotParseError( + PlotDiagnostic( + severity="error", + code="FUNCTION_PLOT_PARSE_FAILED", + message=f"表达式语法错误:{exc.msg}", + ) + ) from exc + _check_node(tree.body) + return tree + + +def evaluate(expr_ast: ast.Expression, x: float) -> float: + """递归解释已校验 AST 得到数值,全程不编译/执行代码。""" + return _eval_node(expr_ast.body, x) + + +def _eval_node(node: ast.AST, x: float) -> float: + if isinstance(node, ast.Constant): + return float(node.value) + if isinstance(node, ast.Name): + return x if node.id == "x" else _CONSTANTS[node.id] + if isinstance(node, ast.BinOp): + left = _eval_node(node.left, x) + right = _eval_node(node.right, x) + if isinstance(node.op, ast.Add): + return left + right + if isinstance(node.op, ast.Sub): + return left - right + if isinstance(node.op, ast.Mult): + return left * right + if isinstance(node.op, ast.Div): + return left / right + return left**right + if isinstance(node, ast.UnaryOp): + value = _eval_node(node.operand, x) + return -value if isinstance(node.op, ast.USub) else value + if isinstance(node, ast.Call): + args = [_eval_node(arg, x) for arg in node.args] + return _FUNCTION_IMPL[node.func.id](*args) # type: ignore[operator] + raise ValueError("unreachable node") + + +def _strip_comment(line: str) -> str: + return line.split("#", 1)[0].strip() + + +def _parse_pair(value: str) -> tuple[float, float]: + """解析 ``min, max`` / ``min max`` 数值对。""" + parts = [p for p in re.split(r"[,,\s]+", value.strip()) if p] + if len(parts) != 2: + raise ValueError("需要两个数值") + return float(parts[0]), float(parts[1]) + + +def _parse_directive(line: str) -> tuple[str, str] | None: + """指令行形如 ``key: value``(表达式不含冒号,冒号是可靠判别)。""" + if ":" not in line or "=" in line: + return None + key, _, value = line.partition(":") + key = key.strip().lower() + if not key or " " in key: + return None + return key, value.strip() + + +def parse_source(source: str) -> FunctionPlotParseResult: + """把 function-plot fenced block 源码解析为 FunctionPlot + 诊断。""" + diagnostics: list[PlotDiagnostic] = [] + expressions: list[FunctionPlotExpression] = [] + domain: tuple[float, float] = (-10.0, 10.0) + range_: tuple[float, float] | None = None + xlabel: str | None = None + ylabel: str | None = None + grid: bool = True + has_error = False + + for lineno, raw_line in enumerate(source.splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + + directive = _parse_directive(line) + if directive is not None: + key, value = directive + if key == "domain": + try: + domain = _parse_pair(value) + except ValueError: + diagnostics.append( + PlotDiagnostic( + severity="warning", + code="FUNCTION_PLOT_PARSE_FAILED", + message=f"domain 需要两个数值,已忽略:{value!r}", + line=lineno, + ) + ) + elif key == "range": + try: + range_ = _parse_pair(value) + except ValueError: + diagnostics.append( + PlotDiagnostic( + severity="warning", + code="FUNCTION_PLOT_PARSE_FAILED", + message=f"range 需要两个数值,已忽略:{value!r}", + line=lineno, + ) + ) + elif key == "xlabel": + xlabel = value or None + elif key == "ylabel": + ylabel = value or None + elif key == "grid": + grid = value.lower() in ("true", "1", "yes", "on") + else: + diagnostics.append( + PlotDiagnostic( + severity="warning", + code="FUNCTION_PLOT_PARSE_FAILED", + message=f"未知指令 {key!r} 已忽略", + line=lineno, + ) + ) + continue + + # 表达式行:y = 或裸 + expr_text = _strip_comment(line) + if not expr_text: + continue + if "=" in expr_text: + lhs, _, rhs = expr_text.partition("=") + if lhs.strip().lower() not in ("y", ""): + diagnostics.append( + PlotDiagnostic( + severity="error", + code="FUNCTION_PLOT_PARSE_FAILED", + message="表达式应形如 'y = '", + line=lineno, + ) + ) + has_error = True + continue + expr_text = rhs.strip() + if not expr_text: + diagnostics.append( + PlotDiagnostic( + severity="error", + code="FUNCTION_PLOT_PARSE_FAILED", + message="表达式为空", + line=lineno, + ) + ) + has_error = True + continue + + try: + parse_expression(expr_text) + except PlotParseError as exc: + exc.diagnostic.line = lineno + diagnostics.append(exc.diagnostic) + has_error = True + continue + expressions.append(FunctionPlotExpression(expression=expr_text)) + + if has_error: + return FunctionPlotParseResult(plot=None, diagnostics=diagnostics) + if not expressions: + diagnostics.append( + PlotDiagnostic( + severity="error", + code="FUNCTION_PLOT_PARSE_FAILED", + message="没有找到任何函数表达式", + ) + ) + return FunctionPlotParseResult(plot=None, diagnostics=diagnostics) + + plot = FunctionPlot( + expressions=expressions, + domain=domain, + range=range_, + axes=PlotAxes(xlabel=xlabel, ylabel=ylabel, grid=grid), + ) + return FunctionPlotParseResult(plot=plot, diagnostics=diagnostics) diff --git a/backend/app/plot/render.py b/backend/app/plot/render.py new file mode 100644 index 0000000..4c3dd78 --- /dev/null +++ b/backend/app/plot/render.py @@ -0,0 +1,223 @@ +"""Function Plot → 静态 SVG 渲染。 + +只输出纯几何与 的 SVG(无 script/foreignObject/内联事件),可安全内嵌 HTML。 +所有文本与颜色都经过转义/校验,不把用户输入直接拼进标记。 +""" + +from __future__ import annotations + +import html +import math +import re +from typing import Callable + +from app.plot.model import FunctionPlot, StaticRenderResult +from app.plot.parser import PlotParseError, evaluate, parse_expression + +_WIDTH = 640 +_HEIGHT = 480 +_MARGIN = 52 # 四周留白,放轴刻度与标签 +_SAMPLES = 400 +_PALETTE = ["#0969da", "#d1242f", "#1a7f37", "#8250df", "#bf8700", "#e36209"] +_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$") + + +def _safe_color(color: str | None, fallback: str) -> str: + return color.strip() if color and _COLOR_RE.match(color.strip()) else fallback + + +def _fmt_num(v: float) -> str: + if v == 0: + return "0" + if abs(v) >= 1e6 or abs(v) < 1e-6: + return f"{v:.2e}" + return f"{v:.6g}" + + +def _nice_step(span: float, target_ticks: int = 6) -> float: + raw = abs(span) / target_ticks + mag = 10 ** math.floor(math.log10(raw)) + for m in (1, 2, 5, 10): + if raw <= m * mag: + return m * mag + return 10 * mag + + +def _ticks(lo: float, hi: float, step: float) -> list[float]: + first = math.ceil(lo / step) * step + values: list[float] = [] + v = first + while v <= hi + step * 1e-9: + values.append(v) + v += step + 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]) + + 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): + continue + if math.isfinite(y): + ys.append(y) + + if not ys: + return -10.0, 10.0 + lo, hi = min(ys), max(ys) + if lo == hi: + lo -= 1.0 + hi += 1.0 + pad = (hi - lo) * 0.05 + return lo - pad, hi + pad + + +def _polyline( + tree: object, + xmin: float, + xmax: float, + sx: Callable[[float], float], + sy: Callable[[float], float], + color: str, +) -> str: + """采样并把非有限点处断开成多段 polyline,避免画穿渐近线。""" + segments: list[str] = [] + points: list[str] = [] + 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): + y = math.nan + if not math.isfinite(y): + if points: + segments.append(f'') + points = [] + continue + px = sx(x) + py = sy(y) + points.append(f"{px:.2f},{py:.2f}") + if points: + segments.append(f'') + return "".join(segments) + + +def _grid( + xmin: float, + xmax: float, + ymin: float, + ymax: float, + sx: Callable[[float], float], + sy: Callable[[float], float], +) -> str: + parts: list[str] = [] + for x in _ticks(xmin, xmax, _nice_step(xmax - xmin)): + parts.append(f'') + for y in _ticks(ymin, ymax, _nice_step(ymax - ymin)): + parts.append(f'') + return "".join(parts) + + +def _axes( + xmin: float, + xmax: float, + ymin: float, + ymax: float, + sx: Callable[[float], float], + sy: Callable[[float], float], +) -> str: + parts: list[str] = [] + # 坐标轴:过原点则画在原点,否则贴边,保证始终有参照系 + x_axis_y = 0.0 if ymin <= 0 <= ymax else ymin + y_axis_x = 0.0 if xmin <= 0 <= xmax else xmin + parts.append( + f'' + ) + parts.append( + f'' + ) + # x 轴刻度数字(画在轴下方) + for x in _ticks(xmin, xmax, _nice_step(xmax - xmin)): + parts.append( + f'{html.escape(_fmt_num(x))}' + ) + # y 轴刻度数字(画在轴左侧) + for y in _ticks(ymin, ymax, _nice_step(ymax - ymin)): + parts.append( + f'{html.escape(_fmt_num(y))}' + ) + return "".join(parts) + + +def _labels(plot: FunctionPlot, sx: Callable[[float], float], sy: Callable[[float], float]) -> str: + parts: list[str] = [] + if plot.axes.xlabel: + parts.append( + f'{html.escape(plot.axes.xlabel)}' + ) + if plot.axes.ylabel: + parts.append( + f'{html.escape(plot.axes.ylabel)}' + ) + return "".join(parts) + + +def render_svg(plot: FunctionPlot) -> StaticRenderResult: + """把已解析的 FunctionPlot 渲染为内嵌 SVG。""" + warnings: list[str] = [] + xmin, xmax = plot.domain + if xmin >= xmax: + warnings.append("domain 无效,回退到 [-10, 10]") + xmin, xmax = -10.0, 10.0 + + # 重新解析并编译表达式(parse_source 已校验,这里异常只在模型被绕过时触发) + fns: list[tuple[object, object]] = [] + for expr in plot.expressions: + try: + tree = parse_expression(expr.expression) + except PlotParseError as exc: + warnings.append(f"表达式无法渲染,已跳过:{expr.expression}({exc.diagnostic.message})") + 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) + + def sx(x: float) -> float: + return _MARGIN + (x - xmin) / (xmax - xmin) * (_WIDTH - 2 * _MARGIN) + + def sy(y: float) -> float: + return _HEIGHT - _MARGIN - (y - ymin) / (ymax - ymin) * (_HEIGHT - 2 * _MARGIN) + + parts: list[str] = [ + f'' + ] + if plot.axes.grid: + parts.append(_grid(xmin, xmax, ymin, ymax, sx, sy)) + parts.append(_axes(xmin, xmax, ymin, ymax, sx, sy)) + for i, (expr, tree) in enumerate(fns): + color = _safe_color(expr.color, _PALETTE[i % len(_PALETTE)]) + parts.append(_polyline(tree, xmin, xmax, sx, sy, color)) + parts.append(_labels(plot, sx, sy)) + parts.append("") + + return StaticRenderResult( + content="".join(parts), + width=_WIDTH, + height=_HEIGHT, + warnings=warnings, + ) diff --git a/backend/tests/test_plot.py b/backend/tests/test_plot.py new file mode 100644 index 0000000..ce9d727 --- /dev/null +++ b/backend/tests/test_plot.py @@ -0,0 +1,139 @@ +"""Function Plot 的解析与静态 SVG 渲染测试。 + +覆盖 parser 的白名单表达式(幂/隐式乘法/函数/常量)、拒绝项(属性访问、任意调用等)、 +parse_source 指令与回退,以及 render 的 SVG 输出与 HTML 导出链路集成。 +""" + +from __future__ import annotations + +import asyncio +import math + +import pytest + +from app.contracts import ExportOptions +from app.export.exporters.html import HtmlExporter +from app.export.markdown import parse_document +from app.plot.parser import PlotParseError, evaluate, parse_expression, parse_source +from app.plot.render import render_svg + + +# --------------------------------------------------------------------------- # +# 表达式解析 +# --------------------------------------------------------------------------- # +def test_parse_expression_power_and_implicit_multiplication() -> None: + assert evaluate(parse_expression("x^2"), 3) == 9.0 + assert evaluate(parse_expression("2^3"), 0) == 8.0 + assert evaluate(parse_expression("2x+1"), 3) == 7.0 + assert evaluate(parse_expression("2(x+1)"), 3) == 8.0 + assert evaluate(parse_expression("(x+1)(x-1)"), 3) == 8.0 + + +def test_parse_expression_functions_and_constants() -> None: + assert evaluate(parse_expression("sin(0)"), 0) == 0.0 + assert math.isclose(evaluate(parse_expression("sin(pi/2)"), 0), 1.0) + assert math.isclose(evaluate(parse_expression("ln(e)"), 0), 1.0) + assert evaluate(parse_expression("abs(-3)"), 0) == 3.0 + + +def test_parse_expression_rejects_unsafe() -> None: + unsafe = [ + "os.system('x')", + "__import__('os')", + "foo(x)", + "eval('x')", + "x[0]", + "x.attr", + "lambda: 1", + ] + for expr in unsafe: + with pytest.raises(PlotParseError) as exc: + parse_expression(expr) + assert exc.value.diagnostic.code == "FUNCTION_PLOT_EXPRESSION_UNSAFE", expr + + +def test_parse_expression_syntax_error() -> None: + with pytest.raises(PlotParseError) as exc: + parse_expression("x +") + assert exc.value.diagnostic.code == "FUNCTION_PLOT_PARSE_FAILED" + + +# --------------------------------------------------------------------------- # +# fenced 源码解析 +# --------------------------------------------------------------------------- # +def test_parse_source_directives() -> None: + result = parse_source("domain: 0, 10\nrange: -1, 1\nxlabel: x\ngrid: false\ny = x^2") + assert result.plot is not None + assert result.plot.domain == (0.0, 10.0) + assert result.plot.range == (-1.0, 1.0) + assert result.plot.axes.xlabel == "x" + assert result.plot.axes.grid is False + assert len(result.plot.expressions) == 1 + assert result.plot.expressions[0].expression == "x^2" + + +def test_parse_source_bare_and_multi_expression() -> None: + result = parse_source("x^2\nsin(x)") + assert result.plot is not None + assert [e.expression for e in result.plot.expressions] == ["x^2", "sin(x)"] + + +def test_parse_source_unknown_directive_warns() -> None: + result = parse_source("foo: bar\ny = x") + assert result.plot is not None # 未知指令仅 warning,不阻断 + assert any(d.severity == "warning" for d in result.diagnostics) + + +def test_parse_source_error_returns_no_plot() -> None: + result = parse_source("y = os.system('x')") + assert result.plot is None + assert any(d.severity == "error" for d in result.diagnostics) + + +# --------------------------------------------------------------------------- # +# SVG 渲染 +# --------------------------------------------------------------------------- # +def test_render_svg_contains_polyline_and_axes() -> None: + plot = parse_source("y = x^2").plot + rendered = render_svg(plot) + svg = rendered.content + assert " None: + plot = parse_source("y = x^2\ny = sin(x)").plot + rendered = render_svg(plot) + assert rendered.content.count("= 2 + + +def test_render_svg_labels() -> None: + plot = parse_source("xlabel: 时间\nylabel: 数值\ny = x").plot + rendered = render_svg(plot) + assert "时间" in rendered.content + assert "数值" in rendered.content + + +# --------------------------------------------------------------------------- # +# HTML 导出链路集成 +# --------------------------------------------------------------------------- # +def test_html_exporter_embeds_function_plot_svg() -> None: + md = "```function-plot\ny = x^2\n```" + result = asyncio.run(HtmlExporter().export(parse_document(md), ExportOptions())) + html = result.content.decode("utf-8") + assert '
' in html + assert " None: + md = "```function-plot\ny = os.system('x')\n```" + result = asyncio.run(HtmlExporter().export(parse_document(md), ExportOptions())) + html = result.content.decode("utf-8") + assert '
' in html
+    assert "