diff --git a/backend/app/contracts.py b/backend/app/contracts.py
index 07e9cc4..3d55b24 100644
--- a/backend/app/contracts.py
+++ b/backend/app/contracts.py
@@ -1452,6 +1452,7 @@ class ExportAsset(Contract):
class ExportRequest(Contract):
+ print_html: str | None = None
assets: list[ExportAsset] = Field(default_factory=list)
title: str = Field(default="", max_length=200)
source: ExportSource
@@ -1460,6 +1461,8 @@ class ExportRequest(Contract):
@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")
diff --git a/backend/app/export/assets.py b/backend/app/export/assets.py
index 9aca754..8791398 100644
--- a/backend/app/export/assets.py
+++ b/backend/app/export/assets.py
@@ -8,7 +8,7 @@ from app.errors import ApiError
_math_lock = threading.Lock()
-def enrich_document(document, file_path=None, unlimited=False, options=None):
+def enrich_document(document, file_path=None, unlimited=False, options=None, preserve_alpha=False):
"""Embed Vault images and MathText, with format-specific quotas and palette."""
from app.config import get_settings
from urllib.parse import unquote, urlsplit
@@ -53,7 +53,7 @@ def enrich_document(document, file_path=None, unlimited=False, options=None):
out = BytesIO()
# Composite transparency over the PDF theme or the print/Word white surface.
rgba=image.convert('RGBA'); background=Image.new('RGBA',rgba.size,palette['surface'] if palette else 'white')
- background.alpha_composite(rgba); background.convert('RGB').save(out,'PNG')
+ 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
diff --git a/backend/app/export/browser_pdf.py b/backend/app/export/browser_pdf.py
new file mode 100644
index 0000000..36022a0
--- /dev/null
+++ b/backend/app/export/browser_pdf.py
@@ -0,0 +1,64 @@
+"""Print the app's self-contained theme snapshot with a real browser engine.
+
+A child process isolates Playwright's Windows event loop from Uvicorn and keeps
+browser lifecycle scoped to one export. Snapshot scripts/network/file loads are
+blocked; fonts and images must already be embedded by the client.
+"""
+from pathlib import Path
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+from app.export.document import ExportResult
+
+
+def browser_executable():
+ 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):
+ 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/service.py b/backend/app/export/service.py
index f2fffe7..e001bcc 100644
--- a/backend/app/export/service.py
+++ b/backend/app/export/service.py
@@ -210,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, assets)
+ _execute(job_id, request.format, markdown, title, metadata, request.options, assets, request.print_html)
)
return job
@@ -249,6 +249,7 @@ async def _execute(
metadata: dict | None,
options: ExportOptions,
assets: dict | None = None,
+ print_html: str | None = None,
) -> None:
"""后台渲染:排队 → 解析 → 导出 → 写文件 → 挂载产物元信息。"""
cancel_event = _cancel_flags[job_id]
@@ -275,17 +276,21 @@ async def _execute(
# 解析与渲染都是 CPU 密集的同步工作,放入线程执行避免阻塞事件循环,
# 使运行中的取消能在渲染边界生效;写文件前再次检查取消。
- 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
+ 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
- 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
+ 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 format != ExportFormat.pdf and len(result.content) > MAX_EXPORT_BYTES:
@@ -398,3 +403,29 @@ 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):
+ """Prepare Vault images and vector plots for the shared browser renderer."""
+ 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
+ markdown, _, metadata = await _resolve_source(request.source, True)
+ def prepare():
+ document = parse_document(markdown)
+ images, plots = [], []
+ def visit(node):
+ 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/plot/render.py b/backend/app/plot/render.py
index 1685ecb..fa79112 100644
--- a/backend/app/plot/render.py
+++ b/backend/app/plot/render.py
@@ -476,9 +476,9 @@ def _labels_svg(geo: PlotGeometry) -> str:
return "".join(parts)
-def render_svg(plot: FunctionPlot, theme_id: str = 'light') -> 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] = [
diff --git a/backend/app/routes.py b/backend/app/routes.py
index aa91fd7..e5c0bfc 100644
--- a/backend/app/routes.py
+++ b/backend/app/routes.py
@@ -1547,6 +1547,11 @@ async def create_export(request: ExportRequest) -> ExportJob:
return await export_service.create_export(request)
+@router.post("/exports/preview-resources", tags=["Export"])
+async def export_preview_resources(request: ExportRequest):
+ return await export_service.preview_resources(request)
+
+
@router.get(
"/exports",
response_model=ExportJobListResponse,
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index f6a52ef..d500c1d 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -18,6 +18,7 @@ dependencies = [
"sqlite-vec>=0.1.9",
"uvicorn[standard]>=0.35,<1.0",
"matplotlib>=3.9,<4",
+ "playwright>=1.55,<2",
]
[dependency-groups]
diff --git a/backend/tests/test_pdf_browser.py b/backend/tests/test_pdf_browser.py
new file mode 100644
index 0000000..1a42030
--- /dev/null
+++ b/backend/tests/test_pdf_browser.py
@@ -0,0 +1,47 @@
+import asyncio
+from pathlib import Path
+import pytest
+from app.contracts import ExportRequest
+from app.export import service
+from app.export.document import ExportResult
+from app.export.browser_pdf import render_snapshot, browser_executable
+
+
+def test_browser_snapshot_uses_print_pipeline(monkeypatch):
+ calls=[]
+ def render(html,size):
+ calls.append((html,size));return ExportResult(content=b'%PDF-browser',mime_type='application/pdf')
+ monkeypatch.setattr('app.export.browser_pdf.render_snapshot',render)
+ monkeypatch.setattr(service,'parse_document',lambda _:pytest.fail('Browser snapshots must not be reparsed by ReportLab'))
+ async def run():
+ request=ExportRequest(format='pdf',source={'type':'markdown','markdown':'snapshot'},print_html='
Note
')
+ job=await service.create_export(request);done=await service.wait_for_export(job.job_id)
+ assert done.status.value=='completed'
+ assert calls==[(request.print_html,'A4')]
+ asyncio.run(run())
+
+
+def test_preview_resources_keeps_vault_boundary_and_plot_quota_removed():
+ async def run():
+ source='\n\n```function-plot\n'+'\n'.join(f'y=x+{i}' for i in range(17))+'\n```'
+ resources=await service.preview_resources(ExportRequest(format='pdf',source={'type':'markdown','markdown':source}))
+ assert resources['images'][0]['data'] is None
+ assert resources['images'][0]['warnings']
+ assert resources['plots'][0]['svg'].startswith('