From 77f7c5ff7b0e95f29a9a0e29bb56b67932891795 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:38:06 +0800 Subject: [PATCH] fix(export): print an exported HTML file, with 1cm margins Printing an exported HTML file from a browser gave a blank page. The export copies the app's stylesheet, whose print block hides everything outside #app, and an exported file has no #app. The export now shows its article on paper and keeps its 40px padding to the screen, so paper gets the print block's margins, which are 1cm for both the PDF route and an exported file. --- scripts/export.checks.test.ts | 13 +++---- scripts/export.spec.ts | 68 +++++++++++++++++++++++++++++++++++ src/lib/utils/export.ts | 15 +++++++- src/styles.css | 6 ++-- 4 files changed, 92 insertions(+), 10 deletions(-) create mode 100644 scripts/export.spec.ts diff --git a/scripts/export.checks.test.ts b/scripts/export.checks.test.ts index cc64200d..ff6e7d8a 100644 --- a/scripts/export.checks.test.ts +++ b/scripts/export.checks.test.ts @@ -130,7 +130,7 @@ describe('exported content width', () => { test('the export cap loses to the print block, so the PDF route is unchanged', () => { // `@media print` in styles.css already forces `.markdown-body` to - // `max-width: 100% !important` with its own .75in padding, and that block + // `max-width: 100% !important` with its own 1cm padding, and that block // travels into the export with the rest of the sheet. Printing an exported // file therefore never saw the 900px and must not start seeing the setting // either — the two routes stay separate concerns. @@ -138,7 +138,7 @@ describe('exported content width', () => { const printed = declarationsFor(css, 'max-width', 'print'); assert.ok(printed.length >= 1, 'the print block must still cap .markdown-body'); assert.deepEqual(printed.at(-1), { value: '100%', important: true }); - assert.deepEqual(declarationsFor(css, 'padding', 'print').at(-1), { value: '0.75in', important: true }); + assert.deepEqual(declarationsFor(css, 'padding', 'print').at(-1), { value: '1cm', important: true }); }); test('the app stylesheet copied into an export does not out-rank the cap', () => { @@ -801,11 +801,12 @@ describe('oversized media in print', () => { const printBlock = extractPrintBlock(styles); // Paper geometry the cap has to satisfy. `@page { margin: 0 }` makes the page - // box the whole sheet, and `.markdown-body` supplies 0.75in of padding top and - // bottom, so the printable column is the sheet height minus 1.5in. - const LETTER_PRINTABLE_IN = 11 - 1.5; + // box the whole sheet, and `.markdown-body` supplies 1cm of padding top and + // bottom, so the printable column is the sheet height minus 2cm. + const MARGINS_IN = 2 / 2.54; + const LETTER_PRINTABLE_IN = 11 - MARGINS_IN; - const A4_PRINTABLE_IN = 11.69 - 1.5; + const A4_PRINTABLE_IN = 11.69 - MARGINS_IN; /** Print rules whose body sets `property` (never a vendor-prefixed variant). */ function printRulesSetting(property: string): { selector: string; body: string }[] { diff --git a/scripts/export.spec.ts b/scripts/export.spec.ts new file mode 100644 index 00000000..b7b9d26c --- /dev/null +++ b/scripts/export.spec.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'vitest'; + +import { buildExportDocument } from '../src/lib/utils/export.ts'; +import { plainAppearance } from './exportFixtures.ts'; +import { readSource } from './sourceTree.js'; + +const appStyles = readSource('src/styles.css'); + +/** + * Loads `html` into the jsdom document as it would come out on `media`. jsdom + * applies no media type itself, so each `@media` block that names `media` (or + * `all`) is replaced in place by its own rules, and every other `@media` block + * is dropped. + */ +function render(html: string, media: 'print' | 'screen'): Document { + document.documentElement.innerHTML = html.replace(/^\s*]*>/, '').replace(/<\/html>\s*$/, ''); + for (const sheet of Array.from(document.styleSheets)) { + for (let index = sheet.cssRules.length - 1; index >= 0; index -= 1) { + const rule = sheet.cssRules[index]; + if (!(rule instanceof CSSMediaRule)) continue; + const applies = /\ball\b/.test(rule.media.mediaText) || rule.media.mediaText.includes(media); + const inner = applies ? Array.from(rule.cssRules, (r) => r.cssText) : []; + sheet.deleteRule(index); + inner.forEach((text, offset) => sheet.insertRule(text, index + offset)); + } + } + return document; +} + +function article(doc: Document): CSSStyleDeclaration { + const element = doc.querySelector('article.markdown-body'); + assert.ok(element, 'the page has no article'); + return getComputedStyle(element); +} + +const exported = buildExportDocument({ + theme: 'light', + title: 'Notes', + styles: appStyles, + articleHtml: '

body

', + contentWidth: null, + appearance: plainAppearance, +}); + +describe('printing an exported HTML file', () => { + test('prints the article instead of a blank page', () => { + // The copied print sheet hides everything outside #app, which an exported + // file does not have, so the article itself was hidden on paper. + assert.notEqual(article(render(exported, 'print')).display, 'none'); + }); + + test('gets the same paper margins as the app’s own PDF', () => { + // The PDF route prints #print-root inside #app; the export used to keep its + // screen padding on paper instead. + const pdf = render( + `
`, + 'print', + ); + const paper = article(pdf).paddingTop; + assert.equal(article(render(exported, 'print')).paddingTop, paper); + assert.equal(article(render(exported, 'print')).paddingLeft, paper); + }); + + test('keeps its 40px padding on screen', () => { + assert.equal(article(render(exported, 'screen')).paddingTop, '40px'); + }); +}); diff --git a/src/lib/utils/export.ts b/src/lib/utils/export.ts index d7432c4d..9150f5a4 100644 --- a/src/lib/utils/export.ts +++ b/src/lib/utils/export.ts @@ -226,7 +226,6 @@ html, body { padding: 0; } .markdown-body { - padding: 40px !important; max-width: ${exportContentMaxWidth(input.contentWidth)}; ${appearance} margin: 0 auto; @@ -234,6 +233,20 @@ ${appearance} overflow: visible !important; min-height: 100%; } +@media screen { + .markdown-body { + padding: 40px !important; + } +} +/* The copied print sheet hides everything outside #app, because the app prints + #print-root inside it. An exported file has no #app: its article is the page. + The :not(#app) is there for weight, to outrank that rule. Its padding is left + to the print sheet, so paper gets the same margins as the app's PDF. */ +@media print { + body > .markdown-body:not(#app) { + display: block !important; + } +} .lang-label { display: none !important; } diff --git a/src/styles.css b/src/styles.css index 350753af..5f0d5a86 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1440,7 +1440,7 @@ body { } .markdown-body { - padding: 0.75in !important; + padding: 1cm !important; margin: 0 !important; box-sizing: border-box !important; box-decoration-break: clone !important; @@ -1549,8 +1549,8 @@ body { margin: 1em auto; } /* An image or a diagram taller than the paper used to be clipped at the page - break rather than scaled; 9.5in is what both Letter and A4 hold with the - 0.75in margins above. */ + break rather than scaled; 9in fits both Letter and A4 inside the + 1cm margins above. */ .markdown-body img, .markdown-body .mermaid-diagram svg { max-height: 9in !important;