From 1e76b690d9bfda9570a2cf57193b34e91af11786 Mon Sep 17 00:00:00 2001 From: Wqrld Date: Mon, 6 Jul 2026 11:29:56 +0200 Subject: [PATCH] fix(xl-pdf-exporter): keep image aspect ratio, alignment and page fit in PDF export (#2866) Images in PDF exports were previously given only a fixed width, so oversized bitmaps could distort, overflow the page horizontally, or spill an empty trailing page when taller than the page (images render in an unbreakable group). - Clamp the image box with maxWidth: 100% and a maxHeight cap derived from the page height, page padding, per-block padding, and one reserved caption line. - Use objectFit: contain + objectPosition so a clamped bitmap keeps its aspect ratio and hugs the edge matching the block's textAlignment. - Clamp captions with maxWidth: 100% so they wrap instead of overflowing. - Teach the shared test file resolver to serve placehold.co URLs at the dimensions requested in the URL (patched into the placeholder JPEG's SOF0 marker), failing loudly if the marker is missing. Known limitations (documented in code): multi-line captions and in-flow headers can still make a page-height image group spill. Co-Authored-By: Claude Fable 5 --- .../src/pdf/__snapshots__/example.jsx | 12 +++ .../exampleWithAlignedImages.jsx | 87 ++++++++++++++++++ .../exampleWithHeaderAndFooter.jsx | 12 +++ .../src/pdf/defaultSchema/blocks.tsx | 68 ++++++++++++-- .../src/pdf/pdfExporter.test.tsx | 92 ++++++++++++++++++- .../xl-pdf-exporter/src/pdf/pdfExporter.tsx | 8 +- shared/util/testFileResolver.ts | 61 +++++++++--- 7 files changed, 318 insertions(+), 22 deletions(-) create mode 100644 packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithAlignedImages.jsx diff --git a/packages/xl-pdf-exporter/src/pdf/__snapshots__/example.jsx b/packages/xl-pdf-exporter/src/pdf/__snapshots__/example.jsx index 71684acf42..4615c01c48 100644 --- a/packages/xl-pdf-exporter/src/pdf/__snapshots__/example.jsx +++ b/packages/xl-pdf-exporter/src/pdf/__snapshots__/example.jsx @@ -650,12 +650,17 @@ @@ -678,6 +683,10 @@ @@ -719,6 +728,7 @@ @@ -762,6 +772,7 @@ @@ -818,6 +829,7 @@ diff --git a/packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithAlignedImages.jsx b/packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithAlignedImages.jsx new file mode 100644 index 0000000000..cc28f3e4d6 --- /dev/null +++ b/packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithAlignedImages.jsx @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithHeaderAndFooter.jsx b/packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithHeaderAndFooter.jsx index dc11a90c93..cba9fabcd7 100644 --- a/packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithHeaderAndFooter.jsx +++ b/packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithHeaderAndFooter.jsx @@ -658,12 +658,17 @@ @@ -686,6 +691,10 @@ @@ -727,6 +736,7 @@ @@ -770,6 +780,7 @@ @@ -826,6 +837,7 @@ diff --git a/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx b/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx index 34985a1753..b1b054db00 100644 --- a/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx +++ b/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx @@ -15,9 +15,16 @@ import { ListItem, } from "../util/listItem.js"; import { Table } from "../util/table/Table.js"; +import { Style } from "../types.js"; +import { + BLOCK_VERTICAL_PADDING, + PAGE_HEIGHT, + type PDFExporter, +} from "../pdfExporter.js"; const PIXELS_PER_POINT = 0.75; const FONT_SIZE = 16; +const CAPTION_FONT_SIZE = FONT_SIZE * 0.8 * PIXELS_PER_POINT; export const pdfBlockMappingForDefaultSchema: BlockMapping< DefaultBlockSchema & { @@ -237,11 +244,7 @@ export const pdfBlockMappingForDefaultSchema: BlockMapping< {caption(block.props, t)} @@ -277,6 +280,54 @@ function file( ); } +function previewWidthToPoints( + props: Partial, +): number | undefined { + return props.previewWidth ? props.previewWidth * PIXELS_PER_POINT : undefined; +} + +// Style values can also be strings ("10pt", "5%"); the page styles set in +// `PDFExporter` are plain numbers, so treat anything else as the fallback. +function points(value: number | string | undefined, fallback: number): number { + return typeof value === "number" ? value : fallback; +} + +function imageStyle( + props: Partial, + exporter: any, +): Style { + const page: Style = + (exporter as PDFExporter).styles?.page ?? {}; + return { + width: previewWidthToPoints(props), + maxWidth: "100%", + // Images can't break across pages, so cap the box to the usable page + // height (accounting for the padding `transformBlocks` adds around every + // block); a taller box would spill an empty page after the bitmap. If + // there's a caption, also reserve one line for it. Known limitations: + // captions long enough to wrap, and headers passed to + // `toReactPDFDocument` (which take flow height on every page), can still + // make the unbreakable image group spill. + maxHeight: + PAGE_HEIGHT - + points(page.paddingTop, 0) - + points(page.paddingBottom, 0) - + 2 * BLOCK_VERTICAL_PADDING - + (props.caption ? CAPTION_FONT_SIZE * points(page.lineHeight, 1.5) : 0), + // `contain` leaves slack inside the box when it's clamped by `maxWidth` + // or `maxHeight`, so pin the bitmap to the block's alignment edge. The + // box itself is aligned by `blocknoteDefaultPropsToReactPDFStyle` on the + // wrapper. + objectFit: "contain", + objectPosition: + props.textAlignment === "right" + ? "100% 0%" + : props.textAlignment === "center" + ? "50% 0%" + : "0% 0%", + }; +} + function caption( props: Partial, _exporter: any, @@ -288,10 +339,9 @@ function caption( {props.caption} diff --git a/packages/xl-pdf-exporter/src/pdf/pdfExporter.test.tsx b/packages/xl-pdf-exporter/src/pdf/pdfExporter.test.tsx index 738e931a1e..c4eea0c237 100644 --- a/packages/xl-pdf-exporter/src/pdf/pdfExporter.test.tsx +++ b/packages/xl-pdf-exporter/src/pdf/pdfExporter.test.tsx @@ -9,8 +9,9 @@ import { defaultStyleSpecs, } from "@blocknote/core"; import { ColumnBlock, ColumnListBlock } from "@blocknote/xl-multi-column"; -import { Text } from "@react-pdf/renderer"; +import { renderToBuffer, Text } from "@react-pdf/renderer"; import { testDocument } from "@shared/testDocument.js"; +import { testResolveFileUrl } from "@shared/util/testFileResolver.js"; import reactElementToJSXString from "react-element-to-jsx-string"; import { describe, expect, it } from "vite-plus/test"; import { pdfDefaultSchemaMappings } from "./defaultSchema/index.js"; @@ -224,6 +225,95 @@ describe("exporter", () => { // `${__dirname}/exampleWithHeaderAndFooter.pdf` // ); }); + it("should keep image alignment when the image box is clamped", async () => { + // `objectPosition` must follow `textAlignment`, otherwise images with + // slack in their box (`objectFit: "contain"` + `maxWidth` clamp) would + // always be pinned to the left edge. + const schema = BlockNoteSchema.create(); + const exporter = new PDFExporter(schema, pdfDefaultSchemaMappings, { + resolveFileUrl: testResolveFileUrl, + }); + const transformed = await exporter.toReactPDFDocument( + partialBlocksToBlocksForTesting(schema, [ + { + type: "image", + props: { + url: "https://placehold.co/332x322.jpg", + // Wider than the page, so `maxWidth: 100%` clamps the box. + previewWidth: 2000, + textAlignment: "left", + }, + }, + { + type: "image", + props: { + url: "https://placehold.co/332x322.jpg", + previewWidth: 2000, + textAlignment: "center", + }, + }, + { + type: "image", + props: { + url: "https://placehold.co/332x322.jpg", + previewWidth: 2000, + textAlignment: "right", + }, + }, + ]), + ); + const str = reactElementToJSXString(transformed); + + await expect(str).toMatchFileSnapshot( + "__snapshots__/exampleWithAlignedImages.jsx", + ); + }); + + it("should scale an image taller than the page instead of spilling a blank page", async () => { + // The image box's height comes from the bitmap's aspect ratio, and images + // can't break across pages, so without a `maxHeight` cap a too-tall box + // spills an empty trailing page. + const schema = BlockNoteSchema.create(); + const exporter = new PDFExporter(schema, pdfDefaultSchemaMappings, { + resolveFileUrl: testResolveFileUrl, + }); + const transformed = await exporter.toReactPDFDocument( + partialBlocksToBlocksForTesting(schema, [ + { + type: "image", + props: { url: "https://placehold.co/400x800.jpg", previewWidth: 600 }, + }, + ]), + ); + const pdf = (await renderToBuffer(transformed as any)).toString("latin1"); + + expect(pdf.match(/\/Type\s*\/Page[^s]/g)).toHaveLength(1); + }); + + it("should keep a page-height image and its caption on one page", async () => { + // A caption is rendered inside the same unbreakable group as the image, + // so the height cap must reserve a line for it. + const schema = BlockNoteSchema.create(); + const exporter = new PDFExporter(schema, pdfDefaultSchemaMappings, { + resolveFileUrl: testResolveFileUrl, + }); + const transformed = await exporter.toReactPDFDocument( + partialBlocksToBlocksForTesting(schema, [ + { + type: "image", + props: { + url: "https://placehold.co/400x800.jpg", + previewWidth: 600, + caption: "A tall image", + }, + }, + ]), + ); + const pdf = (await renderToBuffer(transformed as any)).toString("latin1"); + + expect(pdf.match(/\/Type\s*\/Page[^s]/g)).toHaveLength(1); + }); + it("should export a document with a multi-column block", async () => { const schema = BlockNoteSchema.create({ blockSpecs: { diff --git a/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx b/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx index 87ef5b8ca5..34adb6365d 100644 --- a/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx +++ b/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx @@ -29,6 +29,12 @@ import { Style } from "./types.js"; const FONT_SIZE = 16; const PIXELS_PER_POINT = 0.75; +// Height in points of the page rendered by `toReactPDFDocument`; keep in sync +// with the `` element there. +export const PAGE_HEIGHT = 841.89; +// Vertical padding `transformBlocks` adds around every block. +export const BLOCK_VERTICAL_PADDING = 3 * PIXELS_PER_POINT; + type Options = ExporterOptions & { /** * @@ -159,7 +165,7 @@ export class PDFExporter< x`) + * by patching them into the placeholder's SOF0 marker. It falls back to + * fetching for any other URL. */ export async function testResolveFileUrl(url: string): Promise { if (url.includes("placehold.co")) { - return new Blob([base64ToArrayBuffer(PLACEHOLDER_332x322_JPEG_BASE64)], { - type: "image/jpeg", - }); + const bytes = new Uint8Array( + base64ToArrayBuffer(PLACEHOLDER_332x322_JPEG_BASE64), + ); + // URLs of the form `.../x` get the requested dimensions + // patched in; any other placehold.co URL keeps the original 332x322 so it + // still never touches the network. + const dimensions = url.match(/placehold\.co\/(\d+)x(\d+)/); + if (dimensions) { + patchJpegDimensions( + bytes, + parseInt(dimensions[1]), + parseInt(dimensions[2]), + ); + } + return new Blob([bytes], { type: "image/jpeg" }); } // For any non-image/unknown URL, return it unchanged so the caller can fetch // it. In practice exporter tests only resolve the placeholder image above. return url; } + +/** + * Writes the given dimensions into the JPEG's SOF0 segment. The SOF0 segment + * (FF C0) stores height and width as big-endian u16 at offsets 5 and 7 after + * the marker. + */ +function patchJpegDimensions(bytes: Uint8Array, width: number, height: number) { + if (width > 0xffff || height > 0xffff) { + throw new Error( + `JPEG dimensions must fit in a u16, got ${width}x${height}`, + ); + } + for (let i = 0; i < bytes.length - 8; i++) { + if (bytes[i] === 0xff && bytes[i + 1] === 0xc0) { + bytes[i + 5] = height >> 8; + bytes[i + 6] = height & 0xff; + bytes[i + 7] = width >> 8; + bytes[i + 8] = width & 0xff; + return; + } + } + // Fail loudly: silently returning the unpatched 332x322 image would make + // dimension-dependent snapshots fail (or pass with wrong data) far away + // from this file. + throw new Error("No SOF0 (FF C0) marker found in the placeholder JPEG"); +}