diff --git a/README.md b/README.md
index bc869ae..8dab76f 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# Notes Agent(暂命名) 团队开发说明
+> 第二阶段收尾(开发分支,2026-09-07):标准 Agent/RAG Benchmark 与报告页、函数图预览、三格式快照导出及真实 Provider/MCP 结果见[实现与验收记录](docs/development/第二阶段收尾实现与验收-2026-09-07.md)。当前分支尚未合并,不更改下文历史 main 基线。
+
> 本文件用于团队开发期间快速配置环境、启动项目并了解当前实现状态,不是正式的项目 README。
NotesAgent 是本地优先的 AI 笔记与知识库项目。当前可运行形态为 Vue/Vite Web 前端与 FastAPI AI Core:Markdown 和附件保存在本地 Vault,SQLite 管理元数据、全文索引、向量空间、搜索历史、AI 会话、任务、Agent Trace、多模态任务及运行诊断。AI 对话已接入知识库检索,会话与消息由后端持久化并供 Web 和桌面客户端共用。
diff --git a/backend/README.md b/backend/README.md
index df4bbf3..91a3e26 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -1,5 +1,7 @@
# NotesAgent Backend
+> 第二阶段收尾:标准 Agent/RAG Benchmark 与报告页、函数图预览、三格式快照导出及真实 Provider/MCP 结果见[实现与验收记录](../docs/development/第二阶段收尾实现与验收-2026-09-07.md)。当前分支尚未合并,不更改下文历史 main 基线。
+
NotesAgent Backend 是基于 Python 3.11+、FastAPI、Pydantic v2 和 SQLite 的本地 AI Core / Agent Core,使用 uv 管理 API 依赖和虚拟环境。
当前实现包含 Knowledge/Retrieval、Chat、Agent、Tool/Permission、Skill/Plugin、MCP、模型提供商、RAG Benchmark、多模态任务、本地模型调度、Token/音频用量和运行诊断。数据持久化位于后端 SQLite 与 Vault;Tauri Sidecar 生命周期、Stronghold 和操作系统级 Plugin 沙箱属于后续桌面阶段。
diff --git a/backend/app/agent/markdown_tools.py b/backend/app/agent/markdown_tools.py
index ee6d627..a7738e1 100644
--- a/backend/app/agent/markdown_tools.py
+++ b/backend/app/agent/markdown_tools.py
@@ -6,7 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field
from app.contracts import ToolDefinition
from app.services import note_service
-Format = Literal['heading', 'paragraph', 'bold', 'italic', 'strikethrough', 'inline-code', 'bullet-list', 'ordered-list', 'task-list', 'blockquote', 'callout', 'code-block', 'mermaid', 'inline-math', 'math-block', 'link', 'image', 'table', 'horizontal-rule', 'hard-break', 'reference-link', 'html', 'metadata']
+Format = Literal['heading', 'paragraph', 'bold', 'italic', 'strikethrough', 'inline-code', 'bullet-list', 'ordered-list', 'task-list', 'blockquote', 'callout', 'code-block', 'mermaid', 'function-plot', 'inline-math', 'math-block', 'link', 'image', 'table', 'horizontal-rule', 'hard-break', 'reference-link', 'html', 'metadata']
CALLOUTS = ['note', 'abstract', 'summary', 'tldr', 'info', 'todo', 'tip', 'hint', 'important', 'success', 'check', 'done', 'question', 'help', 'faq', 'warning', 'caution', 'attention', 'failure', 'fail', 'missing', 'danger', 'error', 'bug', 'example', 'quote', 'cite']
@@ -56,7 +56,7 @@ def compose(arguments: ComposeArguments, _):
elif kind == 'inline-code':
marker = '`' * (max([0, *(len(m[0]) for m in re.finditer(r'`+', text))]) + 1)
result = marker + ' ' + text.replace('\n', ' ') + ' ' + marker
- elif kind in ('code-block', 'mermaid'): result = fenced(text, 'mermaid' if kind == 'mermaid' else a.language)
+ elif kind in ('code-block', 'mermaid', 'function-plot'): result = fenced(text, kind if kind != 'code-block' else a.language)
elif kind in ('bullet-list', 'ordered-list', 'task-list'):
result = '\n'.join((f'{i + 1}. ' if kind == 'ordered-list' else '- [ ] ' if kind == 'task-list' else '- ') + item.replace('\n', '\n ') for i, item in enumerate(a.items))
elif kind == 'blockquote': result = '\n'.join('> ' + line for line in text.split('\n'))
@@ -90,7 +90,8 @@ def catalog(_, __):
from typing import get_args
return {'formats': list(get_args(Format)), 'callouts': CALLOUTS,
'workflow': 'Use markdown.compose, then notes.create or notes.patch_markdown to persist. Read notes.read.content_hash before patching. metadata composition replaces the frontmatter only when you explicitly patch it; do not prepend duplicate frontmatter.',
- 'rendering': 'Math, Mermaid, callouts and auto-links depend on editor preferences. HTML is sanitized; scripts are not supported. Heading folding, font size, undo and redo are UI state, not Markdown document syntax. Callout collapsed=null is static, true is folded, false is expanded.'}
+ 'function_plot': 'Use a function-plot fenced block: domain: -4, 4 followed by y = x^2 and y = sin(x). At most 16 expressions per block, 16 plots and 8000 total AST nodes per exported document. No arbitrary code execution.',
+ 'rendering': 'Function plots, Math, Mermaid, callouts and auto-links depend on editor preferences. HTML is sanitized; scripts are not supported. Heading folding, font size, undo and redo are UI state, not Markdown document syntax. Callout collapsed=null is static, true is folded, false is expanded.'}
async def patch(arguments: PatchArguments, _):
diff --git a/backend/app/benchmarks/agent.py b/backend/app/benchmarks/agent.py
new file mode 100644
index 0000000..d493ac0
--- /dev/null
+++ b/backend/app/benchmarks/agent.py
@@ -0,0 +1,153 @@
+"""通过真实 AgentRuntime 执行标准任务评测,不使用脚本化替代运行器。"""
+import asyncio
+from time import perf_counter
+from uuid import uuid4
+from app.contracts import (AgentBenchmarkRequest, AgentCaseResult, AgentRunCreateRequest,
+ BenchmarkRun, BenchmarkReport, BenchmarkKind, BenchmarkStatus, BenchmarkEvent, BenchmarkEventType)
+from app.benchmarks import datasets, service
+from app.errors import ApiError
+
+INVALID = {'TOOL_NOT_FOUND', 'TOOL_NOT_ALLOWED', 'TOOL_ARGUMENT_INVALID', 'TOOL_VALIDATION_ERROR'}
+
+def score(case, run, events, latency, repeat):
+ """按工具选择、参数、结果、输出和引用要求评定单个样本。"""
+ calls = [e.data for e in events if e.event.value == 'ToolCall']
+ # 使用最大二分匹配,避免宽松的参数子集占用唯一能满足更严格预期的调用;
+ # 每个实际调用最多匹配一个预期调用。
+ matched = {}
+ def assign(expected_index, visited):
+ expected = case.expected_tools[expected_index]
+ for call_index, call in enumerate(calls):
+ if call_index in visited or call.get('name') != expected.name:
+ continue
+ arguments = call.get('arguments', {})
+ if not all(key in arguments and arguments[key] == value for key, value in expected.arguments.items()):
+ continue
+ visited.add(call_index)
+ if call_index not in matched or assign(matched[call_index], visited):
+ matched[call_index] = expected_index
+ return True
+ return False
+ accurate = sum(assign(index, set()) for index in range(len(case.expected_tools)))
+ from collections import Counter
+ actual_names = Counter(call.get('name') for call in calls)
+ expected_names = Counter(tool.name for tool in case.expected_tools)
+ selected = sum(min(count, actual_names[name]) for name, count in expected_names.items())
+ results = run.tool_results
+ checks = {
+ 'completed': run.status.value == 'completed',
+ 'tools_selected': selected == len(case.expected_tools),
+ 'tool_arguments': accurate == len(case.expected_tools),
+ 'no_extra_calls': len(calls) <= len(case.expected_tools),
+ 'tool_results': all(r.success for r in results),
+ 'output': all(text.casefold() in (run.output or '').casefold() for text in case.output_contains),
+ 'citation': not case.citation_required or bool(run.citations),
+ 'tasks_created': case.tasks_created is None or sum(r.success and r.name == 'tasks.create' for r in results) == case.tasks_created,
+ }
+ return AgentCaseResult(case_id=case.case_id, repeat=repeat, agent_run_id=run.run_id,
+ success=all(checks.values()), tool_calls=len(calls), expected_calls=len(case.expected_tools),
+ selected_calls=selected, accurate_calls=accurate, invalid_calls=sum(r.error_code in INVALID for r in results),
+ steps=run.current_step, latency_ms=latency, token_usage=run.token_usage, checks=checks, error_code=run.error_code)
+
+def aggregate(cases, planned_total=None):
+ """汇总已执行样本,并让取消后的未执行样本继续计入计划总数。"""
+ total = len(cases) if planned_total is None else planned_total
+ calls = sum(c.tool_calls for c in cases)
+ expected = sum(c.expected_calls for c in cases)
+ # 微平均同时惩罚遗漏和多余调用;完全没有调用要求时准确率记为不适用。
+ denominator = max(calls, expected)
+ return {'total_cases': total, 'evaluated_cases': len(cases), 'task_success_rate': sum(c.success for c in cases)/total if total else 0,
+ 'tool_selection_accuracy': sum(c.selected_calls for c in cases)/denominator if denominator else None,
+ 'tool_argument_accuracy': sum(c.accurate_calls for c in cases)/denominator if denominator else None,
+ 'invalid_tool_call_rate': sum(c.invalid_calls for c in cases)/calls if calls else None,
+ 'average_steps': sum(c.steps for c in cases)/total if total else 0,
+ 'average_latency_ms': sum(c.latency_ms for c in cases)/total if total else 0,
+ 'token_usage': sum(c.token_usage for c in cases), 'tool_calls': calls, 'expected_calls': expected}
+
+async def create_run(request: AgentBenchmarkRequest):
+ """冻结数据集与运行配置,并把评测交给后台真实 Agent Runtime。"""
+ from app.container import container
+ from app.providers.registry import ProviderNotFoundError
+ try:
+ provider = container.providers.get(request.provider_id)
+ except ProviderNotFoundError as exc:
+ raise ApiError(404, 'PROVIDER_NOT_FOUND', 'Provider not found or disabled.') from exc
+ is_mock = provider.config.provider_type.value == 'mock'
+ if request.offline and not is_mock:
+ raise ApiError(422, 'BENCHMARK_OFFLINE_PROVIDER_REQUIRED', 'Offline regression only accepts a mock provider.')
+ if is_mock and not request.offline:
+ raise ApiError(422, 'BENCHMARK_REAL_PROVIDER_REQUIRED', 'Select a real provider or explicitly mark offline regression.')
+ dataset = datasets.load_dataset(request.dataset_id, BenchmarkKind.agent)
+ if not service._evict_terminal():
+ raise ApiError(429, 'BENCHMARK_CAPACITY_EXCEEDED', 'Benchmark capacity exceeded.')
+ run_id = 'benchmark_' + uuid4().hex[:12]
+ snapshot = {**request.model_dump(), 'dataset_hash': dataset.content_hash,
+ 'dataset_version': dataset.version, 'execution': 'offline' if request.offline else 'real_agent_runtime',
+ 'provider_type': provider.config.provider_type, 'scoring_version': '1.0', 'permission_policy': 'runtime_user_decision'}
+ run = BenchmarkRun(run_id=run_id, kind=BenchmarkKind.agent, dataset_id=dataset.dataset_id,
+ dataset_hash=dataset.content_hash, status=BenchmarkStatus.queued, created_at=service._now(), config_snapshot=snapshot)
+ service._runs[run_id] = run
+ service._events[run_id] = []
+ service._subscribers[run_id] = []
+ service._cancel_flags[run_id] = asyncio.Event()
+ service._tasks[run_id] = asyncio.create_task(execute(run_id, request, dataset, container.agent))
+ return run
+
+async def execute(run_id, request, dataset, runtime):
+ """顺序执行样本,传播取消信号,并持续发布可订阅的运行事件。"""
+ flag = service._cancel_flags[run_id]
+ results = []; active = None
+ def emit(kind, data):
+ event = BenchmarkEvent(event=kind, run_id=run_id, sequence=len(service._events[run_id]), data=data, timestamp=service._now())
+ service._events[run_id].append(event)
+ for queue in service._subscribers.get(run_id, []): queue.put_nowait(event)
+ status = BenchmarkStatus.completed
+ error = None
+ try:
+ service._runs[run_id] = service._runs[run_id].model_copy(update={'status': BenchmarkStatus.running, 'started_at': service._now()})
+ emit(BenchmarkEventType.run_started, {'dataset_id': dataset.dataset_id})
+ for case in dataset.cases:
+ for repeat in range(request.repeat):
+ if flag.is_set():
+ status = BenchmarkStatus.cancelled; break
+ started = perf_counter()
+ active = await runtime.create_run(AgentRunCreateRequest(input=case.prompt, provider_id=request.provider_id,
+ model=request.model, allowed_tools=case.allowed_tools, max_steps=request.max_steps,
+ token_budget=request.token_budget, run_timeout_seconds=request.timeout_seconds,
+ tool_timeout_seconds=min(30, request.timeout_seconds), allow_network=request.allow_network,
+ metadata={'benchmark_run_id': run_id, 'case_id': case.case_id}))
+ # 样本仍在运行时就暴露真实 Trace 与权限入口,便于界面处理待决授权。
+ service._runs[run_id].config_snapshot['active_agent_run_id'] = active.run_id
+ wait = asyncio.create_task(runtime.wait(active.run_id))
+ cancel = asyncio.create_task(flag.wait())
+ try:
+ done, _ = await asyncio.wait([wait, cancel], return_when=asyncio.FIRST_COMPLETED)
+ if cancel in done:
+ await runtime.cancel(active.run_id)
+ status = BenchmarkStatus.cancelled
+ finished = await wait
+ finally:
+ cancel.cancel(); await asyncio.gather(cancel, return_exceptions=True)
+ events = [event async for event in runtime.events(active.run_id)]
+ result = score(case, finished, events, (perf_counter()-started)*1000, repeat)
+ results.append(result); active = None
+ service._runs[run_id].progress = len(results)/(len(dataset.cases)*request.repeat)
+ emit(BenchmarkEventType.case_completed, result.model_dump(mode='json'))
+ if status == BenchmarkStatus.cancelled: break
+ except asyncio.CancelledError:
+ status = BenchmarkStatus.cancelled
+ except Exception:
+ status = BenchmarkStatus.failed; error = 'BENCHMARK_RUN_FAILED'
+ finally:
+ if active:
+ await runtime.cancel(active.run_id)
+ await runtime.wait(active.run_id)
+ metrics = aggregate(results, len(dataset.cases)*request.repeat)
+ run = service._runs[run_id]
+ service._runs[run_id] = run.model_copy(update={'status':status, 'metrics':metrics, 'completed_at':service._now(), 'error_code':error})
+ service._reports[run_id] = BenchmarkReport(run_id=run_id, kind=BenchmarkKind.agent,
+ dataset_id=dataset.dataset_id, dataset_hash=dataset.content_hash, status=status,
+ config_snapshot=run.config_snapshot, cases=results, metrics=metrics, error_code=error)
+ emit({BenchmarkStatus.completed: BenchmarkEventType.run_completed, BenchmarkStatus.failed: BenchmarkEventType.run_failed,
+ BenchmarkStatus.cancelled: BenchmarkEventType.run_cancelled}[status], {'metrics':metrics, 'error_code':error})
+ service._cancel_flags.pop(run_id, None); service._subscribers.pop(run_id, None)
diff --git a/backend/app/benchmarks/datasets.py b/backend/app/benchmarks/datasets.py
index fff0661..6b2901e 100644
--- a/backend/app/benchmarks/datasets.py
+++ b/backend/app/benchmarks/datasets.py
@@ -17,20 +17,20 @@ from app.config import get_settings
from app.contracts import (
BenchmarkDatasetInfo,
BenchmarkKind,
- RAGDatasetCase,
+ RAGDatasetCase, AgentDatasetCase,
)
from app.errors import ApiError
@dataclass
class RAGDataset:
- """内存中的 RAG 数据集:元信息 + 已校验的 Case 列表 + 内容哈希。"""
+ """内存中的 RAG / Agent 数据集:元信息 + 已校验的 Case 列表 + 内容哈希。"""
dataset_id: str
kind: BenchmarkKind
version: str
description: str
- cases: list[RAGDatasetCase] = field(default_factory=list)
+ cases: list[RAGDatasetCase | AgentDatasetCase] = field(default_factory=list)
content_hash: str = ""
@@ -104,10 +104,10 @@ def _dataset_from_raw(raw: dict, raw_bytes: bytes, kind: BenchmarkKind) -> RAGDa
{"dataset_id": dataset_id},
)
- cases: list[RAGDatasetCase] = []
+ cases: list[RAGDatasetCase | AgentDatasetCase] = []
for index, case in enumerate(raw_cases):
try:
- parsed = RAGDatasetCase.model_validate(case)
+ parsed = (AgentDatasetCase if kind == BenchmarkKind.agent else RAGDatasetCase).model_validate(case)
except ValidationError as exc:
raise ApiError(
422,
@@ -115,6 +115,13 @@ def _dataset_from_raw(raw: dict, raw_bytes: bytes, kind: BenchmarkKind) -> RAGDa
f"Dataset case #{index} is invalid.",
{"dataset_id": dataset_id, "case_index": index, "errors": exc.errors()},
) from exc
+ if kind == BenchmarkKind.agent:
+ if not (parsed.expected_tools or parsed.output_contains or parsed.citation_required or parsed.tasks_created is not None):
+ raise ApiError(422, 'BENCHMARK_DATASET_INVALID', 'Agent case requires objective expectations.')
+ if any(tool.name not in parsed.allowed_tools for tool in parsed.expected_tools):
+ raise ApiError(422, 'BENCHMARK_DATASET_INVALID', 'Expected tools must be allowed.')
+ cases.append(parsed)
+ continue
# 每个 Case 至少要声明一个期望 id,否则无法计算命中/召回
if not parsed.expected_note_ids and not parsed.expected_block_ids:
raise ApiError(
@@ -133,6 +140,8 @@ def _dataset_from_raw(raw: dict, raw_bytes: bytes, kind: BenchmarkKind) -> RAGDa
)
cases.append(parsed)
+ if len(cases) > 100 or len({c.case_id for c in cases}) != len(cases):
+ raise ApiError(422, 'BENCHMARK_DATASET_INVALID', 'Dataset case IDs must be unique; maximum 100 cases.')
return RAGDataset(
dataset_id=dataset_id,
kind=kind,
diff --git a/backend/app/benchmarks/rag.py b/backend/app/benchmarks/rag.py
index e364f46..6f53067 100644
--- a/backend/app/benchmarks/rag.py
+++ b/backend/app/benchmarks/rag.py
@@ -86,6 +86,7 @@ async def _evaluate_one(
limit=request.retrieval.top_k,
include_snippet=False,
rrf_k=request.retrieval.rrf_k,
+ fusion=request.retrieval.fusion,
rerank=request.retrieval.rerank,
rerank_candidates=request.retrieval.rerank_candidates,
score_threshold=request.retrieval.score_threshold,
diff --git a/backend/app/benchmarks/service.py b/backend/app/benchmarks/service.py
index f06f3ca..3ba2bbd 100644
--- a/backend/app/benchmarks/service.py
+++ b/backend/app/benchmarks/service.py
@@ -352,3 +352,12 @@ async def wait_for_run(run_id: str) -> BenchmarkRun:
if task is not None:
await task
return _runs.get(run_id)
+
+
+async def shutdown():
+ loop = asyncio.get_running_loop()
+ active = {rid: task for rid, task in _tasks.items() if not task.done() and task.get_loop() is loop}
+ for rid in active:
+ flag = _cancel_flags.get(rid)
+ if flag: flag.set()
+ await asyncio.gather(*active.values(), return_exceptions=True)
diff --git a/backend/app/contracts.py b/backend/app/contracts.py
index 05e0608..3d55b24 100644
--- a/backend/app/contracts.py
+++ b/backend/app/contracts.py
@@ -155,6 +155,7 @@ class SearchRequest(Contract):
include_snippet: bool = True
# 检索调优参数(Benchmark 与 Skill 共用):控制 RRF / 精排 / 候选池 / 分数阈值。
# rerank_candidates=None 表示对全部候选精排(保留原有行为),Benchmark 传显式值。
+ fusion: Literal['rrf', 'weighted'] = 'rrf'
rrf_k: int = Field(default=60, ge=1)
rerank: bool = True
rerank_candidates: int | None = Field(default=None, ge=1)
@@ -1221,6 +1222,7 @@ class RAGRetrievalConfig(Contract):
其余参数透传到 SearchRequest,由检索引擎实际执行。"""
top_k: int = Field(default=10, ge=1, le=100)
+ fusion: Literal['rrf', 'weighted'] = 'rrf'
rrf_k: int = Field(default=60, ge=1)
rerank: bool = True
rerank_candidates: int = Field(default=20, ge=1)
@@ -1329,6 +1331,51 @@ class RAGCaseResult(Contract):
error_code: str | None = None
+class ExpectedToolCall(Contract):
+ name: str = Field(min_length=1)
+ arguments: dict[str, Any] = Field(default_factory=dict)
+
+
+class AgentDatasetCase(Contract):
+ case_id: str = Field(min_length=1)
+ prompt: str = Field(min_length=1, max_length=20000)
+ allowed_tools: list[str] = Field(default_factory=list, max_length=30)
+ expected_tools: list[ExpectedToolCall] = Field(default_factory=list, max_length=30)
+ output_contains: list[str] = Field(default_factory=list)
+ citation_required: bool = False
+ tasks_created: int | None = Field(default=None, ge=0, le=20)
+ tags: list[str] = Field(default_factory=list)
+
+
+class AgentBenchmarkRequest(Contract):
+ dataset_id: str = Field(min_length=1)
+ provider_id: str
+ model: str = Field(min_length=1)
+ max_steps: int = Field(default=6, ge=1, le=20)
+ timeout_seconds: int = Field(default=90, ge=1, le=300)
+ token_budget: int = Field(default=6000, ge=1, le=30000)
+ repeat: int = Field(default=1, ge=1, le=3)
+ allow_network: bool = False
+ offline: bool = False
+
+
+class AgentCaseResult(Contract):
+ case_id: str
+ repeat: int
+ agent_run_id: str | None = None
+ success: bool = False
+ tool_calls: int = 0
+ expected_calls: int = 0
+ selected_calls: int = 0
+ accurate_calls: int = 0
+ invalid_calls: int = 0
+ steps: int = 0
+ latency_ms: float = 0
+ token_usage: int = 0
+ checks: dict[str, bool] = Field(default_factory=dict)
+ error_code: str | None = None
+
+
class BenchmarkReport(Contract):
run_id: str
kind: BenchmarkKind
@@ -1337,7 +1384,7 @@ class BenchmarkReport(Contract):
status: BenchmarkStatus
config_snapshot: dict[str, Any] = Field(default_factory=dict)
metrics: dict[str, Any] = Field(default_factory=dict)
- cases: list[RAGCaseResult] = Field(default_factory=list)
+ cases: list[RAGCaseResult | AgentCaseResult] = Field(default_factory=list)
error: str | None = None
error_code: str | None = None
@@ -1366,6 +1413,7 @@ class ExportSource(Contract):
"""导出源:note 引用已索引笔记,markdown 用于未保存预览(不持久化)。"""
type: ExportSourceType
+ file_path: str | None = Field(default=None, max_length=1024)
note_id: str | None = None
markdown: str | None = None
@@ -1378,7 +1426,18 @@ class ExportSource(Contract):
return self
+class ExportPalette(Contract):
+ page: str = Field(pattern=r'^#[0-9a-fA-F]{6}$')
+ surface: str = Field(pattern=r'^#[0-9a-fA-F]{6}$')
+ text: str = Field(pattern=r'^#[0-9a-fA-F]{6}$')
+ muted: str = Field(pattern=r'^#[0-9a-fA-F]{6}$')
+ code: str = Field(pattern=r'^#[0-9a-fA-F]{6}$')
+ border: str = Field(pattern=r'^#[0-9a-fA-F]{6}$')
+ accent: str = Field(pattern=r'^#[0-9a-fA-F]{6}$')
+
+
class ExportOptions(Contract):
+ palette: ExportPalette | None = None
theme_id: str = "light"
include_title: bool = True
include_metadata: bool = False
@@ -1386,11 +1445,29 @@ class ExportOptions(Contract):
code_theme: str = "github-light"
+class ExportAsset(Contract):
+ kind: Literal['mermaid', 'math_block', 'math_inline', 'image']
+ source_hash: str = Field(pattern=r'^[a-f0-9]{64}$')
+ png_base64: str
+
+
class ExportRequest(Contract):
+ print_html: str | None = None
+ assets: list[ExportAsset] = Field(default_factory=list)
+ title: str = Field(default="", max_length=200)
source: ExportSource
format: ExportFormat
options: ExportOptions = Field(default_factory=ExportOptions)
+ @model_validator(mode="after")
+ def _asset_limits(self) -> "ExportRequest":
+ if self.print_html is not None and self.format != ExportFormat.pdf:
+ raise ValueError("print_html is only supported for PDF")
+ if self.format != ExportFormat.pdf:
+ if len(self.assets) > 64 or any(len(asset.png_base64) > 2800000 for asset in self.assets):
+ raise ValueError("export asset count or size limit exceeded")
+ return self
+
class ExportProgress(Contract):
phase: str
diff --git a/backend/app/export/assets.py b/backend/app/export/assets.py
new file mode 100644
index 0000000..2026272
--- /dev/null
+++ b/backend/app/export/assets.py
@@ -0,0 +1,145 @@
+"""处理栅格资源;PDF 不受导出配额限制,但仍执行路径和格式校验。"""
+import base64
+import hashlib
+import threading
+from io import BytesIO
+from PIL import Image
+from app.errors import ApiError
+
+_math_lock = threading.Lock()
+
+def enrich_document(document, file_path=None, unlimited=False, options=None, preserve_alpha=False):
+ """内嵌 Vault 图片和 MathText,并按导出格式应用配额与主题配色。"""
+ from app.config import get_settings
+ from urllib.parse import unquote, urlsplit
+ vault = get_settings().vault_path.resolve()
+ base = (vault / (file_path or '')).parent if file_path else vault
+ from app.export.themes import pdf_palette
+ palette = pdf_palette(options, []) if unlimited and options else None
+ warnings = []
+ count = total = pixels = 0
+ def visit(node):
+ nonlocal count, total, pixels
+ if node.type in {'image','math_block','math_inline'} or node.attributes.get('static_png'):
+ count += 1
+ try:
+ if not unlimited and count > 64: raise ValueError('resource count')
+ if node.attributes.get('static_png'):
+ raw = node.attributes['static_png']
+ elif node.type == 'image':
+ src = str(node.attributes.get('src',''))
+ if urlsplit(src).scheme or src.startswith('//'): raise ValueError('remote image')
+ path = (base / unquote(src)).resolve()
+ if not path.is_relative_to(vault) or path.suffix.lower() not in {'.png','.jpg','.jpeg','.webp'} or (not unlimited and path.stat().st_size > 2_000_000):
+ raise ValueError('image path or budget')
+ raw = path.read_bytes()
+ else:
+ source = node.text
+ depth = 0
+ for char in source:
+ depth += (char == '{') - (char == '}')
+ if not unlimited and depth > 20: raise ValueError('math depth')
+ if (not unlimited and len(source) > 512) or depth != 0: raise ValueError('math budget')
+ from matplotlib.mathtext import math_to_image
+ from matplotlib import rc_context
+ with _math_lock, rc_context({'savefig.transparent': bool(palette)}):
+ out = BytesIO()
+ math_to_image('$'+source+'$', out, dpi=180, format='png', color=palette['text'] if palette else 'black')
+ raw = out.getvalue()
+ with Image.open(BytesIO(raw)) as image:
+ pixels += image.width * image.height
+ if not unlimited and pixels > 16_000_000: raise ValueError('document pixels')
+ if not unlimited and image.width * image.height > 4_000_000: raise ValueError('image dimensions')
+ out = BytesIO()
+ # 透明像素按 PDF 主题表面色合成;打印 HTML 与 Word 使用白色底色。
+ rgba=image.convert('RGBA'); background=Image.new('RGBA',rgba.size,palette['surface'] if palette else 'white')
+ background.alpha_composite(rgba); (rgba if preserve_alpha else background.convert('RGB')).save(out,'PNG')
+ png=out.getvalue();total += len(png)
+ if not unlimited and total > 8_000_000: raise ValueError('resource bytes')
+ node.attributes['static_png']=png
+ except Exception:
+ node.attributes.pop('static_png', None)
+ warnings.append('图片无法内嵌(仅支持 Vault 内 PNG/JPEG/WebP),已保留替代文字' if node.type=='image'
+ else '公式超出 MathText 语法或资源预算,已保留源码' if node.type.startswith('math')
+ else '静态图表超过文档资源预算,已保留源码')
+ for child in node.children: visit(child)
+ for child in document.children: visit(child)
+ return warnings
+
+def source_hash(source):
+ return hashlib.sha256(source.strip().encode()).hexdigest()
+
+def validate_assets(assets, unlimited=False):
+ """校验前端静态资源并解码为 PNG;PDF 仅解除容量限制,不放宽格式要求。"""
+ result = {}
+ total = pixels = 0
+ for asset in assets:
+ try:
+ raw = base64.b64decode(asset.png_base64, validate=True)
+ total += len(raw)
+ if not unlimited and total > 8 * 1024 * 1024:
+ raise ValueError('asset budget')
+ with Image.open(BytesIO(raw)) as image:
+ pixels += image.width * image.height
+ if not unlimited and pixels > 16_000_000: raise ValueError('document pixel budget')
+ if image.format != 'PNG' or (not unlimited and image.width * image.height > 4_000_000):
+ raise ValueError('image budget')
+ image.load()
+ out = BytesIO()
+ rgba = image.convert('RGBA')
+ background = Image.new('RGBA', rgba.size, 'white')
+ background.alpha_composite(rgba)
+ (rgba if unlimited else background.convert('RGB')).save(out, 'PNG')
+ key = (asset.kind, asset.source_hash)
+ if key in result:
+ raise ValueError('duplicate asset')
+ result[key] = out.getvalue()
+ except Exception as exc:
+ raise ApiError(422, 'EXPORT_ASSET_INVALID', 'Invalid PNG or resource budget exceeded.') from exc
+ return result
+
+def attach_assets(document, assets):
+ """按资源类型和源码哈希把已验证图片挂载到对应文档节点。"""
+ def visit(node):
+ source = node.attributes.get('src', '') if node.type == 'image' else node.text
+ key = (node.type, source_hash(source))
+ if key in assets:
+ node.attributes['static_png'] = assets[key]
+ for child in node.children:
+ visit(child)
+ for child in document.children:
+ visit(child)
+
+def plot_png(plot):
+ """按 SVG/PDF 共用的裁剪几何,以二倍分辨率生成 DOCX 图像。"""
+ from app.plot.render import compute_geometry, _sx, _sy, _fmt_num
+ from PIL import ImageDraw, ImageFont
+ geo = compute_geometry(plot)
+ image = Image.new('RGB', (geo.width * 2, (geo.height + ((len(plot.expressions)+1)//2)*24) * 2), 'white')
+ draw = ImageDraw.Draw(image)
+ from app.export.fonts import FONT_PATH
+ font = ImageFont.truetype(str(FONT_PATH), 20) if FONT_PATH else ImageFont.load_default(size=20)
+ def line(points, color, width=2):
+ draw.line([(x * 2, y * 2) for x, y in points], fill=color, width=width)
+ sx = lambda x: _sx(x, geo.xmin, geo.xmax)
+ sy = lambda y: _sy(y, geo.ymin, geo.ymax)
+ for x in geo.xticks:
+ if geo.grid: line([(sx(x),52),(sx(x),428)], '#d0d7de')
+ draw.text((sx(x)*2, sy(geo.x_axis_y)*2+8), _fmt_num(x), fill='#57606a', font=font)
+ for y in geo.yticks:
+ if geo.grid: line([(52,sy(y)),(588,sy(y))], '#d0d7de')
+ draw.text((max(0,sx(geo.y_axis_x)*2-75),sy(y)*2), _fmt_num(y), fill='#57606a', font=font)
+ line([(52,sy(geo.x_axis_y)),(588,sy(geo.x_axis_y))], '#57606a')
+ line([(sx(geo.y_axis_x),52),(sx(geo.y_axis_x),428)], '#57606a')
+ for segments, color in zip(geo.polylines,geo.colors):
+ for segment in segments:
+ if len(segment)>1: line(segment,color,3)
+ if geo.xlabel:
+ draw.text((geo.width, (geo.height - 18)*2), geo.xlabel, fill='#1f2328', font=font, anchor='mm')
+ if geo.ylabel:
+ # 纵轴标题横排在左上边距,避免 CJK 文本在 Word 中旋转后不可读。
+ draw.text((24, 24), geo.ylabel, fill='#1f2328', font=font)
+ for index, expression in enumerate(plot.expressions):
+ draw.text((48+(index%2)*620,geo.height*2+index//2*48),expression.label or 'y = '+expression.expression,fill=geo.colors[index],font=font)
+ out=BytesIO(); image.save(out,'PNG')
+ return out.getvalue(), geo.warnings
diff --git a/backend/app/export/browser_pdf.py b/backend/app/export/browser_pdf.py
new file mode 100644
index 0000000..8c00fae
--- /dev/null
+++ b/backend/app/export/browser_pdf.py
@@ -0,0 +1,66 @@
+"""使用真实浏览器引擎打印应用生成的自包含主题快照。
+
+子进程隔离 Playwright 在 Windows 上的事件循环与 Uvicorn,并把浏览器生命周期限制在
+单次导出内。快照禁止脚本、网络和文件加载,字体与图片必须由客户端提前内嵌。
+"""
+from pathlib import Path
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+from app.export.document import ExportResult
+
+
+def browser_executable():
+ """优先使用显式配置,再查找系统已安装的 Chromium 系浏览器。"""
+ configured = os.environ.get('APP_PDF_BROWSER')
+ if configured:
+ return configured
+ for root in (os.environ.get('PROGRAMFILES(X86)', ''), os.environ.get('PROGRAMFILES', ''), os.environ.get('LOCALAPPDATA', '')):
+ if not root:
+ continue
+ for suffix in ('Microsoft/Edge/Application/msedge.exe', 'Google/Chrome/Application/chrome.exe'):
+ candidate = Path(root) / suffix
+ if candidate.is_file():
+ return str(candidate)
+ return next((p for name in ('chromium','chromium-browser','google-chrome','microsoft-edge') if (p := shutil.which(name))), None)
+
+
+def render_snapshot(snapshot: str, page_size: str) -> ExportResult:
+ """在隔离子进程中打印快照,避免阻塞或污染服务进程的事件循环。"""
+ with tempfile.TemporaryDirectory(prefix='notes-pdf-') as directory:
+ source = Path(directory) / 'snapshot.html'
+ output = Path(directory) / 'document.pdf'
+ source.write_text(snapshot, encoding='utf-8')
+ process = subprocess.run([sys.executable, '-m', 'app.export.browser_pdf', str(source), str(output), page_size],
+ capture_output=True, text=True, encoding='utf-8', errors='replace',
+ creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0),
+ cwd=Path(__file__).resolve().parents[2])
+ if process.returncode:
+ raise RuntimeError('PDF browser rendering failed: ' + process.stderr[-2000:])
+ return ExportResult(content=output.read_bytes(), mime_type='application/pdf', warnings=[])
+
+
+def print_snapshot(source: Path, output: Path, page_size: str):
+ """在离线、禁用 JavaScript 的上下文中将自包含 HTML 打印为 PDF。"""
+ from playwright.sync_api import sync_playwright
+ with sync_playwright() as runtime:
+ browser = runtime.chromium.launch(executable_path=browser_executable(), headless=True)
+ try:
+ context = browser.new_context(java_script_enabled=False, offline=True)
+ context.route('**/*', lambda route: route.abort())
+ page = context.new_page()
+ page.set_default_timeout(0)
+ page.emulate_media(media='screen')
+ csp = "default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'"
+ page.set_content(''+source.read_text(encoding='utf-8'), wait_until='load', timeout=0)
+ page.evaluate('async () => { await document.fonts.ready; await Promise.all([...document.images].map(image => image.decode().catch(() => {}))); }')
+ page.pdf(path=str(output), format='Letter' if page_size.lower()=='letter' else 'A4',
+ print_background=True, display_header_footer=False, prefer_css_page_size=False)
+ finally:
+ browser.close()
+
+
+if __name__ == '__main__':
+ print_snapshot(Path(sys.argv[1]), Path(sys.argv[2]), sys.argv[3])
diff --git a/backend/app/export/exporters/_common.py b/backend/app/export/exporters/_common.py
index cbf41ed..f2b1cf0 100644
--- a/backend/app/export/exporters/_common.py
+++ b/backend/app/export/exporters/_common.py
@@ -1,7 +1,6 @@
"""导出器共享工具:URL 协议校验、函数图像预算与占位 warning 文案。
-html / pdf / docx 三个导出器共用同一套安全规则与函数图像资源预算,避免各写一份
-导致行为漂移。
+导出器共享 URL 规则;HTML / DOCX 使用文档资源预算,PDF 不使用这些预算。
"""
from __future__ import annotations
@@ -27,7 +26,7 @@ MAX_TOTAL_PLOT_NODES = 8000
class FunctionPlotBudget:
"""函数图像文档级资源预算:数量上限 + 累计 AST 节点上限。
- HTML 与 PDF 导出器在渲染每个 function-plot 图块前先问预算,超限即回退源码占位,
+ HTML 与 DOCX 导出器在渲染每个 function-plot 图块前先问预算,超限即回退源码占位,
不解析不采样,避免多图块组合复杂度耗尽内存/CPU。
"""
diff --git a/backend/app/export/exporters/docx.py b/backend/app/export/exporters/docx.py
index d3f03c0..b045c78 100644
--- a/backend/app/export/exporters/docx.py
+++ b/backend/app/export/exporters/docx.py
@@ -1,7 +1,7 @@
"""DocxExporter:Document AST → DOCX(python-docx)。
-v1 为文本优先:标题/段落/行内强调与链接/列表/引用/表格/代码块/数学文本均可导出;
-function_plot 与 mermaid 保留源码占位并记 warning。中文字体通过 Normal 样式挂载
+标题、段落、列表、表格等使用原生 Word 元素;函数图、已准备的 Mermaid、
+受支持的公式与 Vault 图片使用静态图片,无法表示的资源保留源码并记 warning。中文字体通过 Normal 样式挂载
w:eastAsia=宋体,保证 Word 打开时中文正常显示;bold/italic 由 Word 原生渲染。
"""
@@ -50,6 +50,8 @@ class DocxExporter:
def render(self, document: Document, options: ExportOptions) -> ExportResult:
"""同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。"""
+ from app.export.exporters._common import FunctionPlotBudget
+ self._plot_budget = FunctionPlotBudget()
self._doc = DocxDocument()
self._configure_normal_style()
self._configure_page(options)
@@ -109,6 +111,19 @@ class DocxExporter:
self._render_block(child, warnings)
def _render_block(self, node: DocumentNode, warnings: list[str]) -> None:
+ if node.attributes.get('static_png'):
+ from PIL import Image
+ png = node.attributes['static_png']
+ with Image.open(BytesIO(png)) as image:
+ section = self._doc.sections[-1]
+ available_width = (section.page_width - section.left_margin - section.right_margin) / 914400
+ # 为 Word 外层段落的行高和间距预留空间,避免图片跨出页面。
+ available_height = (section.page_height - section.top_margin - section.bottom_margin) / 914400 - 0.25
+ width = min(5.8, available_width,
+ image.width / (180 if node.type == 'math_block' else 96),
+ available_height * image.width / image.height)
+ self._doc.add_picture(BytesIO(png), width=Inches(width))
+ return
handler = getattr(self, f"_block_{node.type}", None)
if handler is not None:
handler(node, warnings)
@@ -270,7 +285,20 @@ class DocxExporter:
self._block_code_block(node, warnings)
def _block_function_plot(self, node: DocumentNode, warnings: list[str]) -> None:
- warnings.append(PLOT_PLACEHOLDER_WARNING)
+ from app.plot.parser import parse_source
+ from app.export.assets import plot_png
+ over = self._plot_budget.check_count()
+ if not over:
+ parsed = parse_source(node.text)
+ warnings.extend(d.message for d in parsed.diagnostics)
+ if parsed.plot:
+ over = self._plot_budget.check_nodes(parsed.plot.node_count)
+ if not over:
+ png, messages = plot_png(parsed.plot)
+ warnings.extend(messages)
+ self._doc.add_picture(BytesIO(png), width=Inches(5.8))
+ return
+ warnings.append(over or '函数图像无法绘制,已保留源码')
self._block_code_block(node, warnings)
def _block_math_block(self, node: DocumentNode, warnings: list[str]) -> None:
@@ -303,6 +331,12 @@ class DocxExporter:
bold: bool = False,
italic: bool = False,
) -> None:
+ if node.attributes.get('static_png'):
+ from PIL import Image
+ with Image.open(BytesIO(node.attributes['static_png'])) as image:
+ width = min(5.8, image.width / (180 if node.type.startswith('math') else 96))
+ paragraph.add_run().add_picture(BytesIO(node.attributes['static_png']), width=Inches(width))
+ return
t = node.type
if t == "text":
self._add_run(paragraph, node.text, bold=bold, italic=italic)
diff --git a/backend/app/export/exporters/html.py b/backend/app/export/exporters/html.py
index ac8c8e8..5ccd364 100644
--- a/backend/app/export/exporters/html.py
+++ b/backend/app/export/exporters/html.py
@@ -151,6 +151,16 @@ class HtmlExporter:
return "".join(self._render_node(child, warnings) for child in children)
def _render_node(self, node: DocumentNode, warnings: list[str]) -> str:
+ if node.attributes.get('static_png'):
+ import base64
+ data = base64.b64encode(node.attributes['static_png']).decode()
+ from PIL import Image
+ from io import BytesIO
+ width = ''
+ if node.type.startswith('math'):
+ with Image.open(BytesIO(node.attributes['static_png'])) as image:
+ width = f'width:{image.width*96/180:.1f}px;vertical-align:middle;'
+ return f'
'
handler = getattr(self, f"_render_{node.type}", None)
if handler is not None:
return handler(node, warnings)
@@ -257,6 +267,8 @@ class HtmlExporter:
warnings.append(f"函数图像:解析或渲染失败,已回退占位({exc})")
return f'
{html.escape(node.text)}'
warnings.extend(rendered.warnings)
+ from app.plot.render import theme_svg
+ rendered.content = theme_svg(rendered.content, self._options.theme_id)
return f'{rendered.content}'
def _render_math_block(self, node: DocumentNode, warnings: list[str]) -> str:
diff --git a/backend/app/export/exporters/pdf.py b/backend/app/export/exporters/pdf.py
index 2f44d22..42af3f2 100644
--- a/backend/app/export/exporters/pdf.py
+++ b/backend/app/export/exporters/pdf.py
@@ -20,7 +20,7 @@ from reportlab.pdfbase.cidfonts import UnicodeCIDFont
from reportlab.platypus import (
Paragraph,
Indenter,
- Preformatted,
+ XPreformatted,
SimpleDocTemplate,
Spacer,
Table,
@@ -29,12 +29,11 @@ from reportlab.platypus import (
from reportlab.platypus.flowables import HRFlowable
from app.contracts import ExportOptions
-from app.export.themes import CALLOUTS, print_theme_warning
+from app.export.themes import CALLOUTS, pdf_palette
from app.export.document import Document, DocumentNode, ExportResult
from app.export.exporters._common import (
MERMAID_WARNING,
RAW_HTML_WARNING,
- FunctionPlotBudget,
format_meta_value,
format_plot_diagnostic,
safe_url,
@@ -42,8 +41,7 @@ from app.export.exporters._common import (
from app.plot.render_reportlab import render_drawing
from app.plot.renderer import FunctionPlotStaticRenderer, StaticRenderRequest
-_FONT = "STSong-Light"
-pdfmetrics.registerFont(UnicodeCIDFont(_FONT))
+from app.export.fonts import FONT as _FONT
_MIME = "application/pdf"
@@ -55,10 +53,11 @@ _HEADING_SIZES = {1: 20, 2: 16, 3: 14, 4: 12, 5: 11, 6: 10.5}
_QUOTE_COLOR = "#57606a"
-def _make_styles() -> dict[str, ParagraphStyle]:
+def _make_styles(palette) -> dict[str, ParagraphStyle]:
body = ParagraphStyle(
"pdf-body",
fontName=_FONT,
+ textColor=palette["text"],
fontSize=10.5,
leading=16,
spaceAfter=6,
@@ -68,7 +67,7 @@ def _make_styles() -> dict[str, ParagraphStyle]:
"pdf-quote",
parent=body,
leftIndent=14,
- textColor="#57606a",
+ textColor=palette["muted"],
spaceBefore=4,
spaceAfter=6,
)
@@ -79,8 +78,8 @@ def _make_styles() -> dict[str, ParagraphStyle]:
leading=12,
leftIndent=6,
rightIndent=6,
- backColor="#f6f8fa",
- borderColor="#d0d7de",
+ backColor=palette["code"],
+ borderColor=palette["border"],
borderWidth=0.5,
borderPadding=6,
spaceBefore=4,
@@ -89,9 +88,9 @@ def _make_styles() -> dict[str, ParagraphStyle]:
math = ParagraphStyle("pdf-math", parent=body, alignment=TA_CENTER, spaceBefore=6)
cell = ParagraphStyle("pdf-cell", parent=body, fontSize=10, leading=14, spaceAfter=0)
cell_head = ParagraphStyle(
- "pdf-cell-head", parent=cell, textColor="#1f2328", fontSize=10
+ "pdf-cell-head", parent=cell, textColor=palette["text"], fontSize=10
)
- meta = ParagraphStyle("pdf-meta", parent=body, fontSize=8.5, leading=13, textColor="#57606a")
+ meta = ParagraphStyle("pdf-meta", parent=body, fontSize=8.5, leading=13, textColor=palette["muted"])
styles: dict[str, ParagraphStyle] = {
"body": body,
"title": title,
@@ -110,6 +109,7 @@ def _make_styles() -> dict[str, ParagraphStyle]:
leading=size * 1.4,
spaceBefore=14 if level <= 2 else 10,
spaceAfter=6,
+ keepWithNext=True,
)
return styles
@@ -119,16 +119,17 @@ class PdfExporter:
def render(self, document: Document, options: ExportOptions) -> ExportResult:
"""同步渲染;CPU 密集,调用方应放入线程执行,避免阻塞事件循环。"""
- self._styles = _make_styles()
warnings: list[str] = []
- print_theme_warning(options, warnings, "PDF")
+ self._palette = pdf_palette(options, warnings)
+ self._styles = _make_styles(self._palette)
+ if _FONT == "STSong-Light": warnings.append("PDF 使用 CID 字体,阅读器需提供中文字体;可配置 APP_EXPORT_FONT 嵌入 TrueType 字体")
page = _PAGE_SIZES.get((options.page_size or "A4").lower(), A4)
self._options = options
- self._plot_budget = FunctionPlotBudget()
self._plot_renderer = FunctionPlotStaticRenderer()
# 内容区宽度(左右各 20mm 边距),供函数图像缩放适配页面
- self._plot_width = page[0] - 40 * mm
+ self._plot_width = page[0] - 40 * mm - 12
+ self._plot_height = page[1] - 36 * mm - 12
buf = BytesIO()
doc = SimpleDocTemplate(
buf,
@@ -144,7 +145,14 @@ class PdfExporter:
self._render_header(document, options, story)
self._render_children(document.children, story, warnings)
- doc.build(story)
+ def paint_page(canvas, template):
+ canvas.saveState()
+ canvas.setFillColor(self._palette['page'])
+ canvas.rect(0, 0, page[0], page[1], fill=1, stroke=0)
+ canvas.setFillColor(self._palette['surface'])
+ canvas.roundRect(12*mm, 10*mm, page[0]-24*mm, page[1]-20*mm, 5*mm, fill=1, stroke=0)
+ canvas.restoreState()
+ doc.build(story, onFirstPage=paint_page, onLaterPages=paint_page)
return ExportResult(content=buf.getvalue(), mime_type=_MIME, warnings=warnings)
async def export(self, document: Document, options: ExportOptions) -> ExportResult:
@@ -169,6 +177,14 @@ class PdfExporter:
self._render_block(child, story, warnings)
def _render_block(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
+ if node.attributes.get('static_png'):
+ from reportlab.platypus import Image
+ image = Image(BytesIO(node.attributes['static_png']))
+ scale = min(1, self._plot_width / image.imageWidth, self._plot_height / image.imageHeight)
+ image.drawWidth = image.imageWidth * scale
+ image.drawHeight = image.imageHeight * scale
+ story.append(image)
+ return
handler = getattr(self, f"_block_{node.type}", None)
if handler is not None:
handler(node, story, warnings)
@@ -186,9 +202,13 @@ class PdfExporter:
def _block_callout(self, node, story, warnings):
kind = node.attributes['kind']
icon, color = CALLOUTS[kind]
+ from reportlab.lib.colors import HexColor
+ background = HexColor(self._palette['code'])
+ if .2126*background.red + .7152*background.green + .0722*background.blue < .5:
+ color = {'#0969da':'#a5d6ff','#7041a0':'#d2a8ff','#176f41':'#7ee787','#805400':'#f2cc60','#b42318':'#ffa198','#57606a':self._palette['muted']}[color]
title = self._render_inline(node.children[0].children,warnings)
style = ParagraphStyle('callout-'+kind,parent=self._styles['body'],textColor=color,
- backColor='#f6f8fa',borderColor=color,borderWidth=1,borderPadding=6,spaceBefore=8,spaceAfter=8)
+ backColor=self._palette['code'],borderColor=color,borderWidth=1,borderPadding=6,spaceBefore=8,spaceAfter=8)
story.append(Paragraph(_html.escape(icon)+' '+title,style))
self._render_children(node.children[1:],story,warnings)
@@ -201,7 +221,7 @@ class PdfExporter:
Paragraph(self._render_inline(child.children, warnings), self._styles["quote"])
)
elif child.type == "list":
- self._block_list(child, story, warnings, indent=14, color=_QUOTE_COLOR)
+ self._block_list(child, story, warnings, indent=14, color=self._palette['muted'])
else:
self._render_block(child, story, warnings)
@@ -265,7 +285,7 @@ class PdfExporter:
parts.append(self._render_inline(child.children, warnings))
elif hasattr(self, f"_block_{child.type}"):
flush()
- # Keep block content inside the list frame, including tables and callouts.
+ # 表格、警告框等块级内容也要保持在列表缩进框内。
story.append(Indenter(left=indent))
self._render_block(child, story, warnings)
story.append(Indenter(left=-indent))
@@ -293,7 +313,7 @@ class PdfExporter:
data.append(cells)
table = Table(data, repeatRows=head_row_count)
commands = [
- ("GRID", (0, 0), (-1, -1), 0.5, "#d0d7de"),
+ ("GRID", (0, 0), (-1, -1), 0.5, self._palette["border"]),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
@@ -301,53 +321,42 @@ class PdfExporter:
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
]
if head_row_count:
- commands.append(("BACKGROUND", (0, 0), (-1, head_row_count - 1), "#f6f8fa"))
+ commands.append(("BACKGROUND", (0, 0), (-1, head_row_count - 1), self._palette["code"]))
table.setStyle(TableStyle(commands))
story.append(table)
def _block_code_block(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
- story.append(Preformatted(node.text, self._styles["code"]))
+ story.append(XPreformatted(_html.escape(node.text), self._styles["code"]))
def _block_thematic_break(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
story.append(Spacer(1, 4))
- story.append(HRFlowable(width="100%", color="#d0d7de", thickness=0.5))
+ story.append(HRFlowable(width="100%", color=self._palette["border"], thickness=0.5))
story.append(Spacer(1, 6))
def _block_mermaid(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
warnings.append(MERMAID_WARNING)
- story.append(Preformatted(node.text, self._styles["code"]))
+ story.append(XPreformatted(_html.escape(node.text), self._styles["code"]))
def _block_function_plot(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
- # 文档级数量上限:超出部分直接回退占位,不解析不采样,防止海量图像耗尽资源
- over = self._plot_budget.check_count()
- if over is not None:
- warnings.append(over)
- story.append(Preformatted(node.text, self._styles["code"]))
- return
# 解析与渲染共同纳入局部异常回退:单个图像失败只回退占位 + warning,
# 绝不阻断整篇导出(含复杂表达式触发的 RecursionError 等异常)。
try:
request = StaticRenderRequest(
kind="function_plot", source=node.text, theme=self._options.theme_id
)
- parsed = self._plot_renderer.parse(request)
+ from app.plot.parser import parse_source
+ parsed = parse_source(request.source, unlimited=True)
for diag in parsed.diagnostics:
warnings.append(format_plot_diagnostic(diag))
if parsed.plot is None:
- story.append(Preformatted(node.text, self._styles["code"]))
- return
- # 文档级累计复杂度预算:超出后回退占位,不再采样求值
- over = self._plot_budget.check_nodes(parsed.plot.node_count)
- if over is not None:
- warnings.append(over)
- story.append(Preformatted(node.text, self._styles["code"]))
+ story.append(XPreformatted(_html.escape(node.text), self._styles["code"]))
return
# Drawing 本身即 Flowable,缩放后追加到 story,与 HTML 视觉一致
- drawing = render_drawing(parsed.plot, width=self._plot_width)
+ drawing = render_drawing(parsed.plot, width=self._plot_width, palette=self._palette, unlimited=True, max_height=self._plot_height)
story.append(drawing)
except Exception as exc:
warnings.append(f"函数图像:解析或渲染失败,已回退占位({exc})")
- story.append(Preformatted(node.text, self._styles["code"]))
+ story.append(XPreformatted(_html.escape(node.text), self._styles["code"]))
def _block_math_block(self, node: DocumentNode, story: list, warnings: list[str]) -> None:
story.append(Paragraph(f"$${_html.escape(node.text)}$$", self._styles["math"]))
@@ -362,6 +371,15 @@ class PdfExporter:
return "".join(self._render_inline_node(child, warnings) for child in children)
def _render_inline_node(self, node: DocumentNode, warnings: list[str]) -> str:
+ if node.attributes.get('static_png'):
+ import base64
+ from PIL import Image as PILImage
+ raw = node.attributes['static_png']
+ with PILImage.open(BytesIO(raw)) as image:
+ scale = min(.4 if node.type.startswith('math') else 1, 350/image.width, 160/image.height)
+ width, height = image.width*scale, image.height*scale
+ data = base64.b64encode(raw).decode()
+ return f'
'
t = node.type
if t == "text":
return _html.escape(node.text)
@@ -376,7 +394,7 @@ class PdfExporter:
if safe_href is None:
warnings.append(f"链接协议不安全,已降级为纯文本:{href!r}")
return inner
- return f'{inner}'
+ return f'{inner}'
if t == "image":
src = str(node.attributes.get("src") or "")
alt = str(node.attributes.get("alt") or "")
diff --git a/backend/app/export/fonts.py b/backend/app/export/fonts.py
new file mode 100644
index 0000000..d74243e
--- /dev/null
+++ b/backend/app/export/fonts.py
@@ -0,0 +1,23 @@
+"""嵌入可用的 CJK TrueType 字体,找不到时保留可移植的 CID 字体回退。"""
+import os
+from pathlib import Path
+from reportlab.pdfbase import pdfmetrics
+from reportlab.pdfbase.ttfonts import TTFont
+from reportlab.pdfbase.cidfonts import UnicodeCIDFont
+
+def register_font():
+ """按显式配置、系统字体、Linux 字体的顺序注册 PDF 中文字体。"""
+ candidates = [os.getenv('APP_EXPORT_FONT',''),
+ str(Path(os.getenv('WINDIR','C:/Windows'))/'Fonts/simsun.ttc'),
+ '/usr/share/fonts/truetype/arphic/uming.ttc']
+ for candidate in candidates:
+ if candidate and Path(candidate).is_file():
+ try:
+ pdfmetrics.registerFont(TTFont('NotesExportCJK',candidate,subfontIndex=0))
+ return 'NotesExportCJK', Path(candidate)
+ except Exception:
+ continue
+ pdfmetrics.registerFont(UnicodeCIDFont('STSong-Light'))
+ return 'STSong-Light', None
+
+FONT, FONT_PATH = register_font()
diff --git a/backend/app/export/markdown.py b/backend/app/export/markdown.py
index 436ebda..521fbf9 100644
--- a/backend/app/export/markdown.py
+++ b/backend/app/export/markdown.py
@@ -178,6 +178,9 @@ class _AstMapper:
type="link", node_id=self.next_id(), attributes=attributes,
children=self.map_inline(token.get("children", [])),
)
+ if kind == "inline_html":
+ # 保留行内 HTML 的来源标记,仅供 PDF 资源扫描识别 img;最终 HTML 仍由前端净化。
+ return DocumentNode(type="text", node_id=self.next_id(), text=token.get("raw", ""), attributes={"raw_html": True})
if kind == "codespan":
return DocumentNode(type="codespan", node_id=self.next_id(), text=token.get("raw", ""))
if kind == "image":
diff --git a/backend/app/export/service.py b/backend/app/export/service.py
index 10666eb..68bd4bc 100644
--- a/backend/app/export/service.py
+++ b/backend/app/export/service.py
@@ -146,7 +146,7 @@ def _evict_terminal() -> bool:
return True
-async def _resolve_source(source: ExportSource) -> tuple[str, str, dict | None]:
+async def _resolve_source(source: ExportSource, unlimited: bool = False) -> tuple[str, str, dict | None]:
"""把导出源解析为 (markdown, title, metadata);metadata 仅 note 源提供。"""
if source.type == ExportSourceType.note:
note = await note_service.get_note(source.note_id)
@@ -157,7 +157,7 @@ async def _resolve_source(source: ExportSource) -> tuple[str, str, dict | None]:
"note not found",
{"note_id": source.note_id},
)
- if len(note.markdown) > MAX_MARKDOWN_CHARS:
+ if not unlimited and len(note.markdown) > MAX_MARKDOWN_CHARS:
raise ApiError(
400,
"EXPORT_OPTIONS_INVALID",
@@ -175,19 +175,22 @@ async def _resolve_source(source: ExportSource) -> tuple[str, str, dict | None]:
markdown = source.markdown or ""
if not markdown.strip():
raise ApiError(400, "EXPORT_OPTIONS_INVALID", "markdown source must not be empty")
- if len(markdown) > MAX_MARKDOWN_CHARS:
+ if not unlimited and len(markdown) > MAX_MARKDOWN_CHARS:
raise ApiError(
400,
"EXPORT_OPTIONS_INVALID",
f"markdown source exceeds {MAX_MARKDOWN_CHARS} characters",
{"size": len(markdown), "limit": MAX_MARKDOWN_CHARS},
)
- return markdown, "", None
+ return markdown, "", {"file_path": source.file_path} if source.file_path else None
async def create_export(request: ExportRequest) -> ExportJob:
"""创建导出任务,立即返回 queued 的 ExportJob,由后台 Task 渲染。"""
- markdown, title, metadata = await _resolve_source(request.source)
+ markdown, title, metadata = await _resolve_source(request.source, request.format == ExportFormat.pdf)
+ title = request.title or title
+ from app.export.assets import validate_assets
+ assets = await asyncio.to_thread(validate_assets, request.assets, request.format == ExportFormat.pdf)
if not _evict_terminal():
raise ApiError(
@@ -207,7 +210,7 @@ async def create_export(request: ExportRequest) -> ExportJob:
_jobs[job_id] = job
_cancel_flags[job_id] = asyncio.Event()
_tasks[job_id] = asyncio.create_task(
- _execute(job_id, request.format, markdown, title, metadata, request.options)
+ _execute(job_id, request.format, markdown, title, metadata, request.options, assets, request.print_html)
)
return job
@@ -245,6 +248,8 @@ async def _execute(
title: str,
metadata: dict | None,
options: ExportOptions,
+ assets: dict | None = None,
+ print_html: str | None = None,
) -> None:
"""后台渲染:排队 → 解析 → 导出 → 写文件 → 挂载产物元信息。"""
cancel_event = _cancel_flags[job_id]
@@ -271,15 +276,24 @@ async def _execute(
# 解析与渲染都是 CPU 密集的同步工作,放入线程执行避免阻塞事件循环,
# 使运行中的取消能在渲染边界生效;写文件前再次检查取消。
- document = await asyncio.to_thread(parse_document, markdown)
- document.attributes["title"] = title
- if metadata:
- document.attributes["metadata"] = metadata
+ if format == ExportFormat.pdf and print_html is not None:
+ from app.export.browser_pdf import render_snapshot
+ result = await asyncio.to_thread(render_snapshot, print_html, options.page_size)
+ else:
+ document = await asyncio.to_thread(parse_document, markdown)
+ document.attributes["title"] = title
+ from app.export.assets import attach_assets
+ attach_assets(document, assets or {})
+ if metadata:
+ document.attributes["metadata"] = metadata
- result = await asyncio.to_thread(_render_document, document, options, format)
+ from app.export.assets import enrich_document
+ resource_warnings = await asyncio.to_thread(enrich_document, document, (metadata or {}).get('file_path'), format == ExportFormat.pdf, options)
+ result = await asyncio.to_thread(_render_document, document, options, format)
+ result.warnings[:0] = resource_warnings
if cancel_event.is_set():
raise ExportCancelled()
- if len(result.content) > MAX_EXPORT_BYTES:
+ if format != ExportFormat.pdf and len(result.content) > MAX_EXPORT_BYTES:
raise ExportTooLarge()
ext = _extension_for(format)
@@ -389,3 +403,42 @@ async def wait_for_export(job_id: str) -> ExportJob | None:
if task is not None:
await task
return _jobs.get(job_id)
+
+
+async def preview_resources(request: ExportRequest):
+ """为浏览器渲染器准备通过 Vault 校验的图片和静态函数图。"""
+ import base64
+ from app.export.assets import enrich_document
+ from app.plot.parser import parse_source
+ from app.plot.render import render_svg
+ from app.export.document import Document, DocumentNode
+ from html.parser import HTMLParser
+ markdown, _, metadata = await _resolve_source(request.source, True)
+ def prepare():
+ document = parse_document(markdown)
+ images, plots = [], []
+ class HtmlImages(HTMLParser):
+ # 原始 HTML 只提取 img.src;路径、扩展名和图片格式仍交给 enrich_document 校验。
+ # 行内代码和代码块在 AST 中不是 HTML 节点,因此不会误当作图片资源。
+ def handle_starttag(self, tag, attrs):
+ if tag == 'img':
+ src = dict(attrs).get('src')
+ if src:
+ visit(DocumentNode(type='image', node_id='html-image', attributes={'src':src}))
+ def visit(node):
+ if node.type == 'html_block' or node.attributes.get('raw_html'):
+ parser = HtmlImages(convert_charrefs=True)
+ parser.feed(node.text)
+ parser.close()
+ if node.type == 'image':
+ warnings = enrich_document(Document(node_id='pdf-resources', children=[node]), (metadata or {}).get('file_path'), True, request.options, preserve_alpha=True)
+ raw = node.attributes.get('static_png')
+ images.append({'source': node.attributes.get('src',''), 'data': 'data:image/png;base64,'+base64.b64encode(raw).decode() if raw else None, 'warnings': warnings})
+ if node.type == 'function_plot':
+ parsed = parse_source(node.text, unlimited=True)
+ result = render_svg(parsed.plot, request.options.theme_id, unlimited=True) if parsed.plot else None
+ plots.append({'source':node.text, 'svg':result.content if result else '', 'warnings':[d.message for d in parsed.diagnostics]+(result.warnings if result else [])})
+ for child in node.children: visit(child)
+ for child in document.children: visit(child)
+ return {'images':images,'plots':plots}
+ return await asyncio.to_thread(prepare)
diff --git a/backend/app/export/themes.py b/backend/app/export/themes.py
index 94e07ee..42dde44 100644
--- a/backend/app/export/themes.py
+++ b/backend/app/export/themes.py
@@ -1,5 +1,6 @@
"""Export palettes are fixed data; arbitrary theme CSS is never executed."""
PALETTES = {
+ 'ocean-blue': ('#edf5fa','#ffffff','#183a50','#46667a','#e6f1f8','#a6c5d9','#086b9c'),
'light': ('#f6f7f9','#ffffff','#1f2328','#57606a','#eaeef2','#d0d7de','#0969da'),
'dark': ('#010409','#0d1117','#e6edf3','#b1bac4','#21262d','#57606a','#79c0ff'),
'sepia': ('#eee5d2','#faf4e6','#463b2d','#6b5943','#eae0cd','#b5a58b','#80532a'),
@@ -31,3 +32,13 @@ ALIASES = {'summary':'abstract','tldr':'abstract','hint':'tip',
'check':'success','done':'success','help':'question','faq':'question',
'caution':'warning','attention':'warning','fail':'failure','missing':'failure',
'error':'danger','cite':'quote'}
+
+
+def pdf_palette(options, warnings):
+ if options.palette is not None:
+ return options.palette.model_dump()
+ theme_id = options.theme_id
+ if theme_id not in PALETTES:
+ warnings.append(f'PDF 不支持主题 {theme_id},已使用 light 导出配色')
+ theme_id = 'light'
+ return dict(zip(('page','surface','text','muted','code','border','accent'), PALETTES[theme_id]))
diff --git a/backend/app/local_models/runtime.py b/backend/app/local_models/runtime.py
index 7bcb7e2..3c941ca 100644
--- a/backend/app/local_models/runtime.py
+++ b/backend/app/local_models/runtime.py
@@ -5,6 +5,8 @@ import asyncio
import json
import os
import time
+import hashlib
+from collections import OrderedDict
from contextlib import closing
from contextvars import ContextVar
from functools import wraps
@@ -239,6 +241,11 @@ class Runtime:
runtime = Runtime()
+# 对确定性的单文本本地向量做有界内存复用。键包含模型目录、不可变版本和冻结运行配置;
+# 远程 API 响应以及模型不可用时的回退结果都不进入缓存。
+_embedding_cache = OrderedDict()
+_EMBEDDING_CACHE_TTL = 600
+
class LocalEmbedding:
dim = 384
@@ -264,9 +271,24 @@ class LocalEmbedding:
async def embed_documents(self, texts):
config = (self._config or configuration()).model_copy(deep=True)
+ from app.retrieval.provenance import record_embedding
+ cache_key = None
+ if len(texts) == 1 and read_state(config.embedding_model)['status'] == 'installed' and interpreter(config).is_file():
+ cache_key = (str(model_path(config.embedding_model).resolve()), config.model_dump_json(),
+ hashlib.sha256(texts[0].encode()).hexdigest())
+ cached = _embedding_cache.get(cache_key)
+ if cached and time.monotonic() - cached[0] < _EMBEDDING_CACHE_TTL:
+ _embedding_cache.move_to_end(cache_key)
+ record_embedding(query_embedding_cache='hit')
+ return [list(cached[1])]
+ record_embedding(query_embedding_cache='miss')
token = runtime_context.set(config)
try:
- return await runtime.infer(config.embedding_model, "embedding", {"texts": texts}, priority=embedding_priority.get())
+ vectors = await runtime.infer(config.embedding_model, "embedding", {"texts": texts}, priority=embedding_priority.get())
+ if cache_key and len(vectors) == 1:
+ _embedding_cache[cache_key] = (time.monotonic(), tuple(vectors[0]))
+ while len(_embedding_cache) > 128: _embedding_cache.popitem(last=False)
+ return vectors
finally:
runtime_context.reset(token)
diff --git a/backend/app/main.py b/backend/app/main.py
index c9e0651..6426954 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -35,6 +35,8 @@ async def lifespan(_: FastAPI):
try:
yield
finally:
+ from app.benchmarks import service as benchmark_service
+ await benchmark_service.shutdown()
await container.agent.shutdown()
from app.services import index_service
await index_service.shutdown()
@@ -75,6 +77,8 @@ app.include_router(local_model_router)
app.include_router(usage_router)
app.include_router(provider_preview_router)
app.include_router(log_router)
+from app.plot_routes import router as plot_router
+app.include_router(plot_router)
@app.middleware('http')
diff --git a/backend/app/plot/model.py b/backend/app/plot/model.py
index f11273e..63ba52e 100644
--- a/backend/app/plot/model.py
+++ b/backend/app/plot/model.py
@@ -1,7 +1,7 @@
"""Function Plot 内部数据模型。
-契约 §12.2 的 FunctionPlot 结构与 §10.4 的 StaticRenderResult 只在导出链路的后端内部
-流转,不进入 HTTP 契约,因此与 Document AST 一样放在独立包内,不进 contracts.py。
+FunctionPlot 供预览和导出共享;StaticRenderResult 同时是交互预览端点的响应内容。
+模型保留在独立包内,由 plot_routes 中的请求与响应类型注册 OpenAPI。
"""
from __future__ import annotations
diff --git a/backend/app/plot/parser.py b/backend/app/plot/parser.py
index 4dab231..f9dcf8c 100644
--- a/backend/app/plot/parser.py
+++ b/backend/app/plot/parser.py
@@ -143,7 +143,7 @@ def _preprocess(expr: str) -> str:
return _insert_implicit_multiplication(expr.replace("^", "**"))
-def _check_node(node: ast.AST, depth: int = 0, counter: list[int] | None = None) -> None:
+def _check_node(node: ast.AST, depth: int = 0, counter: list[int] | None = None, unlimited: bool = False) -> None:
"""白名单校验:任何越界节点都抛 FUNCTION_PLOT_EXPRESSION_UNSAFE。
同时限制 AST 深度与节点总数,避免超长/超深表达式在递归校验或求值时触发
@@ -151,10 +151,10 @@ def _check_node(node: ast.AST, depth: int = 0, counter: list[int] | None = None)
"""
if counter is None:
counter = [0]
- if depth > _MAX_AST_DEPTH:
+ if not unlimited and depth > _MAX_AST_DEPTH:
_unsafe(f"表达式嵌套过深(超过 {_MAX_AST_DEPTH} 层)")
counter[0] += 1
- if counter[0] > _MAX_AST_NODES:
+ if not unlimited and counter[0] > _MAX_AST_NODES:
_unsafe(f"表达式过于复杂(节点数超过 {_MAX_AST_NODES})")
if isinstance(node, ast.Constant):
if isinstance(node.value, bool) or not isinstance(node.value, (int, float)):
@@ -167,13 +167,13 @@ def _check_node(node: ast.AST, depth: int = 0, counter: list[int] | None = None)
if isinstance(node, ast.BinOp):
if not isinstance(node.op, _ALLOWED_BINOPS):
_unsafe(f"不支持的运算符 {type(node.op).__name__}")
- _check_node(node.left, depth + 1, counter)
- _check_node(node.right, depth + 1, counter)
+ _check_node(node.left, depth + 1, counter, unlimited)
+ _check_node(node.right, depth + 1, counter, unlimited)
return
if isinstance(node, ast.UnaryOp):
if not isinstance(node.op, _ALLOWED_UNARY):
_unsafe(f"不支持的运算符 {type(node.op).__name__}")
- _check_node(node.operand, depth + 1, counter)
+ _check_node(node.operand, depth + 1, counter, unlimited)
return
if isinstance(node, ast.Call):
if not isinstance(node.func, ast.Name) or node.func.id not in _FUNCTIONS:
@@ -184,12 +184,12 @@ def _check_node(node: ast.AST, depth: int = 0, counter: list[int] | None = None)
if len(node.args) != 1:
_unsafe(f"{node.func.id} 需要 1 个参数,实际 {len(node.args)} 个")
for arg in node.args:
- _check_node(arg, depth + 1, counter)
+ _check_node(arg, depth + 1, counter, unlimited)
return
_unsafe(f"不支持的语法 {type(node).__name__}")
-def parse_expression(expr: str) -> ast.Expression:
+def parse_expression(expr: str, unlimited: bool = False) -> ast.Expression:
"""把数学表达式解析为已通过白名单校验的 AST(可直接交给 evaluate)。"""
preprocessed = _preprocess(expr)
try:
@@ -211,7 +211,7 @@ def parse_expression(expr: str) -> ast.Expression:
message="表达式嵌套过深,无法解析",
)
) from exc
- _check_node(tree.body)
+ _check_node(tree.body, unlimited=unlimited)
return tree
@@ -279,7 +279,7 @@ def _parse_directive(line: str) -> tuple[str, str] | None:
return key, value.strip()
-def parse_source(source: str) -> FunctionPlotParseResult:
+def parse_source(source: str, unlimited: bool = False) -> FunctionPlotParseResult:
"""把 function-plot fenced block 源码解析为 FunctionPlot + 诊断。"""
diagnostics: list[PlotDiagnostic] = []
expressions: list[FunctionPlotExpression] = []
@@ -371,7 +371,7 @@ def parse_source(source: str) -> FunctionPlotParseResult:
continue
try:
- tree = parse_expression(expr_text)
+ tree = parse_expression(expr_text, unlimited=unlimited)
except PlotParseError as exc:
exc.diagnostic.line = lineno
diagnostics.append(exc.diagnostic)
@@ -380,7 +380,7 @@ def parse_source(source: str) -> FunctionPlotParseResult:
total_nodes += _count_nodes(tree.body)
expressions.append(FunctionPlotExpression(expression=expr_text))
# 表达式数量超限:整块回退并提前终止,避免对海量表达式做采样求值
- if len(expressions) > _MAX_EXPRESSIONS:
+ if not unlimited and len(expressions) > _MAX_EXPRESSIONS:
diagnostics.append(
PlotDiagnostic(
severity="error",
diff --git a/backend/app/plot/render.py b/backend/app/plot/render.py
index baee2be..fa79112 100644
--- a/backend/app/plot/render.py
+++ b/backend/app/plot/render.py
@@ -339,7 +339,7 @@ def _sample_segments(
return clipped
-def compute_geometry(plot: FunctionPlot) -> PlotGeometry:
+def compute_geometry(plot: FunctionPlot, unlimited: bool = False) -> PlotGeometry:
"""解析并计算几何,供 SVG 与 reportlab 后端复用。"""
warnings: list[str] = []
xmin, xmax = plot.domain
@@ -351,7 +351,7 @@ def compute_geometry(plot: FunctionPlot) -> PlotGeometry:
fns: list[tuple[object, object]] = []
for expr in plot.expressions:
try:
- tree = parse_expression(expr.expression)
+ tree = parse_expression(expr.expression, unlimited=unlimited)
except PlotParseError as exc:
warnings.append(f"表达式无法渲染,已跳过:{expr.expression}({exc.diagnostic.message})")
continue
@@ -413,12 +413,12 @@ def _grid_svg(geo: PlotGeometry) -> str:
for x in geo.xticks:
parts.append(
f''
+ f'y2="{sy(geo.ymax):.2f}" stroke="#eaeef2" class="plot-grid"/>'
)
for y in geo.yticks:
parts.append(
f''
+ f'y2="{sy(y):.2f}" stroke="#eaeef2" class="plot-grid"/>'
)
return "".join(parts)
@@ -430,11 +430,11 @@ def _axes_svg(geo: PlotGeometry) -> str:
# 坐标轴:过原点则画在原点,否则贴边,保证始终有参照系
parts.append(
f''
+ f'y2="{sy(geo.x_axis_y):.2f}" stroke="#57606a" class="plot-axis"/>'
)
parts.append(
f''
+ f'y2="{sy(geo.ymax):.2f}" stroke="#57606a" class="plot-axis"/>'
)
# x 轴刻度数字(画在轴下方)
for x in geo.xticks:
@@ -453,10 +453,10 @@ def _axes_svg(geo: PlotGeometry) -> str:
def _polylines_svg(geo: PlotGeometry) -> str:
parts: list[str] = []
- for segments, color in zip(geo.polylines, geo.colors):
+ for index, (segments, color) in enumerate(zip(geo.polylines, geo.colors)):
for seg in segments:
points = " ".join(f"{px:.2f},{py:.2f}" for px, py in seg)
- parts.append(f'')
+ parts.append(f'')
return "".join(parts)
@@ -476,22 +476,43 @@ def _labels_svg(geo: PlotGeometry) -> str:
return "".join(parts)
-def render_svg(plot: FunctionPlot) -> StaticRenderResult:
+def render_svg(plot: FunctionPlot, theme_id: str = 'light', unlimited: bool = False) -> StaticRenderResult:
"""把已解析的 FunctionPlot 渲染为内嵌 SVG。"""
- geo = compute_geometry(plot)
+ geo = compute_geometry(plot, unlimited=unlimited)
+ legend_height = ((len(plot.expressions) + 1) // 2) * 24
+ height = geo.height + legend_height
parts: list[str] = [
- f'