From ca8ecdb5468695f12b52af7cfab31cc2c42aac24 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:00:49 +0800 Subject: [PATCH 1/2] fix(export): give an exported file the page margin on screen too The exported file used 40px on screen and the print margin on paper. Both now come from one --page-margin (1cm, 37.8px on screen), so the file keeps its shape when printed. The live preview's own padding is unchanged. --- scripts/export.spec.ts | 29 +++++++++++++++++++++++++---- scripts/export.test.ts | 2 +- src/lib/utils/export.ts | 10 +++++----- src/styles.css | 7 +++++-- 4 files changed, 36 insertions(+), 12 deletions(-) diff --git a/scripts/export.spec.ts b/scripts/export.spec.ts index b7b9d26c..8c07f4e3 100644 --- a/scripts/export.spec.ts +++ b/scripts/export.spec.ts @@ -11,7 +11,8 @@ 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. + * is dropped. jsdom also leaves `var()` unresolved (a padding of `var(--x)` + * computes to 0), so each one is replaced by the root's value for it. */ function render(html: string, media: 'print' | 'screen'): Document { document.documentElement.innerHTML = html.replace(/^\s*]*>/, '').replace(/<\/html>\s*$/, ''); @@ -25,6 +26,20 @@ function render(html: string, media: 'print' | 'screen'): Document { inner.forEach((text, offset) => sheet.insertRule(text, index + offset)); } } + const root = getComputedStyle(document.documentElement); + for (const sheet of Array.from(document.styleSheets)) { + for (const rule of Array.from(sheet.cssRules)) { + if (!(rule instanceof CSSStyleRule)) continue; + for (const name of Array.from(rule.style)) { + const value = rule.style.getPropertyValue(name); + if (!value.includes('var(')) continue; + const resolved = value.replace(/var\(\s*(--[\w-]+)\s*(?:,\s*([^)]*))?\)/g, (_, variable: string, fallback?: string) => + root.getPropertyValue(variable).trim() || (fallback ?? '').trim(), + ); + rule.style.setProperty(name, resolved, rule.style.getPropertyPriority(name)); + } + } + } return document; } @@ -52,17 +67,23 @@ describe('printing an exported HTML file', () => { 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. + // 40px screen padding on paper instead. const pdf = render( `
`, 'print', ); const paper = article(pdf).paddingTop; + assert.ok(parseFloat(paper) > 0, `the PDF has no page margin: ${paper}`); 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'); + test('looks the same on screen as on paper', () => { + // One page margin for both, so the file does not change shape when printed. + const screen = article(render(exported, 'screen')); + const paper = article(render(exported, 'print')); + assert.ok(parseFloat(screen.paddingTop) > 0, `no margin on screen: ${screen.paddingTop}`); + assert.equal(screen.paddingTop, paper.paddingTop); + assert.equal(screen.paddingLeft, paper.paddingLeft); }); }); diff --git a/scripts/export.test.ts b/scripts/export.test.ts index ff1577bc..f92f7145 100644 --- a/scripts/export.test.ts +++ b/scripts/export.test.ts @@ -196,7 +196,7 @@ describe('exported content width', () => { buildExportDocument({ theme: 'light', title: 'Notes', styles: '', articleHtml: '

body

', contentWidth: null, appearance: plainAppearance }), ); assert.deepEqual(declarationsFor(css, 'margin', 'screen'), [{ value: '0 auto', important: false }]); - assert.deepEqual(declarationsFor(css, 'padding', 'screen'), [{ value: '40px', important: true }]); + assert.deepEqual(declarationsFor(css, 'padding', 'screen'), [{ value: 'var(--page-margin, 1cm)', important: true }]); }); test('a corrupted stored width is clamped instead of reaching the stylesheet raw', () => { diff --git a/src/lib/utils/export.ts b/src/lib/utils/export.ts index 9150f5a4..e2dfabbf 100644 --- a/src/lib/utils/export.ts +++ b/src/lib/utils/export.ts @@ -182,9 +182,8 @@ export interface ExportDocumentInput { * child of ``, so a percentage cap resolves to the width it already has — * but `none` says the intended thing without depending on that reasoning, and * it cannot interact with `box-sizing` the way a percentage can. `margin: 0 - * auto` then centres nothing (there is no slack to distribute) and the - * `padding: 40px !important` still holds the text off the window edge, which is - * exactly how the preview behaves in full-width mode. + * auto` then centres nothing (there is no slack to distribute) and the page + * margin padding still holds the text off the window edge. * * The value is normalized here rather than trusted, because this is the seam * where it stops being a number and becomes a CSS declaration: `previewMaxWidth` @@ -235,13 +234,14 @@ ${appearance} } @media screen { .markdown-body { - padding: 40px !important; + padding: var(--page-margin, 1cm) !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. */ + to the print sheet, so paper gets the same margins as the app's PDF; the + screen rule above uses the same margin, so the file looks the same both ways. */ @media print { body > .markdown-body:not(#app) { display: block !important; diff --git a/src/styles.css b/src/styles.css index 5f0d5a86..3c7c5c06 100644 --- a/src/styles.css +++ b/src/styles.css @@ -25,6 +25,9 @@ body { :root { --win-font: "Segoe UI Variable Display", "Segoe UI", system-ui, -apple-system, sans-serif; + /* The margin round a document on paper (the PDF and a printed export) and + round an exported file on screen, where CSS makes 1cm 37.8px. */ + --page-margin: 1cm; /* Default Light Theme */ --color-fg-default: #1f2328; @@ -1440,7 +1443,7 @@ body { } .markdown-body { - padding: 1cm !important; + padding: var(--page-margin) !important; margin: 0 !important; box-sizing: border-box !important; box-decoration-break: clone !important; @@ -1550,7 +1553,7 @@ body { } /* An image or a diagram taller than the paper used to be clipped at the page break rather than scaled; 9in fits both Letter and A4 inside the - 1cm margins above. */ + page margins above. */ .markdown-body img, .markdown-body .mermaid-diagram svg { max-height: 9in !important; From ed6aa105af9bc1343de49ed4c42ce89f68e3dd7c Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:05:19 +0800 Subject: [PATCH 2/2] test(export): resolve each page in its own frame One reused jsdom document kept the computed style of the previous page, so the margin comparisons read the same value on both sides and could not fail. Each page now gets its own frame, var() is resolved against the root, and a zero margin fails outright. --- scripts/export.checks.test.ts | 4 +- scripts/export.spec.ts | 69 +++++++++++++++++++---------------- 2 files changed, 40 insertions(+), 33 deletions(-) diff --git a/scripts/export.checks.test.ts b/scripts/export.checks.test.ts index ff6e7d8a..14ab68f8 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 1cm padding, and that block + // `max-width: 100% !important` with its own page-margin 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: '1cm', important: true }); + assert.deepEqual(declarationsFor(css, 'padding', 'print').at(-1), { value: 'var(--page-margin)', important: true }); }); test('the app stylesheet copied into an export does not out-rank the cap', () => { diff --git a/scripts/export.spec.ts b/scripts/export.spec.ts index 8c07f4e3..45d4b9f2 100644 --- a/scripts/export.spec.ts +++ b/scripts/export.spec.ts @@ -8,45 +8,53 @@ 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. jsdom also leaves `var()` unresolved (a padding of `var(--x)` - * computes to 0), so each one is replaced by the root's value for it. + * The computed style of the page's article as `html` would come out on `media`. + * + * jsdom applies only screen rules, so each `@media` block that names `media` + * (or `all`) is replaced in place by its own rules and every other `@media` + * block is dropped. jsdom also leaves `var()` unresolved (a padding of + * `var(--x)` computes to 0), so each one is replaced by the root's value for it. + * Every call gets its own frame: one reused document keeps computed styles from + * the previous page. */ -function render(html: string, media: 'print' | 'screen'): Document { - document.documentElement.innerHTML = html.replace(/^\s*]*>/, '').replace(/<\/html>\s*$/, ''); +function article(html: string, media: 'print' | 'screen'): { display: string; paddingTop: string; paddingLeft: string } { + const frame = globalThis.document.createElement('iframe'); + globalThis.document.body.append(frame); + // A frame's window carries the same constructors the global one does. + const window = frame.contentWindow as Window & typeof globalThis; + const { document } = window; + document.open(); + document.write(html); + document.close(); 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; + if (!(rule instanceof window.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)); } } - const root = getComputedStyle(document.documentElement); + const root = window.getComputedStyle(document.documentElement); for (const sheet of Array.from(document.styleSheets)) { for (const rule of Array.from(sheet.cssRules)) { - if (!(rule instanceof CSSStyleRule)) continue; + if (!(rule instanceof window.CSSStyleRule)) continue; for (const name of Array.from(rule.style)) { const value = rule.style.getPropertyValue(name); if (!value.includes('var(')) continue; - const resolved = value.replace(/var\(\s*(--[\w-]+)\s*(?:,\s*([^)]*))?\)/g, (_, variable: string, fallback?: string) => + const replaced = value.replace(/var\(\s*(--[\w-]+)\s*(?:,\s*([^)]*))?\)/g, (_: string, variable: string, fallback?: string) => root.getPropertyValue(variable).trim() || (fallback ?? '').trim(), ); - rule.style.setProperty(name, resolved, rule.style.getPropertyPriority(name)); + rule.style.setProperty(name, replaced, rule.style.getPropertyPriority(name)); } } } - return document; -} - -function article(doc: Document): CSSStyleDeclaration { - const element = doc.querySelector('article.markdown-body'); + const element = document.querySelector('article.markdown-body'); assert.ok(element, 'the page has no article'); - return getComputedStyle(element); + const { display, paddingTop, paddingLeft } = window.getComputedStyle(element); + frame.remove(); + return { display, paddingTop, paddingLeft }; } const exported = buildExportDocument({ @@ -58,30 +66,29 @@ const exported = buildExportDocument({ appearance: plainAppearance, }); +// What the app's own PDF route prints: #print-root inside #app. +const pdfPage = `
`; + 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'); + assert.notEqual(article(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 - // 40px screen padding on paper instead. - const pdf = render( - `
`, - 'print', - ); - const paper = article(pdf).paddingTop; - assert.ok(parseFloat(paper) > 0, `the PDF has no page margin: ${paper}`); - assert.equal(article(render(exported, 'print')).paddingTop, paper); - assert.equal(article(render(exported, 'print')).paddingLeft, paper); + // The export used to keep its 40px screen padding on paper. + const pdf = article(pdfPage, 'print'); + assert.ok(parseFloat(pdf.paddingTop) > 0, `the PDF has no page margin: ${pdf.paddingTop}`); + const printed = article(exported, 'print'); + assert.equal(printed.paddingTop, pdf.paddingTop); + assert.equal(printed.paddingLeft, pdf.paddingLeft); }); test('looks the same on screen as on paper', () => { // One page margin for both, so the file does not change shape when printed. - const screen = article(render(exported, 'screen')); - const paper = article(render(exported, 'print')); + const screen = article(exported, 'screen'); + const paper = article(exported, 'print'); assert.ok(parseFloat(screen.paddingTop) > 0, `no margin on screen: ${screen.paddingTop}`); assert.equal(screen.paddingTop, paper.paddingTop); assert.equal(screen.paddingLeft, paper.paddingLeft);