fix: print PDF from actual editor theme CSS and shared Markdown rendering

This commit is contained in:
2026-09-07 14:33:19 +08:00
parent 9f097ea629
commit d1cbc10fc4
20 changed files with 530 additions and 43 deletions
+3
View File
@@ -1452,6 +1452,7 @@ class ExportAsset(Contract):
class ExportRequest(Contract): class ExportRequest(Contract):
print_html: str | None = None
assets: list[ExportAsset] = Field(default_factory=list) assets: list[ExportAsset] = Field(default_factory=list)
title: str = Field(default="", max_length=200) title: str = Field(default="", max_length=200)
source: ExportSource source: ExportSource
@@ -1460,6 +1461,8 @@ class ExportRequest(Contract):
@model_validator(mode="after") @model_validator(mode="after")
def _asset_limits(self) -> "ExportRequest": 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 self.format != ExportFormat.pdf:
if len(self.assets) > 64 or any(len(asset.png_base64) > 2800000 for asset in self.assets): 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") raise ValueError("export asset count or size limit exceeded")
+2 -2
View File
@@ -8,7 +8,7 @@ from app.errors import ApiError
_math_lock = threading.Lock() _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.""" """Embed Vault images and MathText, with format-specific quotas and palette."""
from app.config import get_settings from app.config import get_settings
from urllib.parse import unquote, urlsplit from urllib.parse import unquote, urlsplit
@@ -53,7 +53,7 @@ def enrich_document(document, file_path=None, unlimited=False, options=None):
out = BytesIO() out = BytesIO()
# Composite transparency over the PDF theme or the print/Word white surface. # 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') 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) png=out.getvalue();total += len(png)
if not unlimited and total > 8_000_000: raise ValueError('resource bytes') if not unlimited and total > 8_000_000: raise ValueError('resource bytes')
node.attributes['static_png']=png node.attributes['static_png']=png
+64
View File
@@ -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])
+42 -11
View File
@@ -210,7 +210,7 @@ async def create_export(request: ExportRequest) -> ExportJob:
_jobs[job_id] = job _jobs[job_id] = job
_cancel_flags[job_id] = asyncio.Event() _cancel_flags[job_id] = asyncio.Event()
_tasks[job_id] = asyncio.create_task( _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 return job
@@ -249,6 +249,7 @@ async def _execute(
metadata: dict | None, metadata: dict | None,
options: ExportOptions, options: ExportOptions,
assets: dict | None = None, assets: dict | None = None,
print_html: str | None = None,
) -> None: ) -> None:
"""后台渲染:排队 → 解析 → 导出 → 写文件 → 挂载产物元信息。""" """后台渲染:排队 → 解析 → 导出 → 写文件 → 挂载产物元信息。"""
cancel_event = _cancel_flags[job_id] cancel_event = _cancel_flags[job_id]
@@ -275,17 +276,21 @@ async def _execute(
# 解析与渲染都是 CPU 密集的同步工作,放入线程执行避免阻塞事件循环, # 解析与渲染都是 CPU 密集的同步工作,放入线程执行避免阻塞事件循环,
# 使运行中的取消能在渲染边界生效;写文件前再次检查取消。 # 使运行中的取消能在渲染边界生效;写文件前再次检查取消。
document = await asyncio.to_thread(parse_document, markdown) if format == ExportFormat.pdf and print_html is not None:
document.attributes["title"] = title from app.export.browser_pdf import render_snapshot
from app.export.assets import attach_assets result = await asyncio.to_thread(render_snapshot, print_html, options.page_size)
attach_assets(document, assets or {}) else:
if metadata: document = await asyncio.to_thread(parse_document, markdown)
document.attributes["metadata"] = metadata 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 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) 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 = await asyncio.to_thread(_render_document, document, options, format)
result.warnings[:0] = resource_warnings result.warnings[:0] = resource_warnings
if cancel_event.is_set(): if cancel_event.is_set():
raise ExportCancelled() raise ExportCancelled()
if format != ExportFormat.pdf and len(result.content) > MAX_EXPORT_BYTES: 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: if task is not None:
await task await task
return _jobs.get(job_id) 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)
+2 -2
View File
@@ -476,9 +476,9 @@ def _labels_svg(geo: PlotGeometry) -> str:
return "".join(parts) 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。""" """把已解析的 FunctionPlot 渲染为内嵌 SVG。"""
geo = compute_geometry(plot) geo = compute_geometry(plot, unlimited=unlimited)
legend_height = ((len(plot.expressions) + 1) // 2) * 24 legend_height = ((len(plot.expressions) + 1) // 2) * 24
height = geo.height + legend_height height = geo.height + legend_height
parts: list[str] = [ parts: list[str] = [
+5
View File
@@ -1547,6 +1547,11 @@ async def create_export(request: ExportRequest) -> ExportJob:
return await export_service.create_export(request) 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( @router.get(
"/exports", "/exports",
response_model=ExportJobListResponse, response_model=ExportJobListResponse,
+1
View File
@@ -18,6 +18,7 @@ dependencies = [
"sqlite-vec>=0.1.9", "sqlite-vec>=0.1.9",
"uvicorn[standard]>=0.35,<1.0", "uvicorn[standard]>=0.35,<1.0",
"matplotlib>=3.9,<4", "matplotlib>=3.9,<4",
"playwright>=1.55,<2",
] ]
[dependency-groups] [dependency-groups]
+47
View File
@@ -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='<style>h1::before{content:"tape"}</style><h1>Note</h1>')
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='![outside](../../private.png)\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('<svg')
asyncio.run(run())
@pytest.mark.skipif(browser_executable() is None,reason='No installed Chromium browser')
def test_browser_prints_css_without_executing_document_scripts(tmp_path):
# The script would erase all text if executed. Embedded CSS and fonts must
# survive the browser path while network and file resources stay blocked.
html='<style>h1{color:#875343;font-size:37px} h1::before{content:"Theme "}</style><h1>Snapshot</h1><script>document.body.innerHTML="EXECUTED"</script><img src="file:///private.png">'
result=render_snapshot(html,'A4')
assert result.content.startswith(b'%PDF')
assert b'/Subtype /Type0' in result.content or b'/Type /Font' in result.content
import shutil, subprocess
if shutil.which('pdftotext'):
pdf=tmp_path/'snapshot.pdf'; pdf.write_bytes(result.content)
text=subprocess.check_output(['pdftotext',str(pdf),'-']).decode('utf-8')
assert 'Theme Snapshot' in text
assert 'EXECUTED' not in text
+110
View File
@@ -547,6 +547,83 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/82/f8/7188153c4b265c899cd035de6a062677d51f67118a4ba640902bd9683e90/fonttools-4.64.0-py3-none-any.whl", hash = "sha256:4a05783ff54ce4c7a28f18e5772efdf63c219374bd9ffc55452182e1cef8be60", size = 1195327, upload-time = "2026-08-31T15:44:31.741Z" }, { url = "https://files.pythonhosted.org/packages/82/f8/7188153c4b265c899cd035de6a062677d51f67118a4ba640902bd9683e90/fonttools-4.64.0-py3-none-any.whl", hash = "sha256:4a05783ff54ce4c7a28f18e5772efdf63c219374bd9ffc55452182e1cef8be60", size = 1195327, upload-time = "2026-08-31T15:44:31.741Z" },
] ]
[[package]]
name = "greenlet"
version = "3.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/a3/07297917485ee2ca85bc3c8dc6ed85ad3fffcf424047fba62671dba68e97/greenlet-3.5.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:be63afcbbccfad3dd95a1ba12ada84dab2ef32031973d80b5b92df67fa763a61", size = 294165, upload-time = "2026-08-10T13:25:17.987Z" },
{ url = "https://files.pythonhosted.org/packages/db/51/6f732f9314cda54c5fd48a7620c7160f4f286967e8045ad94b9d66ce80b7/greenlet-3.5.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a268024ce2d7d2b04694bf1594058981a9fa663d1df4b762dee499211ed7c1c", size = 613610, upload-time = "2026-08-10T14:14:33.829Z" },
{ url = "https://files.pythonhosted.org/packages/d8/c0/b27589e25d220289edcd4d582b2b17b83058d1a56d53d971b6ea1a34f10d/greenlet-3.5.5-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35cbb8bf55ace57fbccb4fb8622c4521713acd8691e77f4696d416ea7ca527da", size = 625481, upload-time = "2026-08-10T14:27:23.647Z" },
{ url = "https://files.pythonhosted.org/packages/39/82/5c873dbb4fb001d22fbbd50e80d4c1b0181ddae106856132160f84b94e88/greenlet-3.5.5-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:abc8bc8d9f935cd685457545b6a53863a877fdc12c2c0f5ee9beee18d9db139c", size = 633329, upload-time = "2026-08-10T14:30:06.062Z" },
{ url = "https://files.pythonhosted.org/packages/51/2d/f2c928218ac52f26d7a2c188c171d1b7e728b23782cb3347e7b4fce1493a/greenlet-3.5.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cc6df89ec5302337adc9cf096221cbed2510fd444b0e0f1586cf0470740864", size = 624562, upload-time = "2026-08-10T13:40:48.064Z" },
{ url = "https://files.pythonhosted.org/packages/70/12/f7df98e72a8eb4a7edfec5a08d6d1a4ab53a52c95ba0b2ea6c10b8dd9bd0/greenlet-3.5.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:3134291427bb0f3526e9d90311988caf336eb43730e95244997a4fb15f45144f", size = 428145, upload-time = "2026-08-10T14:30:01.071Z" },
{ url = "https://files.pythonhosted.org/packages/3e/4a/92fc51d5d35912f4f06eec037ba347985defd0be47463a010a325634d9d2/greenlet-3.5.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d9b454c5fc48aeaa7c4337813dbf513a6870468e426438a04d922c6d0fe63db", size = 1584909, upload-time = "2026-08-10T14:15:04.343Z" },
{ url = "https://files.pythonhosted.org/packages/ac/58/ed98b80ac5738c149a5258544843c45601ade1fd70f61740cdaead6351b3/greenlet-3.5.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03551ed792cb1b4fc0277a0c60dfd8c343894a0ba06fe60dcd22f568b433da39", size = 1651184, upload-time = "2026-08-10T13:40:28.879Z" },
{ url = "https://files.pythonhosted.org/packages/d8/be/b582ceb80cefdf9d8da34078714e4b12b3d16f509dee0f65e40a5cc8fc7d/greenlet-3.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:ab3df3dffb58bf70564e93a5cec7941e4d9faa5a36cc4234a10d3131afe04f53", size = 323280, upload-time = "2026-08-10T13:26:07.495Z" },
{ url = "https://files.pythonhosted.org/packages/4d/18/5313c4c58598c38b0373c013e4ff2b3e6d258aaaa338f373335ebecdaddd/greenlet-3.5.5-cp311-cp311-win_arm64.whl", hash = "sha256:2b70a766135540c472ac1393d57c2e1b4a2eb85bf526a1e41e6d096173a8cee5", size = 307785, upload-time = "2026-08-10T13:28:34.874Z" },
{ url = "https://files.pythonhosted.org/packages/2e/7e/9ecd0285e3153532ae07aeb88063c43c72b4221cf0d4d123b02f3682e3ff/greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380", size = 295809, upload-time = "2026-08-10T13:25:34.023Z" },
{ url = "https://files.pythonhosted.org/packages/35/73/60e4bbcc89252037b18087f2ec16405d5b2d5be42dde191bbf3667e96102/greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053", size = 611910, upload-time = "2026-08-10T14:14:35.18Z" },
{ url = "https://files.pythonhosted.org/packages/a4/17/cd5134be659cd4a443e7a61ae670dabec165a814c51162916d637b6dd38e/greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95", size = 624198, upload-time = "2026-08-10T14:27:25.229Z" },
{ url = "https://files.pythonhosted.org/packages/9b/30/87c212b5c684d0e72974f1063b7a9687631e8985902c06e1016542c874e7/greenlet-3.5.5-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ca5d6ae0739e5764f2cfcfaa562ac5a990cbdaedca93251c5e3cf07c362371f", size = 629504, upload-time = "2026-08-10T14:30:07.967Z" },
{ url = "https://files.pythonhosted.org/packages/78/ac/5c5b959999b6f09c3026b5dfe171575bc3121c5236ce74f495096f25b203/greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d", size = 621439, upload-time = "2026-08-10T13:40:49.391Z" },
{ url = "https://files.pythonhosted.org/packages/63/2c/eb487fafc9f50ffff2b1e0b697f70fb34bf150821c08ab225aacf5583a7e/greenlet-3.5.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:1b5ed9162c0c098e0bbc2cf88a94f433c1b8926f831745252e099e5d83e17759", size = 432462, upload-time = "2026-08-10T14:30:02.309Z" },
{ url = "https://files.pythonhosted.org/packages/c8/8b/6acf112ed8aee499f25b4d6949820fb02ac950ff9c1f3d793bd5be0599f2/greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b", size = 1581342, upload-time = "2026-08-10T14:15:05.653Z" },
{ url = "https://files.pythonhosted.org/packages/b8/d7/734e5f198888876b42d7616ff6644c075baf6b8a2412deadd6b0e1b8b20c/greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2", size = 1645744, upload-time = "2026-08-10T13:40:30.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/30/1f42b88dc587b5899ee50616ad56ee40cafaf225df4fb829f10183c62a5c/greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18", size = 324171, upload-time = "2026-08-10T13:28:44.472Z" },
{ url = "https://files.pythonhosted.org/packages/76/e5/4dee4d8d2e603fe5fdd7b444e63219f7b9bd852c60c6214511c7157cbe88/greenlet-3.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52", size = 308362, upload-time = "2026-08-10T13:26:46.839Z" },
{ url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730, upload-time = "2026-08-10T13:27:51.206Z" },
{ url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536, upload-time = "2026-08-10T14:14:36.589Z" },
{ url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924, upload-time = "2026-08-10T14:27:27.048Z" },
{ url = "https://files.pythonhosted.org/packages/0e/84/eaa476d6bf3816828d0d70e80dcc36bf30a058233bd889e707e693f6e860/greenlet-3.5.5-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42", size = 632726, upload-time = "2026-08-10T14:30:09.874Z" },
{ url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906, upload-time = "2026-08-10T13:40:50.501Z" },
{ url = "https://files.pythonhosted.org/packages/d0/f2/0cc2849ede68579291e9c59b3ab6ec1958f98681cca5b14d8fc75bf674a4/greenlet-3.5.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b", size = 434966, upload-time = "2026-08-10T14:30:03.729Z" },
{ url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430, upload-time = "2026-08-10T14:15:06.853Z" },
{ url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684, upload-time = "2026-08-10T13:40:32.133Z" },
{ url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075, upload-time = "2026-08-10T13:26:58.974Z" },
{ url = "https://files.pythonhosted.org/packages/45/78/649cb5c09d4d81f6dd1444e75474a7206784743283a21d24171562ac4899/greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc", size = 308260, upload-time = "2026-08-10T13:27:50.795Z" },
{ url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" },
{ url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" },
{ url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" },
{ url = "https://files.pythonhosted.org/packages/eb/52/f005d579acde46c3d1cc3cab1c9f3d5708c8a3006a4120e8cf5da801afe9/greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8", size = 677863, upload-time = "2026-08-10T14:30:11.663Z" },
{ url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" },
{ url = "https://files.pythonhosted.org/packages/c4/8a/a75f8a2bdcef3c358a3147cdc9db3aa83755f0a038f766ab0bedb66f512c/greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53", size = 480554, upload-time = "2026-08-10T14:30:05.171Z" },
{ url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" },
{ url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" },
{ url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" },
{ url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" },
{ url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" },
{ url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" },
{ url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ac/0d7887aa4bbfc9eba075cc428244dfc96f623478454d5ec81180d0d6bd5a/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387", size = 681587, upload-time = "2026-08-10T14:30:13.519Z" },
{ url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" },
{ url = "https://files.pythonhosted.org/packages/4f/18/8d58ba1c429b0383e3219a3d0e0bba241d0444d8ed05b73349953c7d7c7b/greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41", size = 510175, upload-time = "2026-08-10T14:30:07.047Z" },
{ url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" },
{ url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" },
{ url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" },
{ url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" },
{ url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" },
{ url = "https://files.pythonhosted.org/packages/65/53/4e13642efc4d7ad6554ecb2242a5be42666b2e1a067323e88dfc0124a04b/greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76", size = 681436, upload-time = "2026-08-10T14:30:14.839Z" },
{ url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" },
{ url = "https://files.pythonhosted.org/packages/cd/32/188447c9a468d6977d2989397226b0c6b65ab6f4cf943f931643328512fc/greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474", size = 487404, upload-time = "2026-08-10T14:30:08.903Z" },
{ url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" },
{ url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" },
{ url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" },
{ url = "https://files.pythonhosted.org/packages/67/67/857e88a36301caa0e029870132c2478bd55d896630321432afab03a3115f/greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769", size = 311750, upload-time = "2026-08-10T13:34:08.815Z" },
{ url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" },
{ url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" },
{ url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" },
{ url = "https://files.pythonhosted.org/packages/ce/f4/e450a68a152f819491d8c7df6a8254e761d87e6a78759268961f8c5bd4dd/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1", size = 686022, upload-time = "2026-08-10T14:30:15.96Z" },
{ url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" },
{ url = "https://files.pythonhosted.org/packages/18/23/17e63d6bf3b9c9b9dbea981b7f643a71f79603bdfb4f1c3a9cf353e22aed/greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f", size = 516951, upload-time = "2026-08-10T14:30:10.907Z" },
{ url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" },
{ url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" },
{ url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" },
{ url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" },
]
[[package]] [[package]]
name = "h11" name = "h11"
version = "0.16.0" version = "0.16.0"
@@ -1020,6 +1097,7 @@ dependencies = [
{ name = "matplotlib" }, { name = "matplotlib" },
{ name = "mistune" }, { name = "mistune" },
{ name = "olefile" }, { name = "olefile" },
{ name = "playwright" },
{ name = "python-docx" }, { name = "python-docx" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "referencing" }, { name = "referencing" },
@@ -1042,6 +1120,7 @@ requires-dist = [
{ name = "matplotlib", specifier = ">=3.9,<4" }, { name = "matplotlib", specifier = ">=3.9,<4" },
{ name = "mistune", specifier = ">=3.0,<4.0" }, { name = "mistune", specifier = ">=3.0,<4.0" },
{ name = "olefile", specifier = ">=0.47" }, { name = "olefile", specifier = ">=0.47" },
{ name = "playwright", specifier = ">=1.55,<2" },
{ name = "python-docx", specifier = ">=1.1,<2.0" }, { name = "python-docx", specifier = ">=1.1,<2.0" },
{ name = "pyyaml", specifier = ">=6.0,<7.0" }, { name = "pyyaml", specifier = ">=6.0,<7.0" },
{ name = "referencing", specifier = ">=0.36,<1.0" }, { name = "referencing", specifier = ">=0.36,<1.0" },
@@ -1314,6 +1393,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
] ]
[[package]]
name = "playwright"
version = "1.62.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet" },
{ name = "pyee" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/5b/ca2abcf3aa69f9fb510215e3064f30b57fe57657c8d04ede45bb966d5606/playwright-1.62.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d8da938f3748841a8754f2e1f0216902c1c8f8ae3720de8b32ccf8e6913a7c4f", size = 43732091, upload-time = "2026-07-31T17:00:44.178Z" },
{ url = "https://files.pythonhosted.org/packages/af/1a/0bfbe9904350961f4dbb713f04342e40d548c5fc26c8157bd13617c81492/playwright-1.62.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:db755ab27db21a04186f1fe8169888e42356086e439b1059b923ef417f0b6034", size = 42510842, upload-time = "2026-07-31T17:00:48.596Z" },
{ url = "https://files.pythonhosted.org/packages/66/dc/c0486b407ad0699a250f6bbe3066fca95344009a99ca66e88ca175c69dc1/playwright-1.62.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:5108bd5b3e87169ddf269feee097da5893af7f8aea4634dfc840518d64c1f1da", size = 43732093, upload-time = "2026-07-31T17:00:52.218Z" },
{ url = "https://files.pythonhosted.org/packages/43/6b/b24aebc2b04bffcb342bccf96e287c78b363e1615bed5cea97500cc0393a/playwright-1.62.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ba33bae6a13b3d9d354c751cb618af357d20fe1d57767cbcce52079bbef17ad3", size = 47748926, upload-time = "2026-07-31T17:00:56.438Z" },
{ url = "https://files.pythonhosted.org/packages/36/43/b4b18bdc87e1949568fffdcde3ff9a0456266b2d0c6d4432cc34d89ea6eb/playwright-1.62.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db2d76613a57ad844362ce42f7d0c2fa26b19a4f7a46d4f76b891c631e6e5aff", size = 47441423, upload-time = "2026-07-31T17:01:00.404Z" },
{ url = "https://files.pythonhosted.org/packages/81/22/af5d926fc2c32a339eec00a443644bc40ab9db1dd2dd9017873c59773c0c/playwright-1.62.0-py3-none-win32.whl", hash = "sha256:e5614fa89355d7081457680324bb219f79f69c423c5cb6fa250e30b0d8aebf1c", size = 38164450, upload-time = "2026-07-31T17:01:04.187Z" },
{ url = "https://files.pythonhosted.org/packages/2b/a9/4160c1033c07af98bf841ad079457dd78408a5ee0dd56cbfe50b8b6a1c22/playwright-1.62.0-py3-none-win_amd64.whl", hash = "sha256:92c0d98ed04eb35af557b709875edba415b1f548bdb22ddb5bb3e1e6c835c2f1", size = 38164458, upload-time = "2026-07-31T17:01:08.459Z" },
{ url = "https://files.pythonhosted.org/packages/6c/ec/06b55d619a7082a766aa04f2c6bb31435c87f02930087d8a0517119408fa/playwright-1.62.0-py3-none-win_arm64.whl", hash = "sha256:ea8d3055aa9d5a9f1832ac82517bd8b42c78fac7ebcbebb0107116735c8cb6a1", size = 34208868, upload-time = "2026-07-31T17:01:11.818Z" },
]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"
@@ -1449,6 +1547,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
] ]
[[package]]
name = "pyee"
version = "13.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
]
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.21.0" version = "2.21.0"
@@ -1638,3 +1638,10 @@ PDF 使用当前主题配色。`ExportOptions.palette` 可选,包含 page/surf
关闭导出窗口仅停止 UI 轮询,已发起的导出继续。主动取消在准备阶段停止提交;创建请求期间取消会等待任务 ID,调用后台取消接口并读取实际状态。 关闭导出窗口仅停止 UI 轮询,已发起的导出继续。主动取消在准备阶段停止提交;创建请求期间取消会等待任务 ID,调用后台取消接口并读取实际状态。
RAG `retrieval.fusion`接受rrf(默认)或weighted(归一化FTS/vector各50%),参数写入config_snapshot。真实本地单查询Embedding可命中有界进程缓存,provenance.query_embedding_cache为hit/miss;比较延迟须分别报告冷暖样本。HashEmbedding仍仅为确定性单元测试,不是当前生产检索模型。 RAG `retrieval.fusion`接受rrf(默认)或weighted(归一化FTS/vector各50%),参数写入config_snapshot。真实本地单查询Embedding可命中有界进程缓存,provenance.query_embedding_cache为hit/miss;比较延迟须分别报告冷暖样本。HashEmbedding仍仅为确定性单元测试,不是当前生产检索模型。
## PDF 浏览器快照契约(2026-09-07
`ExportRequest.print_html?: string` 仅允许 format=pdf。应用导出发送包含实际主题 CSS、根属性/CSS 变量、笔记 DOM 和内嵌字体图片的 HTML 快照;后端通过禁用脚本与网络的 Chromium 打印。`options.page_size` 控制 A4/Letter。print_html 存在时不再经过 ReportLab 的 AST 重排;无此字段的旧客户端维持兼容渲染。
`POST /api/exports/preview-resources` 接收 ExportRequest 的 source/options,返回 `{images:[{source,data:string|null,warnings:string[]}],plots:[{source,svg,warnings:string[]}]}`,供浏览器共享 Markdown 渲染器使用。图片仅解析 Vault 相对路径;函数图使用共享白名单解释器,PDF 不使用预览页面配额。客户端准备失败不会创建半成品任务,已提交任务仍走统一取消和下载接口。
+11
View File
@@ -167,3 +167,14 @@ PDF 使用导出按钮点击时的主题配色快照,支持六种内置/社区
PDF 取消导出源/产物大小限制、Mermaid 数量和像素预算、请求资源数量和字节预算、Vault 图片大小/累计预算、MathText 长度/深度预算、函数图数量/表达式数量及 AST 预算。HTML/DOCX、在线预览仍使用原有限制;有效图片、Vault 路径、表达式语法校验和数值求解退出条件仍生效。无限制指移除应用导出配额,实际文件规模仍受浏览器、解析器和可用内存约束。 PDF 取消导出源/产物大小限制、Mermaid 数量和像素预算、请求资源数量和字节预算、Vault 图片大小/累计预算、MathText 长度/深度预算、函数图数量/表达式数量及 AST 预算。HTML/DOCX、在线预览仍使用原有限制;有效图片、Vault 路径、表达式语法校验和数值求解退出条件仍生效。无限制指移除应用导出配额,实际文件规模仍受浏览器、解析器和可用内存约束。
关闭窗口仅结束 UI 生命周期,导出流程继续;主动取消仍取消提交中的后台任务。回归测试见 `ExportDialog.spec.ts``exportService.spec.ts``test_pdf_theme_resources.py` 关闭窗口仅结束 UI 生命周期,导出流程继续;主动取消仍取消提交中的后台任务。回归测试见 `ExportDialog.spec.ts``exportService.spec.ts``test_pdf_theme_resources.py`
### PDF 实际主题样式修正(2026-09-07)
七色调色板加 ReportLab 固定排版不能复现笔记主题。应用界面的 PDF 导出现在提交 `print_html`:复用 Markdown 渲染器、KaTeX、Shiki、编辑器 DOM 容器、主题 CSS、CSS 变量、标题偏好和字体资源,由 Chromium 打印为可选择文本的 PDF。保留背景纹理、伪元素、边框、阴影和语义样式;打印规则只处理页面尺寸、滚动容器、分页、图表缩放和交互按钮。提示块采用编辑器的 blockquote 结构以匹配主题选择器。KaTeX 内部 SVG 不应用图表缩放规则。
`POST /api/exports/preview-resources` 根据同一 Markdown 快照准备 Vault 图片和函数图 SVG,不恢复 PDF 的旧资源配额。图片保留透明通道;不读取 Vault 外文件。客户端将字体和主题图片内嵌为 data URI。浏览器进程关闭文档 JavaScript、网络与文件资源加载,等待字体就绪后打印;不会为静态 CSS 加载用户脚本。
后端依赖 Playwright(已写入 pyproject/uv.lock)。Windows 优先使用本机 Edge/Chrome,可用 `APP_PDF_BROWSER` 指定可执行文件;没有系统浏览器时,在 backend 目录运行 `uv run playwright install chromium`。打印在独立子进程中执行,避免 Windows Uvicorn 事件循环冲突。
不传 print_html 的旧 API 客户端保留 ReportLab 兼容路径,其固定排版不能代表应用主题的实际样式。HTML/DOCX 导出行为不变。主题字体/图片需可从应用资源读取并内嵌,读取失败会报错,避免输出缺失资源却宣称样式一致。
@@ -0,0 +1,11 @@
# PDF 实际主题样式修复验收
路径:G:/OSProject/NotesAgent。此前七色调色板验收仅覆盖配色,不能代表完整主题样式一致;本次替换应用 PDF 导出的渲染链路。
- 使用真实主题 CSS、编辑器容器、伪元素、字体、标题偏好、KaTeX 与 ShikiChromium 输出 PDF;旧客户端 ReportLab 路径仅用于兼容。
- 纸间时光保留纸张边框、缝线、左侧装订线、顶部胶带、提示块与代码块装饰。修复跨页图表右侧裁切、提示块 DOM 选择器及 KaTeX 根号 SVG 受图表规则影响的问题。
- 无旧 PDF 数量/大小配额;内嵌资源、禁用文档脚本及网络访问、保留用户主动取消行为。
- 六主题各两页,由真实前端构造快照、实际后端导出、Poppler 渲染检查。样例和浏览器参考截图位于本机 .local-plans/pdf-browser。
- 后端全量 882 项、前端全量 451 项通过;最终排版调整后相关前端19项、资源/浏览器后端15项通过;生产构建成功。1 条既有 Starlette 警告和既有大分包提示保留。
- 浏览器烟测验证 CSS 伪元素文本进入 PDF,文档脚本未执行;Vault 越界图片被拒绝,17 条函数表达式可准备。
- 用户已有的三份笔记修改未纳入提交。
@@ -0,0 +1,50 @@
[
{
"theme": "paper-moments",
"pages": 2,
"bytes": 187968,
"sha256": "33a28768101307d97f950135bd8910907ab88df82174b87d4350bd02c71e3236",
"renderer": "Chromium / actual CSS snapshot",
"visual_review": "passed"
},
{
"theme": "light",
"pages": 2,
"bytes": 105426,
"sha256": "50bd5cfb9303c17d1b34fd5e7b3ed05915720dd45aba2dfa5623e4c387f273a6",
"renderer": "Chromium / actual CSS snapshot",
"visual_review": "passed"
},
{
"theme": "dark",
"pages": 2,
"bytes": 105445,
"sha256": "586da3acaf3f55ad802da13c188544a50347f1b58d2fbe2c20a22605d9d9e3d9",
"renderer": "Chromium / actual CSS snapshot",
"visual_review": "passed"
},
{
"theme": "sepia",
"pages": 2,
"bytes": 105471,
"sha256": "700bf49901ef23da7f760efba346a69a61f7ce4f3bb26b3b2e7ab673ecbd3809",
"renderer": "Chromium / actual CSS snapshot",
"visual_review": "passed"
},
{
"theme": "ocean-blue",
"pages": 2,
"bytes": 105414,
"sha256": "7501dc0765af583d498ed54c9678631d9e6578dcb68dbc6db9c0e83551cc3479",
"renderer": "Chromium / actual CSS snapshot",
"visual_review": "passed"
},
{
"theme": "midnight-purple",
"pages": 2,
"bytes": 105489,
"sha256": "213f59e4b43e701deda9e6b8589913c2d795d3ef8a642b516ba425aa1bd6da9e",
"renderer": "Chromium / actual CSS snapshot",
"visual_review": "passed"
}
]
@@ -37,7 +37,7 @@ onBeforeUnmount(() => { disposed = true; clearTimeout(timer) })
<label>纸张 <select v-model="page"><option>A4</option><option>Letter</option></select></label> <label>纸张 <select v-model="page"><option>A4</option><option>Letter</option></select></label>
<label><input v-model="title" type="checkbox">包含标题</label> <label><input v-model="title" type="checkbox">包含标题</label>
<p v-if="format === 'docx'">DOCX 使用浅色打印样式</p> <p v-if="format === 'docx'">DOCX 使用浅色打印样式</p>
<p v-if="format === 'pdf'">PDF 使用当前主题配色</p> <p v-if="format === 'pdf'">PDF 使用当前笔记主题与排版样式</p>
<button class="button-primary" :disabled="preparing || !editor.content.trim()" @click="start">{{ preparing ? '准备图表' : '开始导出' }}</button> <button class="button-primary" :disabled="preparing || !editor.content.trim()" @click="start">{{ preparing ? '准备图表' : '开始导出' }}</button>
<button v-if="preparing" @click="controller?.abort()">取消准备</button> <button v-if="preparing" @click="controller?.abort()">取消准备</button>
<p v-if="error" role="alert">{{ error }}</p> <p v-if="error" role="alert">{{ error }}</p>
+7 -12
View File
@@ -3,6 +3,8 @@ import {webcrypto} from 'node:crypto'
import {describe,it,expect,vi,afterEach} from 'vitest' import {describe,it,expect,vi,afterEach} from 'vitest'
vi.mock('./apiClient',()=>({apiClient:{post:vi.fn(),get:vi.fn()}})) vi.mock('./apiClient',()=>({apiClient:{post:vi.fn(),get:vi.fn()}}))
vi.mock('./mermaidService',()=>({renderMermaid:vi.fn()})) vi.mock('./mermaidService',()=>({renderMermaid:vi.fn()}))
vi.mock('./pdfSnapshotService',()=>({preparePdfSnapshot:vi.fn().mockResolvedValue('<html>theme snapshot</html>')}))
import {preparePdfSnapshot} from './pdfSnapshotService'
import {renderMermaid} from './mermaidService' import {renderMermaid} from './mermaidService'
import {apiClient} from './apiClient' import {apiClient} from './apiClient'
import {exportService,captureExportPalette} from './exportService' import {exportService,captureExportPalette} from './exportService'
@@ -63,20 +65,13 @@ it.each(['mermaid','Mermaid','mermaid title="Flow"'])('prepares a static asset f
expect(apiClient.post).toHaveBeenCalledWith('/api/exports',expect.objectContaining({assets:[expect.objectContaining({kind:'mermaid',png_base64:'YWJj',source_hash:expect.stringMatching(/^[a-f0-9]{64}$/)})]})) expect(apiClient.post).toHaveBeenCalledWith('/api/exports',expect.objectContaining({assets:[expect.objectContaining({kind:'mermaid',png_base64:'YWJj',source_hash:expect.stringMatching(/^[a-f0-9]{64}$/)})]}))
}) })
it('PDF prepares more than 16 Mermaid assets with the frozen palette',async()=>{ it('PDF submits the shared browser snapshot instead of raster assets',async()=>{
vi.stubGlobal('crypto',webcrypto)
vi.stubGlobal('Image',class {src='';decode(){return Promise.resolve()}})
vi.spyOn(HTMLCanvasElement.prototype,'getContext').mockReturnValue({fillStyle:'',fillRect:vi.fn(),drawImage:vi.fn()} as never)
vi.spyOn(HTMLCanvasElement.prototype,'toDataURL').mockReturnValue('data:image/png;base64,YWJj')
vi.mocked(renderMermaid).mockResolvedValue({svg:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 10"></svg>',warnings:[]} as never)
vi.mocked(apiClient.post).mockResolvedValue(queued) vi.mocked(apiClient.post).mockResolvedValue(queued)
const palette={page:'#010409',surface:'#161b22',text:'#e6edf3',muted:'#b1bac4',code:'#21262d',border:'#57606a',accent:'#79c0ff'}
const markdown=Array.from({length:17},(_,i)=>'```mermaid\nflowchart LR\n A'+i+'-->B\n```').join('\n\n') const markdown=Array.from({length:17},(_,i)=>'```mermaid\nflowchart LR\n A'+i+'-->B\n```').join('\n\n')
await exportService.create(markdown,'many','pdf',{...reviewOptions,theme_id:'dark',palette}) await exportService.create(markdown,'many','pdf',{...reviewOptions,theme_id:'dark'})
expect(renderMermaid).toHaveBeenCalledTimes(17) expect(preparePdfSnapshot).toHaveBeenCalledWith(markdown,'many',expect.objectContaining({theme_id:'dark'}),undefined,undefined)
expect(renderMermaid).toHaveBeenCalledWith(expect.any(String),{mode:'raster',theme:'dark',unlimited:true,palette}) expect(renderMermaid).not.toHaveBeenCalled()
expect(apiClient.post).toHaveBeenCalledWith('/api/exports',expect.objectContaining({assets:expect.arrayContaining(Array.from({length:17},()=>expect.anything())),options:expect.objectContaining({palette})})) expect(apiClient.post).toHaveBeenCalledWith('/api/exports',expect.objectContaining({assets:[],print_html:'<html>theme snapshot</html>'}))
await expect(exportService.create(markdown,'many','html',reviewOptions)).rejects.toThrow('最多 16')
}) })
it('captures custom theme CSS as a portable palette',()=>{ it('captures custom theme CSS as a portable palette',()=>{
const values={'background-primary':'#010409','surface-primary':'rgb(22, 27, 34)','text-primary':'#e6edf3','text-secondary':'#b1bac4','background-secondary':'#21262d','border-default':'#57606a','accent-primary':'#79c0ff'} const values={'background-primary':'#010409','surface-primary':'rgb(22, 27, 34)','text-primary':'#e6edf3','text-secondary':'#b1bac4','background-secondary':'#21262d','border-default':'#57606a','accent-primary':'#79c0ff'}
+7 -2
View File
@@ -68,9 +68,14 @@ export async function hashSource(source: string) {
} }
export const exportService = { export const exportService = {
async create(markdown: string, title: string, format: ExportFormat, options: { theme_id: string; include_title: boolean; page_size: string; palette?: ExportPalette }, signal?: AbortSignal, filePath?: string) { async create(markdown: string, title: string, format: ExportFormat, options: { theme_id: string; include_title: boolean; page_size: string; palette?: ExportPalette }, signal?: AbortSignal, filePath?: string) {
let printHtml: string | undefined
if (format === 'pdf') {
const { preparePdfSnapshot } = await import('./pdfSnapshotService')
printHtml = await preparePdfSnapshot(markdown,title,options,signal,filePath)
}
const blocks: string[] = [] const blocks: string[] = []
const parser = new Marked() const parser = new Marked()
parser.walkTokens(parser.lexer(markdown), token => { if (token.type === 'code' && token.lang?.trim().split(/\s+/)[0]?.toLowerCase() === 'mermaid') blocks.push(token.text) }) if (format !== 'pdf') parser.walkTokens(parser.lexer(markdown), token => { if (token.type === 'code' && token.lang?.trim().split(/\s+/)[0]?.toLowerCase() === 'mermaid') blocks.push(token.text) })
const assets = [] const assets = []
for (const source of [...new Set(blocks)]) { for (const source of [...new Set(blocks)]) {
signal?.throwIfAborted() signal?.throwIfAborted()
@@ -83,7 +88,7 @@ export const exportService = {
signal?.throwIfAborted() signal?.throwIfAborted()
// Keep the response handle when cancellation arrives during submission: // Keep the response handle when cancellation arrives during submission:
// aborting HTTP alone could leave an undiscoverable running server job. // aborting HTTP alone could leave an undiscoverable running server job.
const job = mapJob(await apiClient.post<JobWire>('/api/exports', { source: { type: 'markdown', markdown, file_path: filePath }, title, format, options, assets })) const job = mapJob(await apiClient.post<JobWire>('/api/exports', { source: { type: 'markdown', markdown, file_path: filePath }, title, format, options, assets, ...(printHtml ? { print_html:printHtml } : {}) }))
if (signal?.aborted) { if (signal?.aborted) {
await apiClient.post(`/api/exports/${encodeURIComponent(job.id)}/cancel`) await apiClient.post(`/api/exports/${encodeURIComponent(job.id)}/cancel`)
const current = await apiClient.get<JobWire>(`/api/exports/${encodeURIComponent(job.id)}`) const current = await apiClient.get<JobWire>(`/api/exports/${encodeURIComponent(job.id)}`)
+6 -6
View File
@@ -29,13 +29,13 @@ export function mermaidThemeVariables(dark: boolean, useDocument = true) {
} }
} }
async function ensureInitialized(theme: 'light' | 'dark', raster = false, palette?: Record<string,string>, unlimited = false) { async function ensureInitialized(theme: 'light' | 'dark', raster = false, palette?: Record<string,string>, unlimited = false, frozenVariables?: ReturnType<typeof mermaidThemeVariables>) {
const mermaid = await loadMermaid() const mermaid = await loadMermaid()
const dark = palette ? [1,3,5].reduce((sum,index,i) => sum + parseInt(palette.surface!.slice(index,index+2),16) * [0.2126,0.7152,0.0722][i]!,0) < 128 : theme === 'dark' const dark = palette ? [1,3,5].reduce((sum,index,i) => sum + parseInt(palette.surface!.slice(index,index+2),16) * [0.2126,0.7152,0.0722][i]!,0) < 128 : theme === 'dark'
mermaid.initialize({ mermaid.initialize({
startOnLoad: false, startOnLoad: false,
theme: 'base', theme: 'base',
themeVariables: palette ? { themeVariables: frozenVariables ?? (palette ? {
...mermaidThemeVariables(dark, false), background: palette.surface, ...mermaidThemeVariables(dark, false), background: palette.surface,
primaryColor: palette.code, primaryTextColor: palette.text, primaryBorderColor: palette.border, primaryColor: palette.code, primaryTextColor: palette.text, primaryBorderColor: palette.border,
secondaryColor: palette.code, secondaryTextColor: palette.text, secondaryBorderColor: palette.border, secondaryColor: palette.code, secondaryTextColor: palette.text, secondaryBorderColor: palette.border,
@@ -46,7 +46,7 @@ async function ensureInitialized(theme: 'light' | 'dark', raster = false, palett
signalColor: palette.muted, signalTextColor: palette.text, labelBoxBkgColor: palette.surface, signalColor: palette.muted, signalTextColor: palette.text, labelBoxBkgColor: palette.surface,
labelBoxBorderColor: palette.border, labelTextColor: palette.text, noteBkgColor: palette.code, labelBoxBorderColor: palette.border, labelTextColor: palette.text, noteBkgColor: palette.code,
noteTextColor: palette.text, noteBorderColor: palette.border, activationBkgColor: palette.code, activationBorderColor: palette.border, noteTextColor: palette.text, noteBorderColor: palette.border, activationBkgColor: palette.code, activationBorderColor: palette.border,
} : mermaidThemeVariables(theme === 'dark', !raster), } : mermaidThemeVariables(theme === 'dark', !raster)),
...(unlimited ? { maxTextSize: Number.MAX_SAFE_INTEGER, maxEdges: Number.MAX_SAFE_INTEGER } : {}), ...(unlimited ? { maxTextSize: Number.MAX_SAFE_INTEGER, maxEdges: Number.MAX_SAFE_INTEGER } : {}),
securityLevel: 'strict', securityLevel: 'strict',
fontFamily: raster ? 'Arial, Microsoft YaHei, sans-serif' : 'var(--font-ui-sans)', fontFamily: raster ? 'Arial, Microsoft YaHei, sans-serif' : 'var(--font-ui-sans)',
@@ -78,18 +78,18 @@ export interface MermaidParseError {
let renderCounter = 0 let renderCounter = 0
export function renderMermaid(source: string, options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' | 'raster'; palette?: Record<string,string>; unlimited?: boolean } = {}): Promise<MermaidRenderResult> { export function renderMermaid(source: string, options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' | 'raster'; palette?: Record<string,string>; unlimited?: boolean; themeVariables?: ReturnType<typeof mermaidThemeVariables> } = {}): Promise<MermaidRenderResult> {
return serialized(() => renderMermaidNow(source, options)) return serialized(() => renderMermaidNow(source, options))
} }
async function renderMermaidNow( async function renderMermaidNow(
source: string, source: string,
options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' | 'raster'; palette?: Record<string,string>; unlimited?: boolean } = {} options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' | 'raster'; palette?: Record<string,string>; unlimited?: boolean; themeVariables?: ReturnType<typeof mermaidThemeVariables> } = {}
): Promise<MermaidRenderResult> { ): Promise<MermaidRenderResult> {
const theme = options.theme ?? 'light' const theme = options.theme ?? 'light'
const id = `mermaid-${Date.now()}-${++renderCounter}` const id = `mermaid-${Date.now()}-${++renderCounter}`
try { try {
const mermaid = await ensureInitialized(theme, options.mode === 'raster', options.palette, options.unlimited) const mermaid = await ensureInitialized(theme, options.mode === 'raster', options.palette, options.unlimited, options.themeVariables)
const result = await mermaid.render(id, source) const result = await mermaid.render(id, source)
const parser = new DOMParser() const parser = new DOMParser()
const doc = parser.parseFromString(result.svg, 'image/svg+xml') const doc = parser.parseFromString(result.svg, 'image/svg+xml')
@@ -0,0 +1,40 @@
// @vitest-environment jsdom
import {it,expect,vi,afterEach} from 'vitest'
vi.mock('@/utils/markdown',()=>({renderMarkdown:vi.fn().mockResolvedValue('<h1>Heading</h1><details><summary>Tip</summary><p>Body</p></details><div class="markdown-code-toolbar"><button>Copy</button><span>python</span></div>')}))
vi.mock('./apiClient',()=>({apiClient:{post:vi.fn().mockResolvedValue({images:[],plots:[]})}}))
vi.mock('./mermaidService',()=>({mermaidThemeVariables:()=>({primaryColor:'#fff'})}))
vi.mock('@/stores/theme',()=>({useThemeStore:()=>({isDark:false})}))
vi.mock('@/stores/markdownPreferences',()=>({useMarkdownPreferencesStore:()=>({normalized:{wrapCode:true,lineNumbers:true,indent:4}})}))
vi.mock('@/stores/headingAppearance',()=>({useHeadingAppearanceStore:()=>({cssVariables:{'--heading-1-size':'37px'},preferences:{custom:true}})}))
vi.mock('@/components/common/MarkdownContent.vue',()=>({default:{}}))
vi.mock('@/features/editor/VisualMarkdownEditor.vue',()=>({default:{__scopeId:'data-v-editor'}}))
import {preparePdfSnapshot} from './pdfSnapshotService'
import {apiClient} from './apiClient'
import {renderMarkdown} from '@/utils/markdown'
afterEach(()=>vi.clearAllMocks())
it('preserves actual theme CSS, pseudo elements, root attributes and heading preferences',async()=>{
const style=document.createElement('style');style.textContent='[data-theme="paper"] .ProseMirror::before { content:"tape"; transform:rotate(-3deg) }';document.head.append(style)
document.documentElement.dataset.theme='paper'
try {
const html=await preparePdfSnapshot('# Heading','<Title>',{theme_id:'paper',include_title:true,page_size:'A4'})
expect(html).toContain('transform: rotate(-3deg)')
expect(html).toContain('data-theme="paper"')
expect(html).toContain('data-v-editor')
expect(html).toContain('data-heading-style="custom"')
expect(html).toContain('--heading-1-size:37px')
expect(html).toContain('&lt;Title&gt;')
expect(html).toContain('<details open="">')
expect(html).not.toContain('<button>Copy')
expect(html).toContain('<span>python</span>')
expect(renderMarkdown).toHaveBeenCalledWith('# Heading',expect.objectContaining({pdf:expect.anything()}))
} finally {style.remove();delete document.documentElement.dataset.theme}
})
it('rejects a missing image instead of silently producing an incomplete PDF',async()=>{
vi.mocked(renderMarkdown).mockResolvedValueOnce('<img src="missing.png">')
await expect(preparePdfSnapshot('![image](missing.png)','note',{theme_id:'light',include_title:false,page_size:'A4'})).rejects.toThrow('PDF 图片无法读取')
})
it('an aborted snapshot never requests backend resources',async()=>{
const controller=new AbortController();controller.abort()
await expect(preparePdfSnapshot('text','note',{theme_id:'light',include_title:false,page_size:'A4'},controller.signal)).rejects.toMatchObject({name:'AbortError'})
expect(apiClient.post).not.toHaveBeenCalled()
})
+106
View File
@@ -0,0 +1,106 @@
import { apiClient } from './apiClient'
import { renderMarkdown } from '@/utils/markdown'
import { mermaidThemeVariables } from './mermaidService'
import { useThemeStore } from '@/stores/theme'
import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
import { useHeadingAppearanceStore } from '@/stores/headingAppearance'
// Load the same CSS, including Vue's scoped editor rules, without mounting an editor.
import MarkdownContent from '@/components/common/MarkdownContent.vue'
import VisualMarkdownEditor from '@/features/editor/VisualMarkdownEditor.vue'
void MarkdownContent; void VisualMarkdownEditor
interface Resources { images: {source:string; data:string|null; warnings:string[]}[]; plots: {source:string; svg:string; warnings:string[]}[] }
interface Options { theme_id:string; include_title:boolean; page_size:string }
const printRules = `
@page { margin: 0; }
html, body { margin:0 !important; padding:0 !important; width:auto !important; height:auto !important; min-height:0 !important; overflow:visible !important; display:block !important; }
* { -webkit-print-color-adjust:exact !important; print-color-adjust:exact !important; animation:none !important; transition:none !important; }
.pdf-document, .pdf-document .milkdown-host, .pdf-document .milkdown { display:block !important; height:auto !important; min-height:0 !important; overflow:visible !important; }
.pdf-document .ProseMirror { min-height:0 !important; overflow:visible !important; box-decoration-break:clone; -webkit-box-decoration-break:clone; }
.pdf-document :is(h1,h2,h3,h4,h5,h6) { break-after:avoid; }
.pdf-document img { max-width:100%; }
.pdf-document .markdown-mermaid > svg { width:100% !important; min-width:0 !important; max-width:100% !important; height:auto !important; max-height:250mm; }
.pdf-document :is(.markdown-mermaid,.markdown-math,table) { break-inside:avoid; }
.pdf-document :is(pre,.shiki) { overflow:visible !important; white-space:pre-wrap; overflow-wrap:anywhere; }
.pdf-document .markdown-code-toolbar button, .pdf-document .diagram-controls { display:none !important; }
`
function attrs(element: Element): string {
return [...element.attributes].filter(a => a.name==='class' || a.name==='style' || a.name.startsWith('data-')).map(a=>` ${a.name}="${escape(a.value)}"`).join('')
}
function escape(text: string) { return text.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;') }
function scopeAttributes(component: unknown) { const id=(component as {__scopeId?:string}).__scopeId; return id ? ` ${id}` : '' }
async function dataUrl(url: string, signal?:AbortSignal):Promise<string> {
const response=await fetch(url,{signal}); if(!response.ok) throw Error(`PDF 资源读取失败:${url}`)
const blob=await response.blob()
return await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(String(reader.result));reader.onerror=reject;reader.readAsDataURL(blob)})
}
async function embedCss(css: string, base: string, signal?:AbortSignal) {
const matches=[...css.matchAll(/url\(\s*(['"]?)(.*?)\1\s*\)/g)]
for(const match of matches) {
const url=match[2]!
if(url.startsWith('data:')||url.startsWith('#'))continue
const absolute=new URL(url,base)
if(absolute.origin!==location.origin)throw Error(`PDF 主题资源必须来自应用:${absolute.href}`)
css=css.replace(match[0],`url("${await dataUrl(absolute.href,signal)}")`)
}
return css
}
function stylesheetSnapshot(): {css:string;base:string}[] {
const sheets: {css:string;base:string}[]=[]
function visit(sheet:CSSStyleSheet) {
for(const rule of [...sheet.cssRules]) {
if(rule instanceof CSSImportRule && rule.styleSheet)visit(rule.styleSheet)
else sheets.push({css:rule.cssText,base:sheet.href || document.baseURI})
}
}
for(const sheet of [...document.styleSheets])visit(sheet)
return sheets
}
export async function preparePdfSnapshot(markdown:string,title:string,options:Options,signal?:AbortSignal,filePath?:string):Promise<string> {
signal?.throwIfAborted()
const theme=useThemeStore(), preferences={...useMarkdownPreferencesStore().normalized}, heading=useHeadingAppearanceStore()
if(theme.currentThemeId && theme.currentThemeId!==options.theme_id)throw Error('主题在导出准备期间发生变化,请重新导出。')
const htmlAttrs=attrs(document.documentElement), bodyAttrs=attrs(document.body)
const variables=getComputedStyle(document.documentElement)
const rootVariables=[...variables].filter(name=>name.startsWith('--')).map(name=>`${name}:${variables.getPropertyValue(name)};`).join('')
const styles=stylesheetSnapshot()
const diagramVariables=mermaidThemeVariables(theme.isDark)
const headingStyle=Object.entries(heading.cssVariables).map(([key,value])=>`${key}:${value}`).join(';')
const customHeading=heading.preferences.custom
const dark=theme.isDark
const resources=await apiClient.post<Resources>('/api/exports/preview-resources',{format:'pdf',source:{type:'markdown',markdown,file_path:filePath},options})
signal?.throwIfAborted()
const rendered=await renderMarkdown(markdown,{themeId:options.theme_id,theme:dark?'dark':'light',preferences,pdf:{mermaidVariables:diagramVariables,plot:async source=>{
const plot=resources.plots.find(p=>p.source.trim()===source.trim()); if(!plot?.svg)throw Error(plot?.warnings.join('; ')||'函数图像无法导出');return plot
}}})
const fragment=new DOMParser().parseFromString(rendered,'text/html')
for(const image of fragment.querySelectorAll('img')) {
const source=image.getAttribute('src')||''
if(source.startsWith('data:'))continue
const resource=resources.images.find(item=>item.source===source)
if(!resource?.data)throw Error(resource?.warnings.join('; ')||`PDF 图片无法读取:${source}`)
image.src=resource.data
}
// Print all callout content and remove only interactive tools, not decoration.
fragment.querySelectorAll('details').forEach(d=>d.open=true)
// The workspace uses blockquotes for callouts. Preserve that DOM contract so
// editor-specific theme selectors apply, including spacing and decoration.
fragment.querySelectorAll('.markdown-callout:not(blockquote)').forEach(details=>{
const block=fragment.createElement('blockquote')
for(const attribute of [...details.attributes])if(attribute.name!=='open')block.setAttribute(attribute.name,attribute.value)
block.innerHTML=details.innerHTML
const summary=block.querySelector('summary')
if(summary){const title=fragment.createElement('div');title.className=summary.className;title.innerHTML=summary.innerHTML;summary.replaceWith(title)}
details.replaceWith(block)
})
const error=fragment.querySelector('.mermaid-error')
if(error)throw Error(error.textContent||'PDF 图表渲染失败')
fragment.querySelectorAll('.markdown-code-toolbar button,.diagram-controls').forEach(e=>e.remove())
const css=(await Promise.all(styles.map(s=>embedCss(s.css,s.base,signal)))).join('\n')
signal?.throwIfAborted()
const scope=scopeAttributes(VisualMarkdownEditor)
return `<!doctype html><html${htmlAttrs}><head><meta charset="utf-8"><title>${escape(title)}</title><style>${css.replace(/<\/style/gi,'<\\/style')}\n:root{${rootVariables}}\n${printRules}</style></head><body${bodyAttrs}><div class="visual-editor pdf-document"${scope} ${customHeading?'data-heading-style="custom"':''} style="${escape(headingStyle)}"><div class="milkdown-host"${scope}><div class="milkdown"><article class="ProseMirror markdown-content" data-code-wrap="${preferences.wrapCode}" data-line-numbers="${preferences.lineNumbers}" style="--markdown-code-indent:${preferences.indent}">${options.include_title?`<h1>${escape(title)}</h1>`:''}${fragment.body.innerHTML}</article></div></div></div></body></html>`
}
+8 -7
View File
@@ -7,7 +7,7 @@ import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
import { bundledLanguagesInfo } from 'shiki/langs' import { bundledLanguagesInfo } from 'shiki/langs'
import githubDark from '@shikijs/themes/github-dark' import githubDark from '@shikijs/themes/github-dark'
import githubLight from '@shikijs/themes/github-light' import githubLight from '@shikijs/themes/github-light'
import { renderMermaid } from '@/services/mermaidService' import { renderMermaid, type mermaidThemeVariables } from '@/services/mermaidService'
import { appendDiagramControls } from './diagramControls' import { appendDiagramControls } from './diagramControls'
import katex from 'katex' import katex from 'katex'
import 'katex/dist/katex.min.css' import 'katex/dist/katex.min.css'
@@ -126,7 +126,7 @@ export async function getCodeTokenizer(theme: 'github-light' | 'github-dark', re
} }
} }
export async function renderMarkdown(source: string, options?: { themeId?: string; theme?: 'light' | 'dark'; preferences?: MarkdownPreferences; citationNumbers?: number[]; citationAliases?: Record<string, number> }): Promise<string> { export async function renderMarkdown(source: string, options?: { themeId?: string; theme?: 'light' | 'dark'; preferences?: MarkdownPreferences; pdf?: { plot: (source: string) => Promise<{svg: string; warnings: string[]}>; mermaidVariables: ReturnType<typeof mermaidThemeVariables> }; citationNumbers?: number[]; citationAliases?: Record<string, number> }): Promise<string> {
const preferences = options?.preferences ?? defaultMarkdownPreferences const preferences = options?.preferences ?? defaultMarkdownPreferences
const marked = createMarkdownParser(preferences) const marked = createMarkdownParser(preferences)
const citations = new Set(options?.citationNumbers ?? []) const citations = new Set(options?.citationNumbers ?? [])
@@ -146,8 +146,9 @@ export async function renderMarkdown(source: string, options?: { themeId?: strin
for (const code of documentNode.querySelectorAll('pre > code')) { for (const code of documentNode.querySelectorAll('pre > code')) {
const requestedLanguage = [...code.classList].find((name) => name.startsWith('language-'))?.slice(9) || 'text' const requestedLanguage = [...code.classList].find((name) => name.startsWith('language-'))?.slice(9) || 'text'
if (['mermaid', 'function-plot'].includes(requestedLanguage) && preferences.diagrams) { const diagramKind = requestedLanguage.toLowerCase().split(/\s+/)[0]!.replace('function_plot','function-plot')
mermaidBlocks.push({ pre: code.parentElement!, source: code.textContent ?? '', kind: requestedLanguage }) if (['mermaid', 'function-plot'].includes(diagramKind) && preferences.diagrams) {
mermaidBlocks.push({ pre: code.parentElement!, source: code.textContent ?? '', kind: diagramKind })
continue continue
} }
if (requestedLanguage.toLowerCase() === 'latex' && preferences.math) { if (requestedLanguage.toLowerCase() === 'latex' && preferences.math) {
@@ -172,9 +173,9 @@ export async function renderMarkdown(source: string, options?: { themeId?: strin
let plotCount = 0, plotNodes = 0 let plotCount = 0, plotNodes = 0
for (const { pre, source, kind } of mermaidBlocks) { for (const { pre, source, kind } of mermaidBlocks) {
try { try {
if (kind === 'function-plot' && ++plotCount > 16) throw new Error('函数图像数量超过 16') if (!options?.pdf && kind === 'function-plot' && ++plotCount > 16) throw new Error('函数图像数量超过 16')
const result = kind === 'function-plot' ? await renderFunctionPlot(source, options?.themeId) : await renderMermaid(source, { theme: options?.theme, mode: 'static' }) const result = kind === 'function-plot' ? (options?.pdf ? await options.pdf.plot(source) : await renderFunctionPlot(source, options?.themeId)) : await renderMermaid(source, { theme: options?.theme, mode: 'static', ...(options?.pdf ? { unlimited:true, themeVariables:options.pdf.mermaidVariables } : {}) })
if ('nodeCount' in result && (plotNodes += result.nodeCount) > 8000) throw new Error('函数图像累计复杂度超过 8000') if (!options?.pdf && 'nodeCount' in result && (plotNodes += Number(result.nodeCount)) > 8000) throw new Error('函数图像累计复杂度超过 8000')
const container = document.createElement('div') const container = document.createElement('div')
container.className = 'markdown-mermaid' + (kind === 'function-plot' ? ' markdown-function-plot' : '') container.className = 'markdown-mermaid' + (kind === 'function-plot' ? ' markdown-function-plot' : '')
container.innerHTML = result.svg container.innerHTML = result.svg