feat(export): 函数图像静态渲染(function-plot → SVG)
- 新增 app/plot 包:白名单表达式解析(ast 无 eval)+ FunctionPlot 模型 + 静态 SVG 渲染 - HtmlExporter 的 function_plot 节点解析并内嵌 SVG,解析失败回退占位并转诊断 - 新增 test_plot.py(13 个测试)覆盖表达式安全、指令解析、SVG 输出与导出链路集成 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
"""HtmlExporter:Document AST → 完整 HTML5 文档(内嵌基础 CSS)。
|
"""HtmlExporter:Document AST → 完整 HTML5 文档(内嵌基础 CSS)。
|
||||||
|
|
||||||
对无法静态表达的节点(mermaid / function_plot)渲染为占位代码块并记 warning,不静默丢失;
|
mermaid 等无法静态表达的节点渲染为占位代码块并记 warning,不静默丢失;function_plot
|
||||||
严重内容缺失由 service 层以 EXPORT_UNSUPPORTED_CONTENT 判定,本层只负责逐节点渲染。
|
解析为静态 SVG 内嵌(解析失败回退占位并转诊断);严重内容缺失由 service 层以
|
||||||
|
EXPORT_UNSUPPORTED_CONTENT 判定,本层只负责逐节点渲染。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -12,9 +13,10 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
from app.contracts import ExportOptions
|
from app.contracts import ExportOptions
|
||||||
from app.export.document import Document, DocumentNode, ExportResult
|
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 需前端渲染,已保留为占位代码块"
|
_MERMAID_WARNING = "mermaid 需前端渲染,已保留为占位代码块"
|
||||||
_FUNCTION_PLOT_WARNING = "函数图像渲染将在后续版本提供,已保留为占位代码块"
|
|
||||||
_RAW_HTML_WARNING = "原始 HTML 已按纯文本转义保留"
|
_RAW_HTML_WARNING = "原始 HTML 已按纯文本转义保留"
|
||||||
|
|
||||||
# 链接/图片地址允许的协议;无 scheme 的相对地址视为安全,其余协议一律降级
|
# 链接/图片地址允许的协议;无 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-theme-github-dark { background: #0d1117; color: #c9d1d9; }
|
||||||
pre code { background: none; padding: 0; }
|
pre code { background: none; padding: 0; }
|
||||||
pre.mermaid, pre.function-plot { border: 1px dashed #d0d7de; }
|
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; }
|
blockquote { margin: 0.8em 0; padding: 0.2em 1em; border-left: 4px solid #d0d7de; color: #57606a; }
|
||||||
img { max-width: 100%; }
|
img { max-width: 100%; }
|
||||||
table { border-collapse: collapse; margin: 0.8em 0; }
|
table { border-collapse: collapse; margin: 0.8em 0; }
|
||||||
@@ -193,9 +197,21 @@ class HtmlExporter:
|
|||||||
warnings.append(_MERMAID_WARNING)
|
warnings.append(_MERMAID_WARNING)
|
||||||
return f'<pre class="mermaid">{html.escape(node.text)}</pre>'
|
return f'<pre class="mermaid">{html.escape(node.text)}</pre>'
|
||||||
|
|
||||||
|
@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:
|
def _render_function_plot(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||||
warnings.append(_FUNCTION_PLOT_WARNING)
|
# 解析 fenced 源码:有合法 plot 且无 error → 内嵌静态 SVG;否则回退占位并转诊断
|
||||||
return f'<pre class="function-plot">{html.escape(node.text)}</pre>'
|
parsed = parse_source(node.text)
|
||||||
|
for diag in parsed.diagnostics:
|
||||||
|
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)
|
||||||
|
warnings.extend(rendered.warnings)
|
||||||
|
return f'<figure class="function-plot">{rendered.content}</figure>'
|
||||||
|
|
||||||
def _render_math_block(self, node: DocumentNode, warnings: list[str]) -> str:
|
def _render_math_block(self, node: DocumentNode, warnings: list[str]) -> str:
|
||||||
return f'<div class="math-block">$${html.escape(node.text)}$$</div>'
|
return f'<div class="math-block">$${html.escape(node.text)}$$</div>'
|
||||||
|
|||||||
@@ -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(纯几何 + <text>,无脚本)
|
||||||
|
"""
|
||||||
@@ -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)
|
||||||
@@ -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> 或裸 <expr>
|
||||||
|
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 = <expr>'",
|
||||||
|
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)
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""Function Plot → 静态 SVG 渲染。
|
||||||
|
|
||||||
|
只输出纯几何与 <text> 的 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'<polyline points="{" ".join(points)}" fill="none" stroke="{color}"/>')
|
||||||
|
points = []
|
||||||
|
continue
|
||||||
|
px = sx(x)
|
||||||
|
py = sy(y)
|
||||||
|
points.append(f"{px:.2f},{py:.2f}")
|
||||||
|
if points:
|
||||||
|
segments.append(f'<polyline points="{" ".join(points)}" fill="none" stroke="{color}"/>')
|
||||||
|
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'<line x1="{sx(x):.2f}" y1="{sy(ymin):.2f}" x2="{sx(x):.2f}" y2="{sy(ymax):.2f}" stroke="#eaeef2"/>')
|
||||||
|
for y in _ticks(ymin, ymax, _nice_step(ymax - ymin)):
|
||||||
|
parts.append(f'<line x1="{sx(xmin):.2f}" y1="{sy(y):.2f}" x2="{sx(xmax):.2f}" y2="{sy(y):.2f}" stroke="#eaeef2"/>')
|
||||||
|
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'<line x1="{sx(xmin):.2f}" y1="{sy(x_axis_y):.2f}" x2="{sx(xmax):.2f}" y2="{sy(x_axis_y):.2f}" stroke="#57606a"/>'
|
||||||
|
)
|
||||||
|
parts.append(
|
||||||
|
f'<line x1="{sx(y_axis_x):.2f}" y1="{sy(ymin):.2f}" x2="{sx(y_axis_x):.2f}" y2="{sy(ymax):.2f}" stroke="#57606a"/>'
|
||||||
|
)
|
||||||
|
# x 轴刻度数字(画在轴下方)
|
||||||
|
for x in _ticks(xmin, xmax, _nice_step(xmax - xmin)):
|
||||||
|
parts.append(
|
||||||
|
f'<text x="{sx(x):.2f}" y="{sy(x_axis_y) + 14:.2f}" text-anchor="middle" font-size="10" fill="#57606a">{html.escape(_fmt_num(x))}</text>'
|
||||||
|
)
|
||||||
|
# y 轴刻度数字(画在轴左侧)
|
||||||
|
for y in _ticks(ymin, ymax, _nice_step(ymax - ymin)):
|
||||||
|
parts.append(
|
||||||
|
f'<text x="{sx(y_axis_x) - 6:.2f}" y="{sy(y) + 3:.2f}" text-anchor="end" font-size="10" fill="#57606a">{html.escape(_fmt_num(y))}</text>'
|
||||||
|
)
|
||||||
|
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'<text x="{(_WIDTH / 2):.2f}" y="{_HEIGHT - 10:.2f}" text-anchor="middle" font-size="12" fill="#1f2328">{html.escape(plot.axes.xlabel)}</text>'
|
||||||
|
)
|
||||||
|
if plot.axes.ylabel:
|
||||||
|
parts.append(
|
||||||
|
f'<text x="16" y="{(_HEIGHT / 2):.2f}" text-anchor="middle" font-size="12" fill="#1f2328" transform="rotate(-90 16 {_HEIGHT / 2:.2f})">{html.escape(plot.axes.ylabel)}</text>'
|
||||||
|
)
|
||||||
|
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'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {_WIDTH} {_HEIGHT}" role="img">'
|
||||||
|
]
|
||||||
|
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("</svg>")
|
||||||
|
|
||||||
|
return StaticRenderResult(
|
||||||
|
content="".join(parts),
|
||||||
|
width=_WIDTH,
|
||||||
|
height=_HEIGHT,
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
@@ -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 "<svg" in svg
|
||||||
|
assert "<polyline" in svg
|
||||||
|
assert "<line" in svg # 坐标轴/网格
|
||||||
|
assert "<script" not in svg
|
||||||
|
assert rendered.width == 640
|
||||||
|
assert rendered.height == 480
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_svg_multiple_functions() -> None:
|
||||||
|
plot = parse_source("y = x^2\ny = sin(x)").plot
|
||||||
|
rendered = render_svg(plot)
|
||||||
|
assert rendered.content.count("<polyline") >= 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 '<figure class="function-plot">' in html
|
||||||
|
assert "<svg" in html
|
||||||
|
assert "<polyline" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_exporter_function_plot_fallback_on_error() -> 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 '<pre class="function-plot">' in html
|
||||||
|
assert "<svg" not in html
|
||||||
|
assert any("函数图像" in w for w in result.warnings)
|
||||||
Reference in New Issue
Block a user