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'")
+
+ 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 "