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 b7b9d26c..45d4b9f2 100644 --- a/scripts/export.spec.ts +++ b/scripts/export.spec.ts @@ -8,30 +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. + * 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)); } } - return document; -} - -function article(doc: Document): CSSStyleDeclaration { - const element = doc.querySelector('article.markdown-body'); + 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 window.CSSStyleRule)) continue; + for (const name of Array.from(rule.style)) { + const value = rule.style.getPropertyValue(name); + if (!value.includes('var(')) continue; + 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, replaced, rule.style.getPropertyPriority(name)); + } + } + } + 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({ @@ -43,26 +66,31 @@ 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 - // 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); + // 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('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(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); }); }); 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;