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 32a45c4841
commit 66e877672b
16 changed files with 451 additions and 43 deletions
+3
View File
@@ -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")
+2 -2
View File
@@ -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
+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
_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)
+2 -2
View File
@@ -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] = [
+5
View File
@@ -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,
+1
View File
@@ -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]
+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" },
]
[[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]]
name = "h11"
version = "0.16.0"
@@ -1020,6 +1097,7 @@ dependencies = [
{ name = "matplotlib" },
{ name = "mistune" },
{ name = "olefile" },
{ name = "playwright" },
{ name = "python-docx" },
{ name = "pyyaml" },
{ name = "referencing" },
@@ -1042,6 +1120,7 @@ requires-dist = [
{ name = "matplotlib", specifier = ">=3.9,<4" },
{ name = "mistune", specifier = ">=3.0,<4.0" },
{ name = "olefile", specifier = ">=0.47" },
{ name = "playwright", specifier = ">=1.55,<2" },
{ name = "python-docx", specifier = ">=1.1,<2.0" },
{ name = "pyyaml", specifier = ">=6.0,<7.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" },
]
[[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]]
name = "pluggy"
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" },
]
[[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]]
name = "pygments"
version = "2.21.0"
@@ -37,7 +37,7 @@ onBeforeUnmount(() => { disposed = true; clearTimeout(timer) })
<label>纸张 <select v-model="page"><option>A4</option><option>Letter</option></select></label>
<label><input v-model="title" type="checkbox">包含标题</label>
<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 v-if="preparing" @click="controller?.abort()">取消准备</button>
<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'
vi.mock('./apiClient',()=>({apiClient:{post:vi.fn(),get: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 {apiClient} from './apiClient'
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}$/)})]}))
})
it('PDF prepares more than 16 Mermaid assets with the frozen palette',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)
it('PDF submits the shared browser snapshot instead of raster assets',async()=>{
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')
await exportService.create(markdown,'many','pdf',{...reviewOptions,theme_id:'dark',palette})
expect(renderMermaid).toHaveBeenCalledTimes(17)
expect(renderMermaid).toHaveBeenCalledWith(expect.any(String),{mode:'raster',theme:'dark',unlimited:true,palette})
expect(apiClient.post).toHaveBeenCalledWith('/api/exports',expect.objectContaining({assets:expect.arrayContaining(Array.from({length:17},()=>expect.anything())),options:expect.objectContaining({palette})}))
await expect(exportService.create(markdown,'many','html',reviewOptions)).rejects.toThrow('最多 16')
await exportService.create(markdown,'many','pdf',{...reviewOptions,theme_id:'dark'})
expect(preparePdfSnapshot).toHaveBeenCalledWith(markdown,'many',expect.objectContaining({theme_id:'dark'}),undefined,undefined)
expect(renderMermaid).not.toHaveBeenCalled()
expect(apiClient.post).toHaveBeenCalledWith('/api/exports',expect.objectContaining({assets:[],print_html:'<html>theme snapshot</html>'}))
})
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'}
+7 -2
View File
@@ -68,9 +68,14 @@ export async function hashSource(source: string) {
}
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) {
let printHtml: string | undefined
if (format === 'pdf') {
const { preparePdfSnapshot } = await import('./pdfSnapshotService')
printHtml = await preparePdfSnapshot(markdown,title,options,signal,filePath)
}
const blocks: string[] = []
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 = []
for (const source of [...new Set(blocks)]) {
signal?.throwIfAborted()
@@ -83,7 +88,7 @@ export const exportService = {
signal?.throwIfAborted()
// Keep the response handle when cancellation arrives during submission:
// 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) {
await apiClient.post(`/api/exports/${encodeURIComponent(job.id)}/cancel`)
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 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({
startOnLoad: false,
theme: 'base',
themeVariables: palette ? {
themeVariables: frozenVariables ?? (palette ? {
...mermaidThemeVariables(dark, false), background: palette.surface,
primaryColor: palette.code, primaryTextColor: palette.text, primaryBorderColor: 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,
labelBoxBorderColor: palette.border, labelTextColor: palette.text, noteBkgColor: palette.code,
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 } : {}),
securityLevel: 'strict',
fontFamily: raster ? 'Arial, Microsoft YaHei, sans-serif' : 'var(--font-ui-sans)',
@@ -78,18 +78,18 @@ export interface MermaidParseError {
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))
}
async function renderMermaidNow(
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> {
const theme = options.theme ?? 'light'
const id = `mermaid-${Date.now()}-${++renderCounter}`
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 parser = new DOMParser()
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 githubDark from '@shikijs/themes/github-dark'
import githubLight from '@shikijs/themes/github-light'
import { renderMermaid } from '@/services/mermaidService'
import { renderMermaid, type mermaidThemeVariables } from '@/services/mermaidService'
import { appendDiagramControls } from './diagramControls'
import katex from 'katex'
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 marked = createMarkdownParser(preferences)
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')) {
const requestedLanguage = [...code.classList].find((name) => name.startsWith('language-'))?.slice(9) || 'text'
if (['mermaid', 'function-plot'].includes(requestedLanguage) && preferences.diagrams) {
mermaidBlocks.push({ pre: code.parentElement!, source: code.textContent ?? '', kind: requestedLanguage })
const diagramKind = requestedLanguage.toLowerCase().split(/\s+/)[0]!.replace('function_plot','function-plot')
if (['mermaid', 'function-plot'].includes(diagramKind) && preferences.diagrams) {
mermaidBlocks.push({ pre: code.parentElement!, source: code.textContent ?? '', kind: diagramKind })
continue
}
if (requestedLanguage.toLowerCase() === 'latex' && preferences.math) {
@@ -172,9 +173,9 @@ export async function renderMarkdown(source: string, options?: { themeId?: strin
let plotCount = 0, plotNodes = 0
for (const { pre, source, kind } of mermaidBlocks) {
try {
if (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' })
if ('nodeCount' in result && (plotNodes += result.nodeCount) > 8000) throw new Error('函数图像累计复杂度超过 8000')
if (!options?.pdf && kind === 'function-plot' && ++plotCount > 16) throw new Error('函数图像数量超过 16')
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 (!options?.pdf && 'nodeCount' in result && (plotNodes += Number(result.nodeCount)) > 8000) throw new Error('函数图像累计复杂度超过 8000')
const container = document.createElement('div')
container.className = 'markdown-mermaid' + (kind === 'function-plot' ? ' markdown-function-plot' : '')
container.innerHTML = result.svg