Skip to content

Commit 198e4e3

Browse files
committed
fix(pdf): keep headings and images within page layout
1 parent 8a80480 commit 198e4e3

6 files changed

Lines changed: 672 additions & 25 deletions

File tree

apps/desktop/src/renderer/export-window.tsx

Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import {
1414
resolveCustomThemeMode
1515
} from '@renderer/lib/custom-themes'
1616
import { withExportTitle } from '@shared/export-title'
17-
import { settleExportImages } from '@renderer/lib/export-images'
17+
import { fitExportImageBoxes, settleExportImages } from '@renderer/lib/export-images'
18+
import { fitExportImagesToPages } from '@renderer/lib/export-pagination'
1819
import '@renderer/styles/index.css'
1920

2021
const PREFS_KEY = 'zen:prefs:v2'
@@ -130,7 +131,12 @@ function loadExportPrefs(): ExportPrefs {
130131
// frozen-width content is clipped on the sides. (Prose text always reflows, so
131132
// only such fixed-width content was affected, and only when the reading width
132133
// exceeded the printable width.)
133-
const PDF_PRINTABLE_WIDTH = '7.1in'
134+
// Chromium rounds the 0.7in margin to 67 CSS pixels. Use that exact margin for
135+
// both print and pagination measurement so line wraps/page boundaries agree.
136+
const PDF_PAGE_MARGIN_PX = Math.round(0.7 * 96)
137+
const PDF_PRINTABLE_WIDTH_PX = 8.5 * 96 - 2 * PDF_PAGE_MARGIN_PX
138+
const PDF_PRINTABLE_HEIGHT_PX = 11 * 96 - 2 * PDF_PAGE_MARGIN_PX
139+
const PDF_PRINTABLE_WIDTH = `${PDF_PRINTABLE_WIDTH_PX}px`
134140

135141
/** The clean default: a light theme on a white page, best for printing. */
136142
function applyLightExportTheme(): void {
@@ -242,14 +248,6 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element {
242248
// When exporting in the user's theme, the page follows the theme background;
243249
// otherwise it's the clean white print page.
244250
const pageBg = prefs.pdfExportUseTheme ? 'rgb(var(--z-bg))' : '#ffffff'
245-
// A themed export goes full-bleed: with a non-zero @page margin, paged media
246-
// leaves that margin frame unpainted and `color-scheme: dark` fills it with
247-
// Chromium's default dark canvas (#121212) — a mismatched frame around the
248-
// themed content. So drop the page margin and inset the content with padding
249-
// instead, letting --z-bg cover the whole sheet. The light export keeps the
250-
// classic per-page margin (white paper margins look correct there).
251-
const pageMargin = prefs.pdfExportUseTheme ? '0' : '0.7in'
252-
const contentInset = prefs.pdfExportUseTheme ? '0.7in' : '0'
253251

254252
useEffect(() => {
255253
applyExportPrefs(prefs)
@@ -347,7 +345,12 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element {
347345
<>
348346
<style>{`
349347
@page {
350-
margin: ${pageMargin};
348+
size: Letter;
349+
margin: ${PDF_PAGE_MARGIN_PX}px;
350+
/* Paint the margins as well as the content. Document padding only
351+
insets the first/last page, leaving continuation pages flush with
352+
the paper edge; real page margins repeat on every sheet. */
353+
background: ${pageBg};
351354
}
352355
html,
353356
body,
@@ -378,17 +381,31 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element {
378381
print-color-adjust: exact;
379382
}
380383
.export-note-shell .prose-zen {
381-
padding: 32px 40px 48px;
384+
/* Measure images and diagrams at the same column width as print. */
385+
width: ${PDF_PRINTABLE_WIDTH};
386+
max-width: ${PDF_PRINTABLE_WIDTH};
387+
padding: 0;
388+
margin: 0;
389+
orphans: 2;
390+
widows: 2;
391+
}
392+
.export-note-shell .prose-zen :is(h1, h2, h3, h4, h5, h6) {
393+
break-inside: avoid;
394+
break-after: avoid;
395+
}
396+
.export-note-shell .local-image-embed,
397+
.export-note-shell img {
398+
break-inside: avoid;
382399
}
383400
/* Keep tall (portrait) images within the printable page height so they
384401
scale down proportionally instead of overflowing the page and being
385402
clipped at the page boundary — a single <img> can't paginate (#231).
386403
Letter page height is 11in - 2 * 0.7in margins = 9.6in; cap a touch
387-
under that so an image still fits below a heading/caption. */
404+
under that to leave space for the image frame and caption. */
388405
.export-note-shell img {
389406
max-width: 100%;
390407
height: auto;
391-
max-height: 9.3in;
408+
max-height: 9in;
392409
object-fit: contain;
393410
}
394411
/* A standalone local image is rendered as a .local-image-embed figure
@@ -413,7 +430,7 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element {
413430
width: auto;
414431
height: auto;
415432
max-width: 100%;
416-
max-height: 9.3in;
433+
max-height: 9in;
417434
}
418435
@media print {
419436
html,
@@ -428,21 +445,14 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element {
428445
min-height: auto;
429446
overflow: visible;
430447
box-sizing: border-box;
431-
/* With @page margin dropped for themed (full-bleed) exports, the
432-
content inset comes from padding here instead — so the theme
433-
background reaches the paper edge. 0 for the light export. */
434-
padding: ${contentInset};
448+
padding: 0;
435449
}
436450
.export-note-shell .prose-zen {
437451
max-width: none;
438452
width: 100%;
439453
padding: 0;
440454
margin: 0;
441455
}
442-
.export-note-shell img {
443-
max-height: 9.3in;
444-
break-inside: avoid;
445-
}
446456
}
447457
`}</style>
448458
<main className="export-note-shell">
@@ -453,7 +463,17 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element {
453463
// Images load after the DOM is in place, and the preview defers
454464
// the ones below the viewport; print only once they have all
455465
// settled (#769).
456-
void settleExportImages(document).then(() => setExportState('ready'))
466+
void Promise.all([settleExportImages(document), document.fonts.ready]).then(() => {
467+
fitExportImageBoxes(document, PDF_PRINTABLE_HEIGHT_PX)
468+
const article = document.querySelector<HTMLElement>('[data-preview-content]')
469+
if (article) {
470+
fitExportImagesToPages(article, {
471+
width: PDF_PRINTABLE_WIDTH_PX,
472+
height: PDF_PRINTABLE_HEIGHT_PX
473+
})
474+
}
475+
setExportState('ready')
476+
})
457477
}}
458478
/>
459479
</main>

packages/app-core/src/lib/export-images.test.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// @vitest-environment jsdom
22

33
import { afterEach, describe, expect, it, vi } from 'vitest'
4-
import { settleExportImages } from './export-images'
4+
import { fitExportImageBoxes, settleExportImages } from './export-images'
55

66
// #769: the preview lazy-loads local images, so in the hidden export window an
77
// image below the viewport never loaded and printed as an empty frame. The
@@ -64,3 +64,69 @@ describe('settleExportImages', () => {
6464
expect(settled).toBe(true)
6565
})
6666
})
67+
68+
describe('fitExportImageBoxes', () => {
69+
function sizedImage(width: number, height: number, boxWidth: number, boxHeight: number) {
70+
const img = image('zen-asset://v/screenshot.png')
71+
Object.defineProperties(img, {
72+
naturalWidth: { value: width },
73+
naturalHeight: { value: height }
74+
})
75+
img.style.width = `${width}px`
76+
img.style.height = `${height}px`
77+
vi.spyOn(img, 'getBoundingClientRect').mockReturnValue({
78+
width: boxWidth,
79+
height: boxHeight
80+
} as DOMRect)
81+
return img
82+
}
83+
84+
it('removes empty vertical space when a sized screenshot is constrained to the page width', () => {
85+
const img = sizedImage(1200, 750, 640, 750)
86+
fitExportImageBoxes(document)
87+
expect(img.style.width).toBe('640px')
88+
// The browser must derive 400px from the aspect ratio, including if the
89+
// printable column becomes narrower; the old 750px box wasted 350px.
90+
expect(img.style.height).toBe('auto')
91+
})
92+
93+
it('shrinks a portrait frame to the picture constrained by the page height', () => {
94+
const img = sizedImage(800, 1600, 640, 864)
95+
fitExportImageBoxes(document)
96+
expect(img.style.width).toBe('432px')
97+
expect(img.style.height).toBe('auto')
98+
})
99+
100+
it('preserves small images and author-requested smaller sizes', () => {
101+
const small = sizedImage(80, 40, 80, 40)
102+
const resized = sizedImage(1200, 750, 320, 200)
103+
fitExportImageBoxes(document)
104+
expect(small.style.width).toBe('80px')
105+
expect(resized.style.width).toBe('320px')
106+
})
107+
108+
it('reserves room for a portrait caption and its preceding heading on the same page', () => {
109+
const img = sizedImage(800, 1600, 640, 864)
110+
const heading = document.createElement('h2')
111+
heading.style.margin = '20px 0'
112+
vi.spyOn(heading, 'getBoundingClientRect').mockReturnValue({ height: 50 } as DOMRect)
113+
const figure = document.createElement('figure')
114+
figure.style.margin = '10px 0'
115+
// The caption and frame occupy another 40px beyond the image itself.
116+
vi.spyOn(figure, 'getBoundingClientRect').mockReturnValue({ height: 904 } as DOMRect)
117+
figure.append(img)
118+
document.body.append(heading, figure)
119+
120+
fitExportImageBoxes(document, 920)
121+
expect(img.style.width).toBe('385px')
122+
expect(img.style.height).toBe('auto')
123+
})
124+
125+
it('leaves failed and hidden images alone', () => {
126+
const failed = sizedImage(0, 0, 100, 100)
127+
const hidden = sizedImage(800, 400, 0, 0)
128+
fitExportImageBoxes(document)
129+
expect(failed.style.height).toBe('0px')
130+
expect(hidden.style.height).toBe('400px')
131+
})
132+
})

packages/app-core/src/lib/export-images.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,45 @@
1414
*/
1515
export const EXPORT_IMAGE_SETTLE_TIMEOUT_MS = 8000
1616

17+
/**
18+
* Collapse the unused space around an object-fit: contain image before printing.
19+
* A |WxH hint sets both dimensions inline. When max-width constrains a wide
20+
* screenshot to the page, its fixed height survives: Chromium paginates that
21+
* oversized box even though the picture inside it is much shorter.
22+
* Keep the visible picture's size and let its height follow its aspect ratio,
23+
* including if printing narrows the column further. When given the printable
24+
* page height, leave room for the figure's caption and any preceding headings.
25+
* Call after images and fonts settle, at the printable column width.
26+
*/
27+
export function fitExportImageBoxes(root: ParentNode, pageHeight = Infinity): void {
28+
const margins = (element: Element): number => {
29+
const style = getComputedStyle(element)
30+
return (parseFloat(style.marginTop) || 0) + (parseFloat(style.marginBottom) || 0)
31+
}
32+
for (const img of Array.from(root.querySelectorAll<HTMLImageElement>('img'))) {
33+
if (!img.naturalWidth || !img.naturalHeight) continue
34+
const { width, height } = img.getBoundingClientRect()
35+
if (width <= 0 || height <= 0) continue
36+
let availableHeight = pageHeight
37+
const figure = img.closest('figure')
38+
if (figure) {
39+
availableHeight -=
40+
Math.max(0, figure.getBoundingClientRect().height - height) + margins(figure)
41+
let previous = figure.previousElementSibling
42+
while (previous?.matches('h1, h2, h3, h4, h5, h6')) {
43+
availableHeight -= previous.getBoundingClientRect().height + margins(previous)
44+
previous = previous.previousElementSibling
45+
}
46+
}
47+
// An exceptionally long caption/heading cannot fit even without the image;
48+
// leave that case to Chromium's fragmentation fallback instead of hiding it.
49+
const fittedHeight = availableHeight > 0 ? Math.min(height, availableHeight) : height
50+
const fittedWidth = Math.min(width, (fittedHeight * img.naturalWidth) / img.naturalHeight)
51+
img.style.width = `${fittedWidth}px`
52+
img.style.height = 'auto'
53+
}
54+
}
55+
1756
export function settleExportImages(
1857
root: ParentNode,
1958
timeoutMs = EXPORT_IMAGE_SETTLE_TIMEOUT_MS

0 commit comments

Comments
 (0)