Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions scripts/export.checks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,15 +130,15 @@ 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.
const css = exportedStylesheet(exportDocumentWithAppStyles(MIN_PREVIEW_MAX_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', () => {
Expand Down
78 changes: 53 additions & 25 deletions scripts/export.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/^<!DOCTYPE html>\s*<html[^>]*>/, '').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({
Expand All @@ -43,26 +66,31 @@ const exported = buildExportDocument({
appearance: plainAppearance,
});

// What the app's own PDF route prints: #print-root inside #app.
const pdfPage = `<!DOCTYPE html><html><head><style>${appStyles}</style></head><body><div id="app"><div id="print-root"><article class="markdown-body"></article></div></div></body></html>`;

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(
`<head><style>${appStyles}</style></head><body><div id="app"><div id="print-root"><article class="markdown-body"></article></div></div></body>`,
'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);
});
});
2 changes: 1 addition & 1 deletion scripts/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ describe('exported content width', () => {
buildExportDocument({ theme: 'light', title: 'Notes', styles: '', articleHtml: '<p>body</p>', 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', () => {
Expand Down
10 changes: 5 additions & 5 deletions src/lib/utils/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,8 @@ export interface ExportDocumentInput {
* child of `<body>`, 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`
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 5 additions & 2 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading