From f8f8409c5b10be0975795c6b3f15bc1d4f3ed28c Mon Sep 17 00:00:00 2001 From: PathGao Date: Mon, 3 Aug 2026 06:58:22 +0800 Subject: [PATCH] fix(print): make an editor-mode PDF export contain the document Exporting to PDF from plain edit mode produced a blank page, from two independent causes - and fixing either one alone is worse than fixing neither. The print sheet hides the editor pane and restores the preview with `.pane.viewer-pane { width: 100% !important }`, two classes. Svelte scopes the edit-mode collapse to `.layout-container:not(.split).editing.svelte-xxx .viewer-pane:where(.svelte-xxx)`, five. Both carry `!important`, so specificity decides and `width: 0` wins; `opacity: 0` had no print reset at all, so even a width-only fix prints a correctly sized blank rectangle. `#app` is the only handle a global sheet has that outranks a component's scoped selector. The preview-render effect runs on `tab.isSplit || (isEditing && showToc)`, so in plain edit mode with the outline closed `tab.content` never updates. Showing the pane without this yields the document as it was before the edits - present, stale, and looking like it worked, which is worse than a blank page. `syncPreviewForPrint` renders and awaits `renderRichContent` before printing, skipped in reading mode (that DOM already came from the same buffer, and re-rendering would discard scroll, fold and find state) and skipped when the rendered content already matches. A failure toasts rather than silently exporting a stale document. Co-Authored-By: Claude Opus 5 --- scripts/editorPdfExport.test.ts | 383 ++++++++++++++++++++++++++++++++ src/lib/MarkdownViewer.svelte | 48 ++++ src/styles.css | 24 +- 3 files changed, 453 insertions(+), 2 deletions(-) create mode 100644 scripts/editorPdfExport.test.ts diff --git a/scripts/editorPdfExport.test.ts b/scripts/editorPdfExport.test.ts new file mode 100644 index 00000000..27d5ee75 --- /dev/null +++ b/scripts/editorPdfExport.test.ts @@ -0,0 +1,383 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +import { compile } from 'svelte/compiler'; + +/* + * Export PDF from plain edit mode produced a blank page, for two independent + * reasons. This file covers both. + * + * 1. THE PANE. Printing works by hiding the editor and letting the viewer pane + * fill the sheet. Edit mode collapses that pane to `width: 0` / + * `opacity: 0` from MarkdownViewer.svelte, and the print sheet's + * `.pane.viewer-pane` could not outrank it: two `!important` declarations + * are decided by specificity, and Svelte's scoped selector carries five + * classes against two. + * + * This half is a cascade question, so it is answered by resolving the + * cascade — over the REAL compiled component CSS, not a copy of it, so the + * scoping Svelte actually emits is what gets measured. What this proves: + * for the element chain a PDF is rendered from, `width`/`flex`/`opacity`/ + * `display` resolve to visible values with the print sheet live, and to the + * collapsed ones without it. What it does not prove: that the printed page + * is legible, or anything about elements not in the modelled chain. + * + * 2. THE CONTENT. `tab.content` is only kept in step with the buffer while the + * preview is on screen. In plain edit mode with the TOC closed it is not, + * so revealing the pane alone would have printed the document as it was + * before the editing session — a failure that looks like a success. That + * half lives inside the component and is asserted against its source; see + * the note on those tests for what that does and does not establish. + */ + +const styles = readFileSync('src/styles.css', 'utf8'); +const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); + +const componentCss = (() => { + const compiled = compile(viewer, { filename: 'MarkdownViewer.svelte', css: 'external' }); + assert.ok(compiled.css?.code, 'the component must emit scoped CSS'); + return compiled.css.code; +})(); + +// --- a small cascade resolver ------------------------------------------------ + +type Declaration = { property: string; value: string; important: boolean }; +type Rule = { selector: string; declarations: Declaration[]; order: number; print: boolean }; +type Element = { tag: string; id?: string; classes: string[]; inline?: Record }; + +const SCOPE_CLASS = /^svelte-[a-z0-9]+$/; + +function stripComments(css: string): string { + return css.replace(/\/\*[\s\S]*?\*\//g, ''); +} + +function parseDeclarations(body: string): Declaration[] { + const declarations: Declaration[] = []; + for (const piece of body.split(';')) { + const colon = piece.indexOf(':'); + if (colon === -1) continue; + const property = piece.slice(0, colon).trim().toLowerCase(); + let value = piece.slice(colon + 1).trim(); + if (!property || !value) continue; + const important = /!important$/i.test(value); + if (important) value = value.replace(/!important$/i, '').trim(); + declarations.push({ property, value, important }); + } + return declarations; +} + +function parseRules(css: string, startOrder: number): Rule[] { + const rules: Rule[] = []; + let order = startOrder; + + const walk = (source: string, print: boolean) => { + let index = 0; + while (index < source.length) { + const open = source.indexOf('{', index); + if (open === -1) return; + const prelude = source.slice(index, open).trim(); + + let depth = 0; + let close = open; + for (; close < source.length; close += 1) { + if (source[close] === '{') depth += 1; + else if (source[close] === '}') { + depth -= 1; + if (depth === 0) break; + } + } + assert.ok(close < source.length, `unterminated block after ${prelude.slice(0, 40)}`); + const body = source.slice(open + 1, close); + + if (prelude.startsWith('@')) { + if (/^@(media|supports|layer|container)\b/.test(prelude)) { + walk(body, print || /@media[^{]*\bprint\b/.test(prelude)); + } + } else { + const declarations = parseDeclarations(body); + for (const selector of prelude.split(',')) { + const trimmed = selector.trim(); + if (trimmed) rules.push({ selector: trimmed, declarations, order: order++, print }); + } + } + index = close + 1; + } + }; + + walk(css, false); + return rules; +} + +/** `:where()` contributes nothing; `:not()` contributes its argument. */ +function specificity(selector: string): number { + const counted = selector + .replace(/:where\([^()]*\)/g, '') + .replace(/:not\(([^()]*)\)/g, '$1'); + const ids = (counted.match(/#[A-Za-z0-9_-]+/g) || []).length; + const classes = (counted.match(/[.:][A-Za-z0-9_-]+/g) || []).length; + const tags = counted.split(/[\s>]+/).filter((part) => /^[a-z]/.test(part)).length; + return ids * 10000 + classes * 100 + tags; +} + +/** false: cannot match. 'unsupported': this matcher cannot decide. */ +function compoundMatches(compound: string, element: Element): boolean | 'unsupported' { + // The chain is modelled as it exists while printing: no pointer, no focus, + // no siblings, no pseudo-elements. None of these can apply to it. + if (/:(?:hover|focus|active|target|before|after|marker|selection|first-line|first-letter)\b|::/.test(compound)) return false; + if (/\[/.test(compound)) return false; + + let rest = compound; + let failed = false; + rest = rest.replace(/:where\(([^()]*)\)/g, (_, inner: string) => { + for (const part of inner.split(',')) { + const trimmed = part.trim(); + if (!/^\.[A-Za-z0-9_-]+$/.test(trimmed)) return ':unsupported'; + const cls = trimmed.slice(1); + // Svelte puts only its scope class in `:where()`, and every element + // modelled here belongs to this component. + if (!(SCOPE_CLASS.test(cls) || element.classes.includes(cls))) return ':fails'; + } + return ''; + }); + rest = rest.replace(/:not\(([^()]*)\)/g, (_, inner: string) => { + const trimmed = inner.trim(); + if (/^\.[A-Za-z0-9_-]+$/.test(trimmed)) return element.classes.includes(trimmed.slice(1)) ? ':fails' : ''; + if (/^[a-z][a-z0-9]*$/i.test(trimmed)) return trimmed.toLowerCase() === element.tag ? ':fails' : ''; + return ':unsupported'; + }); + if (rest.includes(':fails')) return false; + if (rest.includes(':unsupported')) return 'unsupported'; + if (rest.includes(':')) return 'unsupported'; + + if (!/^\*?(?:[a-z][a-z0-9]*)?(?:#[A-Za-z0-9_-]+)?(?:\.[A-Za-z0-9_-]+)*$/i.test(rest)) return 'unsupported'; + + const id = rest.match(/#([A-Za-z0-9_-]+)/)?.[1]; + if (id && id !== element.id) return false; + const classes = [...rest.matchAll(/\.([A-Za-z0-9_-]+)/g)].map((m) => m[1]); + const tag = rest.replace(/[.#*].*$/, '').toLowerCase(); + if (tag && tag !== element.tag) return false; + return classes.every((cls) => SCOPE_CLASS.test(cls) || element.classes.includes(cls)); +} + +/** Right-to-left matching against an ancestor chain (root first). */ +function selectorMatches(selector: string, chain: Element[]): boolean | 'unsupported' { + const parts = selector.replace(/\s*>\s*/g, ' > ').split(/[\s]+/).filter(Boolean); + + // The subject decides first. A rule whose subject cannot match this element + // is out of the cascade no matter what the rest of it says, so combinators + // this matcher does not model only bite once the element itself matched. + const subject = compoundMatches(parts[parts.length - 1].replace(/^[+~>]$/, ''), chain[chain.length - 1]); + if (subject !== true) return subject; + + // No sibling is modelled, so a rule that survived the subject test and + // needs one cannot be decided here. + if (/[+~]/.test(selector)) return 'unsupported'; + + let part = parts.length - 2; + let index = chain.length - 1; + while (part >= 0) { + const isChild = parts[part] === '>'; + if (isChild) part -= 1; + if (part < 0) return 'unsupported'; + + let matched = false; + while (index > 0) { + index -= 1; + const result = compoundMatches(parts[part], chain[index]); + if (result === 'unsupported') return 'unsupported'; + if (result) { + matched = true; + break; + } + if (isChild) break; + } + if (!matched) return false; + part -= 1; + } + return true; +} + +const allRules = [ + ...parseRules(stripComments(styles), 0), + // Component styles are injected separately from the app sheet and their + // relative order is not guaranteed, so they are appended: the later + // position is the harder case for the print rules to win. + ...parseRules(stripComments(componentCss), 100000), +]; + +function resolve(chain: Element[], property: string, printing: boolean): string | undefined { + const element = chain[chain.length - 1]; + let winner: { important: boolean; specificity: number; order: number; value: string } | undefined; + + // An inline style beats every non-important stylesheet declaration. + const inline = element.inline?.[property]; + if (inline !== undefined) { + winner = { important: false, specificity: Number.MAX_SAFE_INTEGER, order: Number.MAX_SAFE_INTEGER, value: inline }; + } + + for (const rule of allRules) { + if (rule.print && !printing) continue; + if (!rule.declarations.some((declaration) => declaration.property === property)) continue; + const match = selectorMatches(rule.selector, chain); + assert.notEqual( + match, + 'unsupported', + `this test cannot resolve "${rule.selector}" against ${element.tag}.${element.classes.join('.')}; extend the matcher rather than leaving the rule unchecked`, + ); + if (!match) continue; + + for (const declaration of rule.declarations) { + if (declaration.property !== property) continue; + const candidate = { + important: declaration.important, + specificity: specificity(rule.selector), + order: rule.order, + value: declaration.value, + }; + if ( + !winner || + (candidate.important && !winner.important) || + (candidate.important === winner.important && + (candidate.specificity > winner.specificity || + (candidate.specificity === winner.specificity && candidate.order > winner.order))) + ) { + winner = candidate; + } + } + } + + return winner?.value; +} + +// --- the DOM a PDF is rendered from ----------------------------------------- + +const body: Element = { tag: 'body', classes: [] }; +const app: Element = { tag: 'div', id: 'app', classes: [] }; +const container: Element = { tag: 'div', classes: ['markdown-container'] }; + +/** `flex` is written inline from the split ratio / mode; see the template. */ +const layout = (classes: string[]): Element => ({ tag: 'div', classes: ['layout-container', ...classes] }); +const viewerPane = (inlineFlex: string): Element => ({ + tag: 'div', + classes: ['pane', 'viewer-pane'], + inline: { flex: inlineFlex }, +}); +const editorPane = (inlineFlex: string): Element => ({ + tag: 'div', + classes: ['pane', 'editor-pane', 'active'], + inline: { flex: inlineFlex }, +}); + +/** Plain edit mode: not split, TOC closed. This is the reported case. */ +const editingViewer = [body, app, container, layout(['editing']), viewerPane('0')]; +const editingEditor = [body, app, container, layout(['editing']), editorPane('1')]; +const readingViewer = [body, app, container, layout([]), viewerPane('1')]; +const splitViewer = [body, app, container, layout(['split', 'editing']), viewerPane('0.5')]; + +const printed = (chain: Element[], property: string) => resolve(chain, property, true); +const onScreen = (chain: Element[], property: string) => resolve(chain, property, false); + +test('the preview pane a PDF is rendered from is not collapsed by edit mode', () => { + // This is the blank page. On master `width` resolved to `0` because the + // component's scoped rule outranks `.pane.viewer-pane`, and `opacity` had + // no print reset at all — so even a width fix alone would have printed a + // correctly sized blank rectangle. + assert.equal(printed(editingViewer, 'width'), '100%'); + assert.notEqual(printed(editingViewer, 'opacity'), '0'); + assert.notEqual(printed(editingViewer, 'flex'), '0'); + assert.notEqual(printed(editingViewer, 'display'), 'none'); +}); + +test('edit mode still hides the preview on screen', () => { + // The print rules must reveal the pane, not disable the layout: the same + // element on screen has to stay collapsed, or edit mode grows a second pane. + assert.equal(onScreen(editingViewer, 'width'), '0'); + assert.equal(onScreen(editingViewer, 'opacity'), '0'); + assert.equal(onScreen(editingViewer, 'flex'), '0'); +}); + +test('the editor itself is still kept off the page', () => { + assert.equal(printed(editingEditor, 'display'), 'none'); + assert.notEqual(onScreen(editingEditor, 'display'), 'none'); +}); + +test('reading mode and split view print the same single full-width pane', () => { + // The pane carries an inline flex in both, and an inline declaration beats + // any stylesheet rule that is not `!important`. + for (const [name, chain] of [['reading', readingViewer], ['split', splitViewer]] as const) { + assert.equal(printed(chain, 'width'), '100%', name); + assert.equal(printed(chain, 'flex'), 'none', name); + assert.notEqual(printed(chain, 'opacity'), '0', name); + } +}); + +test('the reveal does not depend on which sheet the browser applies last', () => { + // Component CSS and the app sheet are injected separately and their order + // is not guaranteed. The resolver above already places the component last, + // which is the losing arrangement for the print rules; assert that the + // winning declaration is `!important` so order cannot decide it either way. + const printBlock = styles.slice(styles.indexOf('@media print')); + const override = printBlock.slice(printBlock.indexOf('#app .pane.viewer-pane')); + const rule = override.slice(0, override.indexOf('}')); + for (const property of ['width', 'flex', 'opacity']) { + assert.match(rule, new RegExp(`${property}:[^;]*!important`), `${property} must be !important`); + } +}); + +// --- the content half -------------------------------------------------------- +// +// `syncPreviewForPrint` lives in a Svelte component, so these are assertions +// about the component's source. They establish that the export calls it, and +// that it re-renders when the buffer has moved on. They do NOT establish that +// the resulting DOM is complete — that is what the awaited `renderRichContent` +// is for, and only a real browser can confirm it. + +const slice = (source: string, start: string, end: string) => { + const from = source.indexOf(start); + assert.notEqual(from, -1, `expected to find ${start}`); + const to = source.indexOf(end, from + start.length); + assert.notEqual(to, -1, `expected to find ${end} after ${start}`); + return source.slice(from, to); +}; + +test('the preview is only kept live while it is on screen', () => { + // The premise of the second half. If this condition ever widens to cover + // plain edit mode the export-time render below becomes a no-op rather than + // a wrong answer, so this documents the gap instead of forbidding a fix. + assert.match(viewer, /if \(tab && \(tab\.isSplit \|\| \(isEditing && settings\.showToc\)\) && tab\.rawContent !== undefined\)/); +}); + +test('exporting a PDF renders the buffer before the DOM is printed', () => { + const body_ = slice(viewer, 'async function exportAsPdf', 'function handleNewFile'); + const sync = body_.indexOf('await syncPreviewForPrint()'); + assert.notEqual(sync, -1, 'the export must refresh the preview first'); + // Before the diagram pass, which reads the nodes that render produces, and + // before the print itself. + assert.ok(sync < body_.indexOf('renderDiagramsForPrint'), 'refresh before re-theming the diagrams'); + assert.ok(sync < body_.indexOf('_exportPdf('), 'refresh before printing'); +}); + +test('the refresh is skipped when the DOM already matches the buffer', () => { + const body_ = slice(viewer, 'async function syncPreviewForPrint', 'async function exportAsPdf'); + // Reading mode is rendered by loadMarkdown from this same buffer, and + // re-rendering it would discard the scroll, fold and find state on screen. + assert.match(body_, /if \(!tab \|\| !\(tab\.isEditing \|\| tab\.isSplit\)\) return;/); + assert.match(body_, /_lastRenderedRawContent === rawContent\) return;/); +}); + +test('the refresh actually lands before the print', () => { + const body_ = slice(viewer, 'async function syncPreviewForPrint', 'async function exportAsPdf'); + assert.match(body_, /await renderMarkdownPreview\(rawContent, tab\.path\)/); + assert.match(body_, /tabManager\.updateTabContent\(tabId, processed\)/); + // Mermaid, KaTeX and highlight.js all replace nodes asynchronously. The + // on-screen path fires and forgets; the export cannot. + assert.match(body_, /await renderRichContent\(\)/); + assert.equal(body_.match(/await tick\(\)/g)?.length, 2, 'flush before and after the rich pass'); +}); + +test('a failed refresh does not pass off a stale export as a fresh one', () => { + const body_ = slice(viewer, 'async function syncPreviewForPrint', 'async function exportAsPdf'); + assert.match(body_, /catch \(error\)/); + assert.match(body_, /addToast\(/); +}); diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 4ca5cb6c..c8745c2d 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -1994,7 +1994,55 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu } } + /** + * Bring the preview DOM up to date with the buffer before it is printed. + * + * The effect that keeps `tab.content` in step with `tab.rawContent` only + * runs while the preview is on screen — split view, or the editor with the + * TOC open, because the TOC is built from the rendered headings. In plain + * edit mode with the TOC closed nothing re-renders, so `tab.content` is + * still whatever was rendered when the file was opened. + * + * Export PDF prints the live DOM. Revealing the pane (see the `#app + * .pane.viewer-pane` rule in styles.css) without this would export the + * document as it was before the editing session — a worse failure than the + * blank page it replaces, because it looks like it worked. Rendering here + * pays the cost once per export instead of once per keystroke, which is + * what the narrow effect condition exists to avoid. + * + * Reading mode is left alone: its DOM came from `loadMarkdown` rendering + * this same buffer, and re-rendering would throw away the scroll position + * and the fold/find state the user is looking at. + */ + async function syncPreviewForPrint() { + const tab = tabManager.activeTab; + if (!tab || !(tab.isEditing || tab.isSplit)) return; + const tabId = tab.id; + const rawContent = tab.rawContent; + if (rawContent === undefined) return; + if ((tab as any)._lastRenderedRawContent === rawContent) return; + try { + const processed = await renderMarkdownPreview(rawContent, tab.path); + const current = tabManager.activeTab; + if (tabManager.activeTabId !== tabId || current?.rawContent !== rawContent) return; + tabManager.updateTabContent(tabId, processed); + (current as any)._lastRenderedRawContent = rawContent; + await tick(); + // Awaited, unlike the on-screen path: Mermaid, KaTeX and + // highlight.js all replace nodes asynchronously, and the diagram + // re-theming below reads the nodes this produces. + await renderRichContent(); + await tick(); + } catch (error) { + // Printing the stale DOM is still better than not printing, but the + // user must not be told a fresh export happened. + console.error('Failed to refresh the preview before export', error); + addToast('Exported PDF may not include the latest edits', 'warning'); + } + } + async function exportAsPdf() { + await syncPreviewForPrint(); const tab = tabManager.activeTab; // Mermaid bakes the screen theme into the SVG it emits, so a dark // preview exports unreadable diagrams. Rebuild them light for the diff --git a/src/styles.css b/src/styles.css index 8dc44739..53196964 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1491,9 +1491,29 @@ body { } /* The viewer pane carries an inline split ratio during normal interaction. - Width alone cannot override that flex value in the print layout. */ - .pane.viewer-pane { + Width alone cannot override that flex value in the print layout. + + The `#app` is not decoration. Edit mode collapses the preview with a + MarkdownViewer.svelte rule that Svelte scopes to + `.layout-container:not(.split).editing.svelte-xxx .viewer-pane:where(...)` + — five classes, against two for `.pane.viewer-pane`. Two `!important` + declarations are decided by specificity, so the component won and the + pane the whole print layout is built around stayed at `width: 0`. That + is why Export PDF from the editor printed a blank page. `#app` is the + application root element (see app.html); its id is the only handle this + sheet has that outranks a component's scoped selector. + + `opacity` is listed for the same reason `.foldable-content-wrapper` + above lists it: the collapse is size *and* opacity, so restoring the + width alone prints a correctly sized blank rectangle. + + The pane also animates its flex, and a print taken mid-transition would + capture an intermediate width. */ + #app .pane.viewer-pane { + width: 100% !important; flex: none !important; + opacity: 1 !important; + transition: none !important; } .markdown-body {