From d1cbc10fc4fded9f35e58f2fad3a3b4bc44d35c2 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Mon, 7 Sep 2026 14:33:19 +0800 Subject: [PATCH] fix: print PDF from actual editor theme CSS and shared Markdown rendering --- backend/app/contracts.py | 3 + backend/app/export/assets.py | 4 +- backend/app/export/browser_pdf.py | 64 ++++++++++ backend/app/export/service.py | 53 +++++++-- backend/app/plot/render.py | 4 +- backend/app/routes.py | 5 + backend/pyproject.toml | 1 + backend/tests/test_pdf_browser.py | 47 ++++++++ backend/uv.lock | 110 ++++++++++++++++++ docs/contracts/第二阶段接口契约-开发版.md | 7 ++ docs/development/Export开发说明.md | 11 ++ .../PDF实际主题样式修复验收-2026-09-07.md | 11 ++ .../pdf-browser-20260907/results.json | 50 ++++++++ frontend/src/features/editor/ExportDialog.vue | 2 +- frontend/src/services/exportService.spec.ts | 19 ++- frontend/src/services/exportService.ts | 9 +- frontend/src/services/mermaidService.ts | 12 +- .../src/services/pdfSnapshotService.spec.ts | 40 +++++++ frontend/src/services/pdfSnapshotService.ts | 106 +++++++++++++++++ frontend/src/utils/markdown.ts | 15 +-- 20 files changed, 530 insertions(+), 43 deletions(-) create mode 100644 backend/app/export/browser_pdf.py create mode 100644 backend/tests/test_pdf_browser.py create mode 100644 docs/development/PDF实际主题样式修复验收-2026-09-07.md create mode 100644 docs/development/evidence/pdf-browser-20260907/results.json create mode 100644 frontend/src/services/pdfSnapshotService.spec.ts create mode 100644 frontend/src/services/pdfSnapshotService.ts diff --git a/backend/app/contracts.py b/backend/app/contracts.py index 07e9cc4..3d55b24 100644 --- a/backend/app/contracts.py +++ b/backend/app/contracts.py @@ -1452,6 +1452,7 @@ class ExportAsset(Contract): class ExportRequest(Contract): + print_html: str | None = None assets: list[ExportAsset] = Field(default_factory=list) title: str = Field(default="", max_length=200) source: ExportSource @@ -1460,6 +1461,8 @@ class ExportRequest(Contract): @model_validator(mode="after") def _asset_limits(self) -> "ExportRequest": + if self.print_html is not None and self.format != ExportFormat.pdf: + raise ValueError("print_html is only supported for PDF") if self.format != ExportFormat.pdf: if len(self.assets) > 64 or any(len(asset.png_base64) > 2800000 for asset in self.assets): raise ValueError("export asset count or size limit exceeded") diff --git a/backend/app/export/assets.py b/backend/app/export/assets.py index 9aca754..8791398 100644 --- a/backend/app/export/assets.py +++ b/backend/app/export/assets.py @@ -8,7 +8,7 @@ from app.errors import ApiError _math_lock = threading.Lock() -def enrich_document(document, file_path=None, unlimited=False, options=None): +def enrich_document(document, file_path=None, unlimited=False, options=None, preserve_alpha=False): """Embed Vault images and MathText, with format-specific quotas and palette.""" from app.config import get_settings from urllib.parse import unquote, urlsplit @@ -53,7 +53,7 @@ def enrich_document(document, file_path=None, unlimited=False, options=None): out = BytesIO() # Composite transparency over the PDF theme or the print/Word white surface. rgba=image.convert('RGBA'); background=Image.new('RGBA',rgba.size,palette['surface'] if palette else 'white') - background.alpha_composite(rgba); background.convert('RGB').save(out,'PNG') + background.alpha_composite(rgba); (rgba if preserve_alpha else background.convert('RGB')).save(out,'PNG') png=out.getvalue();total += len(png) if not unlimited and total > 8_000_000: raise ValueError('resource bytes') node.attributes['static_png']=png diff --git a/backend/app/export/browser_pdf.py b/backend/app/export/browser_pdf.py new file mode 100644 index 0000000..36022a0 --- /dev/null +++ b/backend/app/export/browser_pdf.py @@ -0,0 +1,64 @@ +"""Print the app's self-contained theme snapshot with a real browser engine. + +A child process isolates Playwright's Windows event loop from Uvicorn and keeps +browser lifecycle scoped to one export. Snapshot scripts/network/file loads are +blocked; fonts and images must already be embedded by the client. +""" +from pathlib import Path +import os +import shutil +import subprocess +import sys +import tempfile +from app.export.document import ExportResult + + +def browser_executable(): + configured = os.environ.get('APP_PDF_BROWSER') + if configured: + return configured + for root in (os.environ.get('PROGRAMFILES(X86)', ''), os.environ.get('PROGRAMFILES', ''), os.environ.get('LOCALAPPDATA', '')): + if not root: + continue + for suffix in ('Microsoft/Edge/Application/msedge.exe', 'Google/Chrome/Application/chrome.exe'): + candidate = Path(root) / suffix + if candidate.is_file(): + return str(candidate) + return next((p for name in ('chromium','chromium-browser','google-chrome','microsoft-edge') if (p := shutil.which(name))), None) + + +def render_snapshot(snapshot: str, page_size: str) -> ExportResult: + with tempfile.TemporaryDirectory(prefix='notes-pdf-') as directory: + source = Path(directory) / 'snapshot.html' + output = Path(directory) / 'document.pdf' + source.write_text(snapshot, encoding='utf-8') + process = subprocess.run([sys.executable, '-m', 'app.export.browser_pdf', str(source), str(output), page_size], + capture_output=True, text=True, encoding='utf-8', errors='replace', + creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0), + cwd=Path(__file__).resolve().parents[2]) + if process.returncode: + raise RuntimeError('PDF browser rendering failed: ' + process.stderr[-2000:]) + return ExportResult(content=output.read_bytes(), mime_type='application/pdf', warnings=[]) + + +def print_snapshot(source: Path, output: Path, page_size: str): + from playwright.sync_api import sync_playwright + with sync_playwright() as runtime: + browser = runtime.chromium.launch(executable_path=browser_executable(), headless=True) + try: + context = browser.new_context(java_script_enabled=False, offline=True) + context.route('**/*', lambda route: route.abort()) + page = context.new_page() + page.set_default_timeout(0) + page.emulate_media(media='screen') + csp = "default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'" + page.set_content(''+source.read_text(encoding='utf-8'), wait_until='load', timeout=0) + page.evaluate('async () => { await document.fonts.ready; await Promise.all([...document.images].map(image => image.decode().catch(() => {}))); }') + page.pdf(path=str(output), format='Letter' if page_size.lower()=='letter' else 'A4', + print_background=True, display_header_footer=False, prefer_css_page_size=False) + finally: + browser.close() + + +if __name__ == '__main__': + print_snapshot(Path(sys.argv[1]), Path(sys.argv[2]), sys.argv[3]) diff --git a/backend/app/export/service.py b/backend/app/export/service.py index f2fffe7..e001bcc 100644 --- a/backend/app/export/service.py +++ b/backend/app/export/service.py @@ -210,7 +210,7 @@ async def create_export(request: ExportRequest) -> ExportJob: _jobs[job_id] = job _cancel_flags[job_id] = asyncio.Event() _tasks[job_id] = asyncio.create_task( - _execute(job_id, request.format, markdown, title, metadata, request.options, assets) + _execute(job_id, request.format, markdown, title, metadata, request.options, assets, request.print_html) ) return job @@ -249,6 +249,7 @@ async def _execute( metadata: dict | None, options: ExportOptions, assets: dict | None = None, + print_html: str | None = None, ) -> None: """后台渲染:排队 → 解析 → 导出 → 写文件 → 挂载产物元信息。""" cancel_event = _cancel_flags[job_id] @@ -275,17 +276,21 @@ async def _execute( # 解析与渲染都是 CPU 密集的同步工作,放入线程执行避免阻塞事件循环, # 使运行中的取消能在渲染边界生效;写文件前再次检查取消。 - document = await asyncio.to_thread(parse_document, markdown) - document.attributes["title"] = title - from app.export.assets import attach_assets - attach_assets(document, assets or {}) - if metadata: - document.attributes["metadata"] = metadata + if format == ExportFormat.pdf and print_html is not None: + from app.export.browser_pdf import render_snapshot + result = await asyncio.to_thread(render_snapshot, print_html, options.page_size) + else: + document = await asyncio.to_thread(parse_document, markdown) + document.attributes["title"] = title + from app.export.assets import attach_assets + attach_assets(document, assets or {}) + if metadata: + document.attributes["metadata"] = metadata - from app.export.assets import enrich_document - resource_warnings = await asyncio.to_thread(enrich_document, document, (metadata or {}).get('file_path'), format == ExportFormat.pdf, options) - result = await asyncio.to_thread(_render_document, document, options, format) - result.warnings[:0] = resource_warnings + from app.export.assets import enrich_document + resource_warnings = await asyncio.to_thread(enrich_document, document, (metadata or {}).get('file_path'), format == ExportFormat.pdf, options) + result = await asyncio.to_thread(_render_document, document, options, format) + result.warnings[:0] = resource_warnings if cancel_event.is_set(): raise ExportCancelled() if format != ExportFormat.pdf and len(result.content) > MAX_EXPORT_BYTES: @@ -398,3 +403,29 @@ async def wait_for_export(job_id: str) -> ExportJob | None: if task is not None: await task return _jobs.get(job_id) + + +async def preview_resources(request: ExportRequest): + """Prepare Vault images and vector plots for the shared browser renderer.""" + import base64 + from app.export.assets import enrich_document + from app.plot.parser import parse_source + from app.plot.render import render_svg + from app.export.document import Document + markdown, _, metadata = await _resolve_source(request.source, True) + def prepare(): + document = parse_document(markdown) + images, plots = [], [] + def visit(node): + if node.type == 'image': + warnings = enrich_document(Document(node_id='pdf-resources', children=[node]), (metadata or {}).get('file_path'), True, request.options, preserve_alpha=True) + raw = node.attributes.get('static_png') + images.append({'source': node.attributes.get('src',''), 'data': 'data:image/png;base64,'+base64.b64encode(raw).decode() if raw else None, 'warnings': warnings}) + if node.type == 'function_plot': + parsed = parse_source(node.text, unlimited=True) + result = render_svg(parsed.plot, request.options.theme_id, unlimited=True) if parsed.plot else None + plots.append({'source':node.text, 'svg':result.content if result else '', 'warnings':[d.message for d in parsed.diagnostics]+(result.warnings if result else [])}) + for child in node.children: visit(child) + for child in document.children: visit(child) + return {'images':images,'plots':plots} + return await asyncio.to_thread(prepare) diff --git a/backend/app/plot/render.py b/backend/app/plot/render.py index 1685ecb..fa79112 100644 --- a/backend/app/plot/render.py +++ b/backend/app/plot/render.py @@ -476,9 +476,9 @@ def _labels_svg(geo: PlotGeometry) -> str: return "".join(parts) -def render_svg(plot: FunctionPlot, theme_id: str = 'light') -> StaticRenderResult: +def render_svg(plot: FunctionPlot, theme_id: str = 'light', unlimited: bool = False) -> StaticRenderResult: """把已解析的 FunctionPlot 渲染为内嵌 SVG。""" - geo = compute_geometry(plot) + geo = compute_geometry(plot, unlimited=unlimited) legend_height = ((len(plot.expressions) + 1) // 2) * 24 height = geo.height + legend_height parts: list[str] = [ diff --git a/backend/app/routes.py b/backend/app/routes.py index aa91fd7..e5c0bfc 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -1547,6 +1547,11 @@ async def create_export(request: ExportRequest) -> ExportJob: return await export_service.create_export(request) +@router.post("/exports/preview-resources", tags=["Export"]) +async def export_preview_resources(request: ExportRequest): + return await export_service.preview_resources(request) + + @router.get( "/exports", response_model=ExportJobListResponse, diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f6a52ef..d500c1d 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "sqlite-vec>=0.1.9", "uvicorn[standard]>=0.35,<1.0", "matplotlib>=3.9,<4", + "playwright>=1.55,<2", ] [dependency-groups] diff --git a/backend/tests/test_pdf_browser.py b/backend/tests/test_pdf_browser.py new file mode 100644 index 0000000..1a42030 --- /dev/null +++ b/backend/tests/test_pdf_browser.py @@ -0,0 +1,47 @@ +import asyncio +from pathlib import Path +import pytest +from app.contracts import ExportRequest +from app.export import service +from app.export.document import ExportResult +from app.export.browser_pdf import render_snapshot, browser_executable + + +def test_browser_snapshot_uses_print_pipeline(monkeypatch): + calls=[] + def render(html,size): + calls.append((html,size));return ExportResult(content=b'%PDF-browser',mime_type='application/pdf') + monkeypatch.setattr('app.export.browser_pdf.render_snapshot',render) + monkeypatch.setattr(service,'parse_document',lambda _:pytest.fail('Browser snapshots must not be reparsed by ReportLab')) + async def run(): + request=ExportRequest(format='pdf',source={'type':'markdown','markdown':'snapshot'},print_html='

Note

') + job=await service.create_export(request);done=await service.wait_for_export(job.job_id) + assert done.status.value=='completed' + assert calls==[(request.print_html,'A4')] + asyncio.run(run()) + + +def test_preview_resources_keeps_vault_boundary_and_plot_quota_removed(): + async def run(): + source='![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(' { disposed = true; clearTimeout(timer) })

DOCX 使用浅色打印样式。

-

PDF 使用当前主题配色。

+

PDF 使用当前笔记主题与排版样式。

{{ error }}

diff --git a/frontend/src/services/exportService.spec.ts b/frontend/src/services/exportService.spec.ts index 744b672..750ef7c 100644 --- a/frontend/src/services/exportService.spec.ts +++ b/frontend/src/services/exportService.spec.ts @@ -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('theme snapshot')})) +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:'',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:'theme snapshot'})) }) 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'} diff --git a/frontend/src/services/exportService.ts b/frontend/src/services/exportService.ts index 5fdbdf7..c0668fd 100644 --- a/frontend/src/services/exportService.ts +++ b/frontend/src/services/exportService.ts @@ -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('/api/exports', { source: { type: 'markdown', markdown, file_path: filePath }, title, format, options, assets })) + const job = mapJob(await apiClient.post('/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(`/api/exports/${encodeURIComponent(job.id)}`) diff --git a/frontend/src/services/mermaidService.ts b/frontend/src/services/mermaidService.ts index 6d38204..d02d375 100644 --- a/frontend/src/services/mermaidService.ts +++ b/frontend/src/services/mermaidService.ts @@ -29,13 +29,13 @@ export function mermaidThemeVariables(dark: boolean, useDocument = true) { } } -async function ensureInitialized(theme: 'light' | 'dark', raster = false, palette?: Record, unlimited = false) { +async function ensureInitialized(theme: 'light' | 'dark', raster = false, palette?: Record, unlimited = false, frozenVariables?: ReturnType) { 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; unlimited?: boolean } = {}): Promise { +export function renderMermaid(source: string, options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' | 'raster'; palette?: Record; unlimited?: boolean; themeVariables?: ReturnType } = {}): Promise { return serialized(() => renderMermaidNow(source, options)) } async function renderMermaidNow( source: string, - options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' | 'raster'; palette?: Record; unlimited?: boolean } = {} + options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' | 'raster'; palette?: Record; unlimited?: boolean; themeVariables?: ReturnType } = {} ): Promise { 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') diff --git a/frontend/src/services/pdfSnapshotService.spec.ts b/frontend/src/services/pdfSnapshotService.spec.ts new file mode 100644 index 0000000..9c65ed6 --- /dev/null +++ b/frontend/src/services/pdfSnapshotService.spec.ts @@ -0,0 +1,40 @@ +// @vitest-environment jsdom +import {it,expect,vi,afterEach} from 'vitest' +vi.mock('@/utils/markdown',()=>({renderMarkdown:vi.fn().mockResolvedValue('

Heading

Tip

Body

python
')})) +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','',{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('<Title>') + 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() +}) diff --git a/frontend/src/services/pdfSnapshotService.ts b/frontend/src/services/pdfSnapshotService.ts new file mode 100644 index 0000000..e4c049b --- /dev/null +++ b/frontend/src/services/pdfSnapshotService.ts @@ -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,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>') } +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)}
${options.include_title?`

${escape(title)}

`:''}${fragment.body.innerHTML}
` +} diff --git a/frontend/src/utils/markdown.ts b/frontend/src/utils/markdown.ts index fc6b11b..4614f0f 100644 --- a/frontend/src/utils/markdown.ts +++ b/frontend/src/utils/markdown.ts @@ -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 }): Promise { +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 }; citationNumbers?: number[]; citationAliases?: Record }): Promise { 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