fix: print PDF from actual editor theme CSS and shared Markdown rendering
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('<meta http-equiv="Content-Security-Policy" content="'+csp+'">'+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])
|
||||
@@ -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)
|
||||
|
||||
@@ -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] = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user