diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 1eb3dd8f..ba78af96 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "@zennotes/desktop",
"productName": "ZenNotes",
- "version": "2.50.0",
+ "version": "2.50.1",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
diff --git a/apps/desktop/src/renderer/export-window.tsx b/apps/desktop/src/renderer/export-window.tsx
index 5aa474c1..bc053635 100644
--- a/apps/desktop/src/renderer/export-window.tsx
+++ b/apps/desktop/src/renderer/export-window.tsx
@@ -14,7 +14,8 @@ import {
resolveCustomThemeMode
} from '@renderer/lib/custom-themes'
import { withExportTitle } from '@shared/export-title'
-import { settleExportImages } from '@renderer/lib/export-images'
+import { fitExportImageBoxes, settleExportImages } from '@renderer/lib/export-images'
+import { fitExportImagesToPages } from '@renderer/lib/export-pagination'
import '@renderer/styles/index.css'
const PREFS_KEY = 'zen:prefs:v2'
@@ -130,7 +131,12 @@ function loadExportPrefs(): ExportPrefs {
// frozen-width content is clipped on the sides. (Prose text always reflows, so
// only such fixed-width content was affected, and only when the reading width
// exceeded the printable width.)
-const PDF_PRINTABLE_WIDTH = '7.1in'
+// Chromium rounds the 0.7in margin to 67 CSS pixels. Use that exact margin for
+// both print and pagination measurement so line wraps/page boundaries agree.
+const PDF_PAGE_MARGIN_PX = Math.round(0.7 * 96)
+const PDF_PRINTABLE_WIDTH_PX = 8.5 * 96 - 2 * PDF_PAGE_MARGIN_PX
+const PDF_PRINTABLE_HEIGHT_PX = 11 * 96 - 2 * PDF_PAGE_MARGIN_PX
+const PDF_PRINTABLE_WIDTH = `${PDF_PRINTABLE_WIDTH_PX}px`
/** The clean default: a light theme on a white page, best for printing. */
function applyLightExportTheme(): void {
@@ -242,14 +248,6 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element {
// When exporting in the user's theme, the page follows the theme background;
// otherwise it's the clean white print page.
const pageBg = prefs.pdfExportUseTheme ? 'rgb(var(--z-bg))' : '#ffffff'
- // A themed export goes full-bleed: with a non-zero @page margin, paged media
- // leaves that margin frame unpainted and `color-scheme: dark` fills it with
- // Chromium's default dark canvas (#121212) — a mismatched frame around the
- // themed content. So drop the page margin and inset the content with padding
- // instead, letting --z-bg cover the whole sheet. The light export keeps the
- // classic per-page margin (white paper margins look correct there).
- const pageMargin = prefs.pdfExportUseTheme ? '0' : '0.7in'
- const contentInset = prefs.pdfExportUseTheme ? '0.7in' : '0'
useEffect(() => {
applyExportPrefs(prefs)
@@ -347,7 +345,12 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element {
<>
@@ -453,7 +463,17 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element {
// Images load after the DOM is in place, and the preview defers
// the ones below the viewport; print only once they have all
// settled (#769).
- void settleExportImages(document).then(() => setExportState('ready'))
+ void Promise.all([settleExportImages(document), document.fonts.ready]).then(() => {
+ fitExportImageBoxes(document, PDF_PRINTABLE_HEIGHT_PX)
+ const article = document.querySelector('[data-preview-content]')
+ if (article) {
+ fitExportImagesToPages(article, {
+ width: PDF_PRINTABLE_WIDTH_PX,
+ height: PDF_PRINTABLE_HEIGHT_PX
+ })
+ }
+ setExportState('ready')
+ })
}}
/>
diff --git a/apps/server/package.json b/apps/server/package.json
index b897e3b7..b3803d6b 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -1,7 +1,7 @@
{
"name": "@zennotes/server",
"private": true,
- "version": "2.50.0",
+ "version": "2.50.1",
"scripts": {
"dev": "node ../../tooling/scripts/run-go-server-dev.mjs",
"prepare-web": "node ../../tooling/scripts/prepare-server-web-dist.mjs",
diff --git a/apps/web/package.json b/apps/web/package.json
index b2825986..acf33710 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,7 +1,7 @@
{
"name": "@zennotes/web",
"private": true,
- "version": "2.50.0",
+ "version": "2.50.1",
"type": "module",
"description": "ZenNotes web client for self-hosted and hosted deployments",
"homepage": "https://zennotes.org",
diff --git a/package-lock.json b/package-lock.json
index 73313a0d..23e67575 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "zennotes-monorepo",
- "version": "2.50.0",
+ "version": "2.50.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "zennotes-monorepo",
- "version": "2.50.0",
+ "version": "2.50.1",
"hasInstallScript": true,
"workspaces": [
"apps/*",
@@ -23,7 +23,7 @@
},
"apps/desktop": {
"name": "@zennotes/desktop",
- "version": "2.50.0",
+ "version": "2.50.1",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.18.3",
@@ -874,11 +874,11 @@
},
"apps/server": {
"name": "@zennotes/server",
- "version": "2.50.0"
+ "version": "2.50.1"
},
"apps/web": {
"name": "@zennotes/web",
- "version": "2.50.0",
+ "version": "2.50.1",
"dependencies": {
"@codemirror/autocomplete": "^6.18.3",
"@codemirror/commands": "^6.7.1",
@@ -16286,7 +16286,7 @@
},
"packages/app-core": {
"name": "@zennotes/app-core",
- "version": "2.50.0",
+ "version": "2.50.1",
"dependencies": {
"@codemirror/autocomplete": "^6.18.3",
"@codemirror/commands": "^6.7.1",
@@ -16363,11 +16363,11 @@
},
"packages/bridge-contract": {
"name": "@zennotes/bridge-contract",
- "version": "2.50.0"
+ "version": "2.50.1"
},
"packages/shared-domain": {
"name": "@zennotes/shared-domain",
- "version": "2.50.0",
+ "version": "2.50.1",
"dependencies": {
"@zennotes/bridge-contract": "*",
"lz-string": "^1.5.0"
@@ -16378,7 +16378,7 @@
},
"packages/shared-ui": {
"name": "@zennotes/shared-ui",
- "version": "2.50.0"
+ "version": "2.50.1"
}
}
}
diff --git a/package.json b/package.json
index 37049184..e2d86e65 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "zennotes-monorepo",
"private": true,
- "version": "2.50.0",
+ "version": "2.50.1",
"description": "ZenNotes monorepo for desktop, web, and self-hosted server builds",
"packageManager": "npm@10.9.2",
"engines": {
diff --git a/packages/app-core/package.json b/packages/app-core/package.json
index 7fc48020..840e4256 100644
--- a/packages/app-core/package.json
+++ b/packages/app-core/package.json
@@ -1,7 +1,7 @@
{
"name": "@zennotes/app-core",
"private": true,
- "version": "2.50.0",
+ "version": "2.50.1",
"type": "module",
"exports": {
"./main": "./src/main.tsx"
diff --git a/packages/app-core/src/lib/export-images.test.ts b/packages/app-core/src/lib/export-images.test.ts
index 8514a2f9..0326322c 100644
--- a/packages/app-core/src/lib/export-images.test.ts
+++ b/packages/app-core/src/lib/export-images.test.ts
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { settleExportImages } from './export-images'
+import { fitExportImageBoxes, settleExportImages } from './export-images'
// #769: the preview lazy-loads local images, so in the hidden export window an
// image below the viewport never loaded and printed as an empty frame. The
@@ -64,3 +64,69 @@ describe('settleExportImages', () => {
expect(settled).toBe(true)
})
})
+
+describe('fitExportImageBoxes', () => {
+ function sizedImage(width: number, height: number, boxWidth: number, boxHeight: number) {
+ const img = image('zen-asset://v/screenshot.png')
+ Object.defineProperties(img, {
+ naturalWidth: { value: width },
+ naturalHeight: { value: height }
+ })
+ img.style.width = `${width}px`
+ img.style.height = `${height}px`
+ vi.spyOn(img, 'getBoundingClientRect').mockReturnValue({
+ width: boxWidth,
+ height: boxHeight
+ } as DOMRect)
+ return img
+ }
+
+ it('removes empty vertical space when a sized screenshot is constrained to the page width', () => {
+ const img = sizedImage(1200, 750, 640, 750)
+ fitExportImageBoxes(document)
+ expect(img.style.width).toBe('640px')
+ // The browser must derive 400px from the aspect ratio, including if the
+ // printable column becomes narrower; the old 750px box wasted 350px.
+ expect(img.style.height).toBe('auto')
+ })
+
+ it('shrinks a portrait frame to the picture constrained by the page height', () => {
+ const img = sizedImage(800, 1600, 640, 864)
+ fitExportImageBoxes(document)
+ expect(img.style.width).toBe('432px')
+ expect(img.style.height).toBe('auto')
+ })
+
+ it('preserves small images and author-requested smaller sizes', () => {
+ const small = sizedImage(80, 40, 80, 40)
+ const resized = sizedImage(1200, 750, 320, 200)
+ fitExportImageBoxes(document)
+ expect(small.style.width).toBe('80px')
+ expect(resized.style.width).toBe('320px')
+ })
+
+ it('reserves room for a portrait caption and its preceding heading on the same page', () => {
+ const img = sizedImage(800, 1600, 640, 864)
+ const heading = document.createElement('h2')
+ heading.style.margin = '20px 0'
+ vi.spyOn(heading, 'getBoundingClientRect').mockReturnValue({ height: 50 } as DOMRect)
+ const figure = document.createElement('figure')
+ figure.style.margin = '10px 0'
+ // The caption and frame occupy another 40px beyond the image itself.
+ vi.spyOn(figure, 'getBoundingClientRect').mockReturnValue({ height: 904 } as DOMRect)
+ figure.append(img)
+ document.body.append(heading, figure)
+
+ fitExportImageBoxes(document, 920)
+ expect(img.style.width).toBe('385px')
+ expect(img.style.height).toBe('auto')
+ })
+
+ it('leaves failed and hidden images alone', () => {
+ const failed = sizedImage(0, 0, 100, 100)
+ const hidden = sizedImage(800, 400, 0, 0)
+ fitExportImageBoxes(document)
+ expect(failed.style.height).toBe('0px')
+ expect(hidden.style.height).toBe('400px')
+ })
+})
diff --git a/packages/app-core/src/lib/export-images.ts b/packages/app-core/src/lib/export-images.ts
index f4cd06c7..2efe8a65 100644
--- a/packages/app-core/src/lib/export-images.ts
+++ b/packages/app-core/src/lib/export-images.ts
@@ -14,6 +14,45 @@
*/
export const EXPORT_IMAGE_SETTLE_TIMEOUT_MS = 8000
+/**
+ * Collapse the unused space around an object-fit: contain image before printing.
+ * A |WxH hint sets both dimensions inline. When max-width constrains a wide
+ * screenshot to the page, its fixed height survives: Chromium paginates that
+ * oversized box even though the picture inside it is much shorter.
+ * Keep the visible picture's size and let its height follow its aspect ratio,
+ * including if printing narrows the column further. When given the printable
+ * page height, leave room for the figure's caption and any preceding headings.
+ * Call after images and fonts settle, at the printable column width.
+ */
+export function fitExportImageBoxes(root: ParentNode, pageHeight = Infinity): void {
+ const margins = (element: Element): number => {
+ const style = getComputedStyle(element)
+ return (parseFloat(style.marginTop) || 0) + (parseFloat(style.marginBottom) || 0)
+ }
+ for (const img of Array.from(root.querySelectorAll('img'))) {
+ if (!img.naturalWidth || !img.naturalHeight) continue
+ const { width, height } = img.getBoundingClientRect()
+ if (width <= 0 || height <= 0) continue
+ let availableHeight = pageHeight
+ const figure = img.closest('figure')
+ if (figure) {
+ availableHeight -=
+ Math.max(0, figure.getBoundingClientRect().height - height) + margins(figure)
+ let previous = figure.previousElementSibling
+ while (previous?.matches('h1, h2, h3, h4, h5, h6')) {
+ availableHeight -= previous.getBoundingClientRect().height + margins(previous)
+ previous = previous.previousElementSibling
+ }
+ }
+ // An exceptionally long caption/heading cannot fit even without the image;
+ // leave that case to Chromium's fragmentation fallback instead of hiding it.
+ const fittedHeight = availableHeight > 0 ? Math.min(height, availableHeight) : height
+ const fittedWidth = Math.min(width, (fittedHeight * img.naturalWidth) / img.naturalHeight)
+ img.style.width = `${fittedWidth}px`
+ img.style.height = 'auto'
+ }
+}
+
export function settleExportImages(
root: ParentNode,
timeoutMs = EXPORT_IMAGE_SETTLE_TIMEOUT_MS
diff --git a/packages/app-core/src/lib/export-pagination.ts b/packages/app-core/src/lib/export-pagination.ts
new file mode 100644
index 00000000..c8d54d73
--- /dev/null
+++ b/packages/app-core/src/lib/export-pagination.ts
@@ -0,0 +1,150 @@
+const HEADINGS = 'h1, h2, h3, h4, h5, h6'
+const FORCED_BREAKS = new Set(['page', 'left', 'right', 'recto', 'verso', 'column', 'all'])
+const MIN_IMAGE_SCALE = 0.5
+
+/**
+ * Fit standalone images into otherwise wasted space at the end of a page.
+ *
+ * A continuous DOM's y % pageHeight ignores earlier page breaks. Instead, let
+ * Chromium fragment the export into page-sized columns, then measure the last
+ * fragment of the preceding block. Columns use the same break/keep rules as
+ * pages. Reflow after each image so subsequent images see the updated pages.
+ * Only image widths survive this pass; the measurement layout is always removed.
+ */
+export function fitExportImagesToPages(
+ article: HTMLElement,
+ page: { width: number; height: number }
+): void {
+ const images = Array.from(article.querySelectorAll('img'))
+ if (!images.length) return
+
+ const originalStyle = article.getAttribute('style')
+ const restoreBreaks: (() => void)[] = []
+ try {
+ // Keep the measuring column identical to the final printable column. The
+ // export stylesheet supplies the same typography and break rules in both.
+ Object.assign(article.style, {
+ display: 'block',
+ width: `${page.width}px`,
+ minWidth: '0',
+ maxWidth: 'none',
+ height: `${page.height}px`,
+ minHeight: '0',
+ maxHeight: 'none',
+ columnWidth: `${page.width}px`,
+ columnCount: '1',
+ columnGap: '0',
+ columnFill: 'auto',
+ margin: '0',
+ padding: '0',
+ overflow: 'visible'
+ })
+
+ // Explicit page breaks and page-only keep rules must also participate in
+ // this measurement. Restore each property separately so fitted image widths
+ // aren't rolled back along with these temporary changes.
+ for (const element of Array.from(article.querySelectorAll('*'))) {
+ const style = getComputedStyle(element)
+ for (const property of ['break-before', 'break-after', 'break-inside']) {
+ const value = style.getPropertyValue(property)
+ const replacement =
+ value === 'avoid-page'
+ ? 'avoid'
+ : FORCED_BREAKS.has(value) && value !== 'column' && value !== 'all'
+ ? 'column'
+ : null
+ if (!replacement) continue
+ const original = element.style.getPropertyValue(property)
+ const priority = element.style.getPropertyPriority(property)
+ element.style.setProperty(property, replacement, 'important')
+ restoreBreaks.push(() => {
+ if (original) element.style.setProperty(property, original, priority)
+ else element.style.removeProperty(property)
+ })
+ }
+ }
+
+ const origin = article.getBoundingClientRect()
+ const rtl = getComputedStyle(article).direction === 'rtl'
+ const pageOf = (rect: DOMRect): number =>
+ Math.floor(((rtl ? origin.right - rect.right : rect.left - origin.left) + 0.5) / page.width)
+
+ for (const image of images) {
+ if (!image.naturalWidth || !image.naturalHeight) continue
+ const figure = image.closest('figure')
+ const parent = image.parentElement
+ const block =
+ figure ??
+ (parent?.tagName === 'P' && parent.childElementCount === 1 && !parent.textContent?.trim()
+ ? parent
+ : null)
+ // Inline icons and figures containing multiple pictures aren't one image
+ // block; changing their dimensions could rearrange unrelated content.
+ if (!block || block.querySelectorAll('img').length !== 1) continue
+ let first: Element = block
+ while (first.previousElementSibling?.matches(HEADINGS)) {
+ if (!getComputedStyle(first.previousElementSibling).breakAfter.startsWith('avoid')) break
+ first = first.previousElementSibling
+ }
+ const previous = first.previousElementSibling
+ if (!previous) continue
+ if (
+ FORCED_BREAKS.has(getComputedStyle(first).breakBefore) ||
+ FORCED_BREAKS.has(getComputedStyle(previous).breakAfter)
+ )
+ continue
+
+ const precedingRects = previous.getClientRects()
+ const firstRects = first.getClientRects()
+ if (!precedingRects.length || !firstRects.length) continue
+ const targetPage = pageOf(precedingRects[precedingRects.length - 1])
+ if (pageOf(firstRects[0]) !== targetPage + 1) continue
+
+ const originalWidth = image.getBoundingClientRect().width
+ // Preserve small graphics at their chosen size. Large pictures can shrink
+ // at most by half, avoiding excessive reduction to fill a tiny remainder.
+ if (originalWidth < 160) continue
+ const savedWidth = image.style.getPropertyValue('width')
+ const savedPriority = image.style.getPropertyPriority('width')
+ const restoreWidth = (): void => {
+ if (savedWidth) image.style.setProperty('width', savedWidth, savedPriority)
+ else image.style.removeProperty('width')
+ }
+ const fits = (): boolean => {
+ const start = first.getClientRects()
+ const end = block.getClientRects()
+ return (
+ start.length > 0 &&
+ end.length > 0 &&
+ pageOf(start[0]) === targetPage &&
+ pageOf(end[end.length - 1]) === targetPage &&
+ end[end.length - 1].bottom <= origin.top + page.height
+ )
+ }
+
+ let fitted = false
+ try {
+ let low = originalWidth * MIN_IMAGE_SCALE
+ let high = originalWidth
+ image.style.width = `${low}px`
+ if (!fits()) continue
+ // Find the largest readable size that fits, including caption wrapping,
+ // heading spacing, borders, and collapsed margins measured by Chromium.
+ for (let attempt = 0; attempt < 10 && high - low > 0.5; attempt++) {
+ const width = (low + high) / 2
+ image.style.width = `${width}px`
+ if (fits()) low = width
+ else high = width
+ }
+ image.style.width = `${Math.max(originalWidth * MIN_IMAGE_SCALE, Math.floor(low) - 1)}px`
+ fitted = fits()
+ } finally {
+ if (!fitted) restoreWidth()
+ }
+ }
+ } finally {
+ for (const restore of restoreBreaks) restore()
+ if (originalStyle === null) article.removeAttribute('style')
+ else article.setAttribute('style', originalStyle)
+ }
+}
diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json
index 5b1fbdf4..9a38cbc7 100644
--- a/packages/bridge-contract/package.json
+++ b/packages/bridge-contract/package.json
@@ -1,7 +1,7 @@
{
"name": "@zennotes/bridge-contract",
"private": true,
- "version": "2.50.0",
+ "version": "2.50.1",
"type": "module",
"exports": {
"./bridge": "./src/bridge.ts",
diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json
index e2676c5d..4041f7db 100644
--- a/packages/shared-domain/package.json
+++ b/packages/shared-domain/package.json
@@ -1,7 +1,7 @@
{
"name": "@zennotes/shared-domain",
"private": true,
- "version": "2.50.0",
+ "version": "2.50.1",
"type": "module",
"exports": {
"./*": "./src/*.ts"
diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json
index 9dfe56c7..81187875 100644
--- a/packages/shared-ui/package.json
+++ b/packages/shared-ui/package.json
@@ -1,7 +1,7 @@
{
"name": "@zennotes/shared-ui",
"private": true,
- "version": "2.50.0",
+ "version": "2.50.1",
"type": "module",
"exports": {
".": "./src/index.ts"
diff --git a/tooling/scripts/pdf-export-smoke.md b/tooling/scripts/pdf-export-smoke.md
new file mode 100644
index 00000000..b62f0127
--- /dev/null
+++ b/tooling/scripts/pdf-export-smoke.md
@@ -0,0 +1,73 @@
+# Desktop PDF export test
+
+A sample note exported by the ZenNotes desktop renderer on September 14, 2026. Both versions use the same content: one has a white page, and the other uses the dark theme.
+
+This document exercises headings, long paragraphs, blockquotes, image captions, and images of different sizes. The green test images have borders and diagonal lines so clipping or distortion is easy to spot.
+
+## Paragraph flow
+
+A long note needs to remain comfortable to read when it becomes a printed document. The text should follow a consistent column from one page to the next. A paragraph may continue across a page boundary when there is more text than the current page can hold. The break should occur between complete lines, and the continuation should begin inside the top margin. The page background should extend behind the margins in the themed version, keeping the surrounding color consistent throughout the document.
+
+Headings provide landmarks in that flow. When there is enough space for a heading but too little space for the beginning of its section, the heading should move with the text that follows it. This prevents a reader from reaching a section label at the bottom of a page and having to turn the page before seeing what the section contains. Short paragraphs remain readable, while longer paragraphs can use the available space without being treated as a single indivisible block.
+
+The same principles apply to material quoted from another note. A long quotation may span more than one page, and its lines should remain inside the printable area. The indentation and background help distinguish the quotation from the surrounding text. They should not prevent the document from continuing naturally. Reading the end of one page and the start of the next should feel like following one continuous note, with no missing line or text pressed against the paper edge.
+
+## Result
+
+This heading should appear with the beginning of this paragraph. The section deliberately follows several paragraphs so the export must decide whether enough room remains for both the heading and its content.
+
+## Landscape image
+
+The following image has an explicit 1200 by 750 size hint. It should scale down to the reading column without retaining an oversized empty box around the visible picture.
+
+![[image.png|1200x750]]
+
+The image caption belongs with the image. When enough room remains to keep an image readable, it should scale proportionally to use that space. Its aspect ratio should remain unchanged, and all four edges should be visible.
+
+## A quotation across pages
+
+> A useful export preserves the order and meaning of the original note. A reader can move from an observation to its supporting explanation, then inspect the image that illustrates it. The document should not require the reader to guess which heading belongs to which paragraph or whether an image has been cut off. Consistent spacing helps the eye follow the sequence of the note, while the page margins keep text clear of the edges. These details matter most in longer documents, where the same patterns repeat over several pages and small inconsistencies become distracting. This quotation is intentionally long enough to exercise that continuation behavior. It remains one paragraph even though it contains several sentences. When it reaches a page boundary, the renderer should carry the remaining lines onto the next sheet without dropping content or reserving a large empty region just because the paragraph continues. The first line on the new page should sit inside the same top margin used elsewhere. The final sentence of the quotation marks the end of this test passage.
+
+## Follow-up text
+
+This section follows the quotation and should remain in document order. It provides another heading and paragraph pair for the renderer to place. The heading is useful only when readers can immediately see the content it introduces. If a page ends before both can fit, they should begin together on the next page.
+
+## Portrait image
+
+![[portrait.png]]
+
+## Small image
+
+![[small.png]]
+
+The small image above is only 80 by 40 pixels. It should stay small rather than stretching to the width of the document.
+
+## End of test
+
+Check that every image has its caption, the portrait shares a page with its heading, and the white or dark background remains consistent at each page edge.
+
+Paragraph continuity
+
+
+
+Successive images
+
+Successive image introduction stays with the first picture.
+
+
+
+![[sequence-first.png]]
+
+![[sequence-second.png]]
+
+The second picture should use its full reading-column width after the first picture has fitted on the preceding page.
+
+The forced break starts after this sentence.
+
+
+
+## Forced page begins here
+
+![[forced.png]]
+
+The explicit page break remains in effect even when the image would fit on the preceding page.
diff --git a/tooling/scripts/pdf-export-smoke.py b/tooling/scripts/pdf-export-smoke.py
new file mode 100644
index 00000000..0fc4b264
--- /dev/null
+++ b/tooling/scripts/pdf-export-smoke.py
@@ -0,0 +1,299 @@
+#!/usr/bin/env python3
+"""Exercise PDF pagination in the actual built Electron export renderer.
+
+Requires the repository's npm dependencies, Python 3, pdfplumber and Pillow:
+ python3 -m pip install pdfplumber Pillow
+ python3 tooling/scripts/pdf-export-smoke.py
+ python3 tooling/scripts/pdf-export-smoke.py --skip-build --output-dir output/pdf/smoke
+
+The fixture bridge supplies an isolated note and generated images; all Markdown
+rendering, export preparation and printToPDF pagination run in the application.
+Only the temporary Electron profile is modified. PDFs and page contact sheets
+are retained in the output directory, which defaults to a new temporary folder.
+On Linux, run under a graphical session (or xvfb-run).
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import logging
+import os
+from pathlib import Path
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+
+try:
+ import pdfplumber
+ from PIL import Image, ImageDraw
+except ImportError:
+ sys.exit("Install test dependencies with: python3 -m pip install pdfplumber Pillow")
+
+
+ROOT = Path(__file__).resolve().parents[2]
+FIXTURE = Path(__file__).with_suffix(".md")
+# Unique intrinsic dimensions identify the fixture images in the final PDF.
+IMAGES = {
+ "image.png": (1200, 750),
+ "portrait.png": (800, 1600),
+ "small.png": (80, 40),
+ "sequence-first.png": (1100, 650),
+ "sequence-second.png": (1000, 750),
+ "forced.png": (1200, 400),
+}
+MARGIN = 0.7 * 72
+PRINTABLE_WIDTH = 7.1 * 72
+CONTINUITY = "Paragraph continuity starts here. " + " ".join(
+ f"Sentence {index:03d} stays in order through every page break." for index in range(72)
+) + " Paragraph continuity ends here."
+
+# Electron's bundled fonts omit an optional FontBBox; pdfminer otherwise warns
+# once per font per page while successfully extracting their text geometry.
+logging.getLogger("pdfminer").setLevel(logging.ERROR)
+
+PRELOAD = r"""
+const { contextBridge, ipcRenderer } = require('electron');
+const fixture = ipcRenderer.sendSync('pdf-smoke-fixture');
+localStorage.setItem('zen:prefs:v2', JSON.stringify({
+ pdfExportUseTheme: fixture.themed, themeId: 'github-dark', themeMode: 'dark',
+ editorFontSize: 16, editorLineHeight: 1.7
+}));
+contextBridge.exposeInMainWorld('zen', {
+ getAppInfo: () => ({ runtime: 'desktop', platform: process.platform }),
+ getCurrentVault: async () => ({ root: '/pdf-smoke-fixture' }),
+ listNotes: async () => [],
+ listAssets: async () => Object.keys(fixture.images).map(path => ({ path })),
+ readNote: async () => ({ path: 'pagination.md', title: 'Pagination', body: fixture.markdown }),
+ listOverrides: async () => [],
+ resolveVaultAssetUrl: (_vault, path) => fixture.images[path],
+ resolveLocalAssetUrl: (_vault, _note, path) => fixture.images[path]
+});
+"""
+
+HARNESS = r"""
+const { app, BrowserWindow, ipcMain } = require('electron');
+const fs = require('node:fs');
+const path = require('node:path');
+const config = JSON.parse(fs.readFileSync(path.join(__dirname, 'config.json'), 'utf8'));
+app.setPath('userData', path.join(__dirname, 'user-data'));
+app.on('window-all-closed', () => {});
+let fixture;
+ipcMain.on('pdf-smoke-fixture', event => { event.returnValue = fixture; });
+// A renderer that never reaches ready must fail rather than hang a test run.
+const timeout = setTimeout(() => { console.error('PDF smoke test timed out'); app.exit(1); }, 60000);
+app.whenReady().then(async () => {
+ for (const themed of [false, true]) {
+ fixture = { themed, markdown: config.markdown, images: config.images };
+ const window = new BrowserWindow({
+ show: false, width: 1024, height: 1400,
+ webPreferences: { preload: path.join(__dirname, 'preload.cjs'), sandbox: false,
+ contextIsolation: true, nodeIntegration: false }
+ });
+ const errors = [];
+ window.webContents.on('console-message', ({ level, message }) => {
+ // Chromium emits this for the application's normal file:// meta CSP.
+ const metaCspWarning = "The Content Security Policy directive 'frame-ancestors' is ignored when delivered via a element.";
+ if (level === 'error' && message !== metaCspWarning) errors.push(message);
+ });
+ try {
+ await window.loadFile(config.renderer, { query: { exportNote: 'pagination.md' } });
+ const deadline = Date.now() + 20000;
+ while (true) {
+ const state = await window.webContents.executeJavaScript(`({
+ state: document.body.dataset.exportState, error: document.body.dataset.exportError
+ })`);
+ if (state.state === 'ready') break;
+ if (state.state === 'error' || Date.now() > deadline) throw Error(JSON.stringify(state));
+ await new Promise(resolve => setTimeout(resolve, 50));
+ }
+ await window.webContents.executeJavaScript('document.fonts.ready.then(() => true)');
+ const restoredLayout = await window.webContents.executeJavaScript(`(() => {
+ const prose = document.querySelector('.prose-zen');
+ if (!prose) return false;
+ const style = getComputedStyle(prose);
+ return style.columnCount === 'auto' && style.columnWidth === 'auto' &&
+ [prose, ...prose.querySelectorAll('*')].every(element => {
+ const current = getComputedStyle(element);
+ return current.breakBefore !== 'column' && current.breakAfter !== 'column' &&
+ current.breakInside !== 'avoid-column';
+ });
+ })()`);
+ if (!restoredLayout) throw Error('Temporary pagination styles leaked into the print layout');
+ if (errors.length) throw Error(errors.join('\n'));
+ const theme = themed ? 'dark' : 'light';
+ // Keep these options aligned with exportNoteToPdf in desktop src/main/index.ts.
+ fs.writeFileSync(path.join(config.output, `${theme}.pdf`),
+ await window.webContents.printToPDF({ printBackground: true, preferCSSPageSize: true }));
+ } finally {
+ window.destroy();
+ }
+ }
+ clearTimeout(timeout);
+ app.quit();
+}).catch(error => { console.error(error); app.exit(1); });
+"""
+
+
+def generate_images(directory: Path) -> dict[str, str]:
+ import base64
+
+ result = {}
+ for name, (width, height) in IMAGES.items():
+ picture = Image.new("RGB", (width, height), (181, 232, 207))
+ draw = ImageDraw.Draw(picture)
+ stroke = max(2, min(width, height) // 80)
+ draw.rectangle((0, 0, width - 1, height - 1), outline=(15, 108, 68), width=stroke)
+ draw.line((0, 0, width - 1, height - 1), fill=(15, 108, 68), width=stroke)
+ draw.line((0, height - 1, width - 1, 0), fill=(15, 108, 68), width=stroke)
+ picture.save(directory / name)
+ result[name] = "data:image/png;base64," + base64.b64encode((directory / name).read_bytes()).decode()
+ return result
+
+
+def verify_pdf(path: Path) -> list[str]:
+ errors = []
+
+ def check(condition: bool, message: str) -> None:
+ if not condition:
+ errors.append(f"{path.stem}: {message}")
+
+ with pdfplumber.open(path) as pdf:
+ texts = [re.sub(r"\s+", " ", page.extract_text() or "") for page in pdf.pages]
+
+ def page_of(text: str) -> int:
+ hits = [index for index, content in enumerate(texts) if text in content]
+ check(len(hits) == 1, f"expected one occurrence of {text!r}, got pages {hits}")
+ return hits[0] if hits else -1
+
+ images = {}
+ for name, size in IMAGES.items():
+ hits = [(index, item) for index, page in enumerate(pdf.pages)
+ for item in page.images if tuple(item["srcsize"]) == size]
+ check(len(hits) == 1, f"expected one intact {name}, found {len(hits)}")
+ if len(hits) != 1:
+ continue
+ page_index, item = images[name] = hits[0]
+ expected_ratio = size[0] / size[1]
+ check(abs(item["width"] / item["height"] - expected_ratio) < 0.01,
+ f"{name} aspect ratio changed")
+ check(page_of(name) == page_index, f"{name} caption separated from image")
+ check(item["x0"] >= MARGIN - 1 and item["x1"] <= 612 - MARGIN + 1
+ and item["top"] >= MARGIN - 1 and item["bottom"] <= 792 - MARGIN + 1,
+ f"{name} crosses printable page margins")
+
+ landscape_intro = page_of("The following image has an explicit 1200 by 750 size hint.")
+ if "image.png" in images:
+ image_page, item = images["image.png"]
+ check(image_page == landscape_intro,
+ f"landscape image left its introduction on page {landscape_intro + 1} "
+ f"and moved to page {image_page + 1}, leaving the reported blank gap")
+ check(item["width"] >= PRINTABLE_WIDTH * 0.5 - 2,
+ "landscape image became too small to remain readable")
+ if "portrait.png" in images:
+ check(page_of("Portrait image") == images["portrait.png"][0],
+ "portrait heading stranded on preceding page")
+ if "small.png" in images:
+ check(images["small.png"][1]["width"] <= 80 * 0.75 + 1,
+ "small image enlarged beyond its intrinsic size")
+ if "sequence-first.png" in images and "sequence-second.png" in images:
+ first_page, first = images["sequence-first.png"]
+ second_page, second = images["sequence-second.png"]
+ check(first_page == page_of("Successive image introduction stays with the first picture."),
+ "first successive image left usable space on its introduction page")
+ check(second_page == first_page + 1, "second successive image is on the wrong page")
+ check(first["width"] < PRINTABLE_WIDTH * 0.95, "successive fixture did not exercise shrinking")
+ check(second["width"] >= PRINTABLE_WIDTH - 3,
+ "second image was unnecessarily shrunk after earlier pagination changed")
+ forced_page = page_of("Forced page begins here")
+ check(forced_page > page_of("The forced break starts after this sentence."),
+ "explicit page break was ignored")
+ if "forced.png" in images:
+ check(images["forced.png"][0] == forced_page,
+ "forced-break image did not stay with its section")
+
+ # Text geometry catches clipped lines at either edge on continuation pages.
+ for index, page in enumerate(pdf.pages):
+ chars = [char for char in page.chars if char["text"].strip()]
+ check(bool(chars), f"page {index + 1} is blank")
+ check(all(char["x0"] >= MARGIN - 1 and char["x1"] <= page.width - MARGIN + 1
+ and char["top"] >= MARGIN - 1 and char["bottom"] <= page.height - MARGIN + 1
+ for char in chars), f"text is clipped beyond page {index + 1} margins")
+
+ # A long paragraph must continue without losing words, reserving a huge bottom
+ # gap, or leaving an isolated final line on the following page.
+ start = page_of("Paragraph continuity starts here.")
+ end = page_of("Paragraph continuity ends here.")
+ check(start >= 0 and end > start, "fixture did not exercise paragraph continuation")
+ check(CONTINUITY in " ".join(texts), "paragraph text was dropped, duplicated, or reordered")
+ if start >= 0 and end > start:
+ for index in range(start, end):
+ last_bottom = max(char["bottom"] for char in pdf.pages[index].chars)
+ check(last_bottom > 792 - MARGIN - 45,
+ f"paragraph continuation left a large bottom gap on page {index + 1}")
+ end_lines = pdf.pages[end].extract_text_lines()
+ paragraph_end = next((i for i, line in enumerate(end_lines)
+ if "continuity ends here." in line["text"]), -1)
+ check(paragraph_end >= 1, "paragraph continuation left fewer than two lines")
+
+ # Render the real PDFs for human review and verify the page background.
+ thumbnails = []
+ for index, page in enumerate(pdf.pages):
+ rendered = page.to_image(resolution=72).original.convert("RGB")
+ background = rendered.getpixel((10, 10))
+ check((max(background) < 100) if path.stem == "dark" else (min(background) > 240),
+ f"wrong background in page {index + 1} margin: {background}")
+ rendered.thumbnail((306, 396))
+ thumbnails.append(rendered)
+ sheet = Image.new("RGB", (326 * 3, 420 * ((len(thumbnails) + 2) // 3)), (205, 205, 205))
+ draw = ImageDraw.Draw(sheet)
+ for index, thumbnail in enumerate(thumbnails):
+ x, y = (index % 3) * 326 + 10, (index // 3) * 420 + 5
+ sheet.paste(thumbnail, (x, y))
+ draw.text((x, y + 399), f"Page {index + 1}", fill=(20, 20, 20))
+ sheet.save(path.with_suffix(".png"))
+ print(f"{path.name}: {len(pdf.pages)} pages, {sum(len(page.images) for page in pdf.pages)} images")
+ return errors
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument("--skip-build", action="store_true", help="reuse the existing desktop renderer build")
+ parser.add_argument("--output-dir", type=Path, help="retain exported PDFs and contact sheets here")
+ args = parser.parse_args()
+ output = (args.output_dir or Path(tempfile.mkdtemp(prefix="zennotes-pdf-export-smoke-"))).resolve()
+ output.mkdir(parents=True, exist_ok=True)
+ if not args.skip_build:
+ subprocess.run([shutil.which("npm") or "npm", "run", "build", "--workspace", "@zennotes/desktop"],
+ cwd=ROOT, check=True, timeout=300)
+ renderer = ROOT / "apps/desktop/out/renderer/index.html"
+ if not renderer.is_file():
+ sys.exit("Desktop renderer is missing. Run without --skip-build.")
+ electron = subprocess.check_output([shutil.which("node") or "node", "-p", "require('electron')"],
+ cwd=ROOT / "apps/desktop", text=True, timeout=15).strip()
+ with tempfile.TemporaryDirectory(prefix="zennotes-pdf-export-profile-") as temporary:
+ work = Path(temporary)
+ (work / "preload.cjs").write_text(PRELOAD)
+ (work / "export.cjs").write_text(HARNESS)
+ (work / "config.json").write_text(json.dumps({
+ "markdown": FIXTURE.read_text().replace("", CONTINUITY),
+ "images": generate_images(work),
+ "renderer": str(renderer), "output": str(output),
+ }))
+ environment = dict(os.environ)
+ environment.pop("ELECTRON_RUN_AS_NODE", None)
+ subprocess.run([electron, str(work / "export.cjs")], cwd=ROOT, env=environment,
+ check=True, timeout=90)
+ errors = [error for theme in ("light", "dark") for error in verify_pdf(output / f"{theme}.pdf")]
+ print(f"PDFs and contact sheets: {output}")
+ if errors:
+ print("\n".join(f"FAIL: {error}" for error in errors), file=sys.stderr)
+ return 1
+ print("PASS: image placement, successive pagination, forced breaks, captions, geometry, and paragraph flow")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())