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( + `