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
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
68 changes: 44 additions & 24 deletions apps/desktop/src/renderer/export-window.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -347,7 +345,12 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element {
<>
<style>{`
@page {
margin: ${pageMargin};
size: Letter;
margin: ${PDF_PAGE_MARGIN_PX}px;
/* Paint the margins as well as the content. Document padding only
insets the first/last page, leaving continuation pages flush with
the paper edge; real page margins repeat on every sheet. */
background: ${pageBg};
}
html,
body,
Expand Down Expand Up @@ -378,17 +381,31 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element {
print-color-adjust: exact;
}
.export-note-shell .prose-zen {
padding: 32px 40px 48px;
/* Measure images and diagrams at the same column width as print. */
width: ${PDF_PRINTABLE_WIDTH};
max-width: ${PDF_PRINTABLE_WIDTH};
padding: 0;
margin: 0;
orphans: 2;
widows: 2;
}
.export-note-shell .prose-zen :is(h1, h2, h3, h4, h5, h6) {
break-inside: avoid;
break-after: avoid;
}
.export-note-shell .local-image-embed,
.export-note-shell img {
break-inside: avoid;
}
/* Keep tall (portrait) images within the printable page height so they
scale down proportionally instead of overflowing the page and being
clipped at the page boundary — a single <img> can't paginate (#231).
Letter page height is 11in - 2 * 0.7in margins = 9.6in; cap a touch
under that so an image still fits below a heading/caption. */
under that to leave space for the image frame and caption. */
.export-note-shell img {
max-width: 100%;
height: auto;
max-height: 9.3in;
max-height: 9in;
object-fit: contain;
}
/* A standalone local image is rendered as a .local-image-embed figure
Expand All @@ -413,7 +430,7 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element {
width: auto;
height: auto;
max-width: 100%;
max-height: 9.3in;
max-height: 9in;
}
@media print {
html,
Expand All @@ -428,21 +445,14 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element {
min-height: auto;
overflow: visible;
box-sizing: border-box;
/* With @page margin dropped for themed (full-bleed) exports, the
content inset comes from padding here instead — so the theme
background reaches the paper edge. 0 for the light export. */
padding: ${contentInset};
padding: 0;
}
.export-note-shell .prose-zen {
max-width: none;
width: 100%;
padding: 0;
margin: 0;
}
.export-note-shell img {
max-height: 9.3in;
break-inside: avoid;
}
}
`}</style>
<main className="export-note-shell">
Expand All @@ -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<HTMLElement>('[data-preview-content]')
if (article) {
fitExportImagesToPages(article, {
width: PDF_PRINTABLE_WIDTH_PX,
height: PDF_PRINTABLE_HEIGHT_PX
})
}
setExportState('ready')
})
}}
/>
</main>
Expand Down
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
18 changes: 9 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion packages/app-core/package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
68 changes: 67 additions & 1 deletion packages/app-core/src/lib/export-images.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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')
})
})
39 changes: 39 additions & 0 deletions packages/app-core/src/lib/export-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLImageElement>('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
Expand Down
Loading