From cc14f64786daf5f460144739b4e4f89177e6717d Mon Sep 17 00:00:00 2001
From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com>
Date: Fri, 2 Oct 2026 18:04:28 +0200
Subject: [PATCH 01/17] feat: :sparkles: add support for lazier-loading
---
README.md | 78 +++++++
cypress/integration/api.spec.ts | 2 +
cypress/integration/pdf.spec.tsx | 29 +++
eslint.config.ts | 1 +
src/hooks/usePDFDocument.spec.tsx | 177 ++++++++++++++
src/hooks/usePDFDocument.ts | 240 +++++++++++++++++++
src/index.ts | 29 +++
src/utils/getImagesFromPDF.ts | 62 ++---
src/utils/getPDFPageCount.ts | 18 +-
src/utils/pdf.spec.ts | 190 +++++++++++++++
src/utils/pdf.ts | 368 +++++++++++++++++++++++++++++-
11 files changed, 1144 insertions(+), 50 deletions(-)
create mode 100644 src/hooks/usePDFDocument.spec.tsx
create mode 100644 src/hooks/usePDFDocument.ts
create mode 100644 src/utils/pdf.spec.ts
diff --git a/README.md b/README.md
index 3376ce3..21c2c48 100644
--- a/README.md
+++ b/README.md
@@ -91,6 +91,84 @@ function App() {
- **`style`** : style object to change container css properties
- **`className`** : apply a className to the control
+## PDF documents
+
+PDF pages are rendered in the browser with [pdf.js](https://mozilla.github.io/pdf.js/) (loaded lazily, in a Web Worker, the first time a PDF is processed). Pages are rendered **on demand**, so large documents can be displayed progressively instead of being rasterized up front.
+
+### `usePDFDocument` hook
+
+The simplest way to display a PDF in a React component:
+
+```jsx
+import { AnnotationViewer, usePDFDocument } from 'react-mindee-js';
+
+function Document({ file }) {
+ const { status, numPages, pages, loading, loadPage, error } = usePDFDocument(
+ file,
+ { prefetch: 5, batch: 3 },
+ );
+ const [current, setCurrent] = useState(0);
+
+ if (status === 'error') return
{error.message}
;
+ if (status !== 'ready') return Opening…
;
+
+ return (
+ <>
+
+ {pages.map((image, index) => (
+ {
+ setCurrent(index);
+ loadPage(index);
+ }}
+ >
+ {loading.has(index) ? '…' : index + 1}
+
+ ))}
+
+ {pages[current] && (
+
+ )}
+ >
+ );
+}
+```
+
+- **`source`** : a URL / data URL string, `Blob`/`File`, `ArrayBuffer` or `Uint8Array`. Keep it referentially stable between renders; a new value closes the previous document and opens the new one.
+- **`options.prefetch`** (default `5`) : pages rendered as soon as the document opens.
+- **`options.batch`** (default `3`) : pages rendered together when `loadPage` asks for one that is not rendered yet.
+- **`options.resolution`** : target pixel count of each render (default 1.5 Mpx; use ~50 000 for thumbnails).
+- **`options.maxPages`** : reject documents with more pages (`TooManyPagesError`).
+
+The hook returns `status` (`idle` | `opening` | `ready` | `error`), `numPages`, `pages` (a **0-based** array with `undefined` for pages not rendered yet), `loading` (set of page indexes being rendered), `loadPage(index)`, `loadRange(from, to)`, `error` and the underlying `document` handle. Call `loadPage(index)` whenever a page comes into view: it is idempotent, renders that page with high priority and queues the rest of its batch. The document and every image URL it produced are released automatically when `source` changes or the component unmounts.
+
+### `openPDF` (framework-agnostic)
+
+For custom scheduling, use the handle directly. Page numbers are **1-based**, like pdf.js.
+
+```js
+import { openPDF } from 'react-mindee-js';
+
+const doc = await openPDF(file, { maxPages: 100 });
+doc.numPages; // 5
+
+const first = await doc.getPage(1, { priority: 'high' }); // blob: URL
+for await (const { pageNumber, image } of doc.getPages(2, 4)) {
+ // inclusive range, yielded as soon as each page is ready
+}
+
+await doc.destroy(); // revokes every blob: URL handed out by this handle
+```
+
+`getPage` / `getPages` options: `resolution`, `output` (`'object-url'` default, or `'data-url'` for a base64 string), `priority` (`'high'` jumps ahead of pending renders, `'low'` appends) and `signal` (`AbortSignal`; rejects with an `AbortError`). Renders are cached per page and resolution for the lifetime of the handle.
+
+Images are `blob:` object URLs by default: they only live in the current tab until `destroy()`. To upload one, read it back first (`await fetch(url).then((r) => r.blob())`), or request `{ output: 'data-url' }`.
+
+### `getPDFPageCount` / `getImagesFromPDF`
+
+`getPDFPageCount(source)` returns the page count without rendering. `getImagesFromPDF` still works but is **deprecated**: it renders and holds every page in memory at once. Prefer `usePDFDocument` or `openPDF`.
+
## Browser support
React mindee supports all recent browsers and works where React works. However, you may need check the [SSR](/docs/ssr) section.
diff --git a/cypress/integration/api.spec.ts b/cypress/integration/api.spec.ts
index ac038b2..a016afc 100644
--- a/cypress/integration/api.spec.ts
+++ b/cypress/integration/api.spec.ts
@@ -15,8 +15,10 @@ describe('built library public API', () => {
'getImagesFromPDF',
'getPDFPageCount',
'getZoomScale',
+ 'openPDF',
'setShapeConfig',
'toBase64',
+ 'usePDFDocument',
];
it('exposes exactly the documented runtime exports, all of them functions', () => {
diff --git a/cypress/integration/pdf.spec.tsx b/cypress/integration/pdf.spec.tsx
index 7cee748..9e6499c 100644
--- a/cypress/integration/pdf.spec.tsx
+++ b/cypress/integration/pdf.spec.tsx
@@ -130,4 +130,33 @@ describe('built library PDF support', () => {
cy.wrap(getPDFPageCount(multiPage)).should('equal', PAGE_COUNT);
});
});
+
+ it('renders pages on demand through openPDF with the inlined worker', () => {
+ cy.window().then((win) => {
+ cy.spy(win, 'Worker').as('worker');
+ });
+ loadDist({ fresh: true }).then(({ openPDF }) => {
+ cy.then(async () => {
+ const handle = await openPDF(multiPage);
+ const image = await handle.getPage(1);
+ const size = await imageDimensions(image);
+ await handle.destroy();
+ return { numPages: handle.numPages, image, size };
+ }).should(({ numPages, image, size }) => {
+ expect(numPages).to.equal(PAGE_COUNT);
+ expect(image).to.match(/^blob:/);
+ expect(size.width * size.height).to.be.greaterThan(0);
+ });
+ });
+ cy.get('@worker').should((spy) => {
+ expect(spy.calledOnce).to.equal(true);
+ expect(String(spy.firstCall.args[0])).to.match(/^blob:/);
+ });
+ });
+
+ it('exposes usePDFDocument from the built entry point', () => {
+ loadDist().then(({ usePDFDocument }) => {
+ expect(usePDFDocument).to.be.a('function');
+ });
+ });
});
diff --git a/eslint.config.ts b/eslint.config.ts
index 6c97a3b..4d835d3 100644
--- a/eslint.config.ts
+++ b/eslint.config.ts
@@ -112,6 +112,7 @@ export default defineConfig(
globals: { ...globals.mocha, cy: 'readonly', Cypress: 'readonly' },
},
rules: {
+ '@typescript-eslint/no-deprecated': 'off',
'@typescript-eslint/no-floating-promises': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
diff --git a/src/hooks/usePDFDocument.spec.tsx b/src/hooks/usePDFDocument.spec.tsx
new file mode 100644
index 0000000..504083b
--- /dev/null
+++ b/src/hooks/usePDFDocument.spec.tsx
@@ -0,0 +1,177 @@
+import { useEffect, useState, type JSX } from 'react';
+import multiPage from 'cypress/assets/multi-page.pdf';
+
+import {
+ usePDFDocument,
+ type UsePDFDocumentOptions,
+ type UsePDFDocumentResult,
+} from './usePDFDocument';
+
+const PAGE_COUNT = 5;
+
+type HarnessProps = {
+ source: string | null;
+ options?: UsePDFDocumentOptions;
+ onChange: (result: UsePDFDocumentResult) => void;
+};
+
+/**
+ * Renders the hook and reports every result to the test, which drives
+ * `loadPage` through the latest reported value.
+ */
+const Harness = ({ source, options, onChange }: HarnessProps): JSX.Element => {
+ const result = usePDFDocument(source, options);
+ useEffect(() => {
+ onChange(result);
+ }, [result, onChange]);
+ return (
+
+ {result.pages.map((page, index) => (
+
+ {page === undefined ? 'pending' : 'ready'}
+
+ ))}
+
+ );
+};
+
+const mountHarness = (
+ source: string | null,
+ options?: UsePDFDocumentOptions,
+): { latest: () => UsePDFDocumentResult } => {
+ let latest: UsePDFDocumentResult | undefined;
+ cy.mount(
+ {
+ latest = result;
+ }}
+ />,
+ );
+ return {
+ latest: () => {
+ if (latest === undefined) {
+ throw new Error('hook has not reported yet');
+ }
+ return latest;
+ },
+ };
+};
+
+const readyCount = (result: UsePDFDocumentResult): number =>
+ result.pages.filter((page) => page !== undefined).length;
+
+describe('usePDFDocument', () => {
+ it('stays idle without a source', () => {
+ const harness = mountHarness(null);
+ cy.wrap(null).should(() => {
+ expect(harness.latest().status).to.equal('idle');
+ expect(harness.latest().numPages).to.equal(0);
+ });
+ });
+
+ it('opens the document and prefetches the first pages only', () => {
+ const harness = mountHarness(multiPage, { prefetch: 2, batch: 2 });
+ cy.wrap(null).should(() => {
+ const result = harness.latest();
+ expect(result.status).to.equal('ready');
+ expect(result.numPages).to.equal(PAGE_COUNT);
+ expect(readyCount(result)).to.equal(2);
+ expect(result.loading.size).to.equal(0);
+ });
+ cy.get('[data-cy=page-0]').should('have.text', 'ready');
+ cy.get('[data-cy=page-2]').should('have.text', 'pending');
+ });
+
+ it('loads the whole batch containing a requested page', () => {
+ const harness = mountHarness(multiPage, { prefetch: 1, batch: 2 });
+ cy.wrap(null).should(() => {
+ expect(readyCount(harness.latest())).to.equal(1);
+ });
+ cy.then(() => {
+ harness.latest().loadPage(4);
+ });
+ cy.wrap(null).should(() => {
+ const result = harness.latest();
+ expect(result.pages[3]).to.be.a('string');
+ expect(result.pages[4]).to.be.a('string');
+ expect(result.pages[1]).to.equal(undefined);
+ expect(result.pages[2]).to.equal(undefined);
+ });
+ });
+
+ it('marks pages as loading while they render', () => {
+ const harness = mountHarness(multiPage, { prefetch: 1, batch: 1 });
+ cy.wrap(null).should(() => {
+ expect(harness.latest().status).to.equal('ready');
+ });
+ cy.then(() => {
+ harness.latest().loadPage(3);
+ expect(harness.latest().loadPage).to.be.a('function');
+ });
+ cy.wrap(null).should(() => {
+ expect(harness.latest().loading.has(3)).to.equal(true);
+ });
+ cy.wrap(null).should(() => {
+ expect(harness.latest().loading.has(3)).to.equal(false);
+ expect(harness.latest().pages[3]).to.be.a('string');
+ });
+ });
+
+ it('ignores page indexes outside the document', () => {
+ const harness = mountHarness(multiPage, { prefetch: 1, batch: 1 });
+ cy.wrap(null).should(() => {
+ expect(harness.latest().status).to.equal('ready');
+ });
+ cy.then(() => {
+ harness.latest().loadPage(-1);
+ harness.latest().loadPage(PAGE_COUNT);
+ });
+ cy.wrap(null).should(() => {
+ expect(readyCount(harness.latest())).to.equal(1);
+ expect(harness.latest().error).to.equal(null);
+ });
+ });
+
+ it('reports an error status for an unreadable source', () => {
+ const harness = mountHarness('data:application/pdf;base64,AAAA');
+ cy.wrap(null).should(() => {
+ expect(harness.latest().status).to.equal('error');
+ expect(harness.latest().error).to.be.instanceOf(Error);
+ });
+ });
+
+ it('destroys the document and revokes its pages when the source changes', () => {
+ const Switcher = (): JSX.Element => {
+ const [source, setSource] = useState(multiPage);
+ const result = usePDFDocument(source, { prefetch: 1 });
+ return (
+
+ {result.status}
+ {result.pages[0] ?? ''}
+ {
+ setSource(null);
+ }}
+ >
+ clear
+
+
+ );
+ };
+ cy.window().then((win) => {
+ cy.spy(win.URL, 'revokeObjectURL').as('revoke');
+ });
+ cy.mount( );
+ cy.get('[data-cy=first]')
+ .should('not.have.text', '')
+ .invoke('text')
+ .then((url) => {
+ cy.get('[data-cy=clear]').click();
+ cy.get('[data-cy=status]').should('have.text', 'idle');
+ cy.get('@revoke').should('have.been.calledWith', url);
+ });
+ });
+});
diff --git a/src/hooks/usePDFDocument.ts b/src/hooks/usePDFDocument.ts
new file mode 100644
index 0000000..7f4a896
--- /dev/null
+++ b/src/hooks/usePDFDocument.ts
@@ -0,0 +1,240 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+
+import {
+ openPDF,
+ type PDFDocumentHandle,
+ type PDFPageOptions,
+ type PDFSource,
+} from '@/utils/pdf';
+
+export type UsePDFDocumentOptions = {
+ /** Pages rendered as soon as the document opens. Defaults to 5. */
+ prefetch?: number;
+ /** Pages rendered together when one of them is requested. Defaults to 3. */
+ batch?: number;
+ /** Rendering resolution in pixels, see `PDFPageOptions.resolution`. */
+ resolution?: number;
+ /** Rejects documents with more pages, see `OpenPDFOptions.maxPages`. */
+ maxPages?: number;
+};
+
+export type UsePDFDocumentStatus = 'idle' | 'opening' | 'ready' | 'error';
+
+export type UsePDFDocumentResult = {
+ status: UsePDFDocumentStatus;
+ error: Error | null;
+ numPages: number;
+ /**
+ * One slot per page, indexed from 0. A slot is `undefined` until the page
+ * has been rendered, then holds an image URL usable as `data.image`.
+ */
+ pages: (string | undefined)[];
+ /** Pages (0-based indexes) whose render is queued or in progress. */
+ loading: ReadonlySet;
+ /**
+ * Ensures the page at `index` (0-based) and the rest of its batch are
+ * rendered. Safe to call repeatedly: already rendered or queued pages are
+ * skipped. The page itself is requested with high priority so it is drawn
+ * before any pending prefetch.
+ */
+ loadPage: (index: number) => void;
+ /** Renders every page in `[from, to)` (0-based, `to` exclusive). */
+ loadRange: (from: number, to: number) => void;
+ /** Underlying handle for advanced use, `null` until `status` is `ready`. */
+ document: PDFDocumentHandle | null;
+};
+
+const DEFAULT_PREFETCH = 5;
+const DEFAULT_BATCH = 3;
+
+const EMPTY_SET: ReadonlySet = new Set();
+
+type State = {
+ source: PDFSource | null | undefined;
+ status: UsePDFDocumentStatus;
+ error: Error | null;
+ document: PDFDocumentHandle | null;
+ pages: (string | undefined)[];
+ loading: ReadonlySet;
+};
+
+const initialState = (source: PDFSource | null | undefined): State => ({
+ source,
+ status: source === null || source === undefined ? 'idle' : 'opening',
+ error: null,
+ document: null,
+ pages: [],
+ loading: EMPTY_SET,
+});
+
+const toError = (error: unknown): Error =>
+ error instanceof Error ? error : new Error(String(error));
+
+/**
+ * Boundaries of the batch containing `index`: the first `prefetch` pages form
+ * one block, the remaining pages are grouped `batch` at a time after it.
+ */
+const batchBounds = (
+ index: number,
+ numPages: number,
+ prefetch: number,
+ batch: number,
+): [number, number] => {
+ if (index < prefetch) {
+ return [0, Math.min(prefetch, numPages)];
+ }
+ const from = prefetch + Math.floor((index - prefetch) / batch) * batch;
+ return [from, Math.min(from + batch, numPages)];
+};
+
+/**
+ * Opens a PDF and renders its pages progressively: the first `prefetch`
+ * pages right away, then `batch` pages at a time whenever `loadPage` is
+ * called for a page that is not rendered yet. The document is destroyed
+ * (and its object URLs revoked) when `source` changes or on unmount.
+ *
+ * @example
+ * const { pages, numPages, loadPage } = usePDFDocument(file);
+ *
+ * // when the user scrolls page `index` into view:
+ * loadPage(index);
+ */
+export const usePDFDocument = (
+ source: PDFSource | null | undefined,
+ options: UsePDFDocumentOptions = {},
+): UsePDFDocumentResult => {
+ const {
+ prefetch = DEFAULT_PREFETCH,
+ batch = DEFAULT_BATCH,
+ resolution,
+ maxPages,
+ } = options;
+
+ const [state, setState] = useState(() => initialState(source));
+ const requested = useRef(new Set());
+
+ if (state.source !== source) {
+ setState(initialState(source));
+ }
+
+ useEffect(() => {
+ requested.current = new Set();
+ if (source === null || source === undefined) {
+ return undefined;
+ }
+
+ let handle: PDFDocumentHandle | undefined;
+ let cancelled = false;
+ openPDF(source, { maxPages })
+ .then(async (opened) => {
+ if (cancelled) {
+ await opened.destroy();
+ return;
+ }
+ handle = opened;
+ setState((previous) => ({
+ ...previous,
+ status: 'ready',
+ document: opened,
+ pages: new Array(opened.numPages).fill(undefined),
+ }));
+ })
+ .catch((reason: unknown) => {
+ if (!cancelled) {
+ setState((previous) => ({
+ ...previous,
+ status: 'error',
+ error: toError(reason),
+ }));
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ void handle?.destroy();
+ };
+ }, [source, maxPages]);
+
+ const document = state.document;
+
+ const render = useCallback(
+ (index: number, pageOptions: PDFPageOptions) => {
+ if (document === null || requested.current.has(index)) {
+ return;
+ }
+ requested.current.add(index);
+ setState((previous) => ({
+ ...previous,
+ loading: new Set(previous.loading).add(index),
+ }));
+ document
+ .getPage(index + 1, { ...pageOptions, resolution })
+ .then((image) => {
+ setState((previous) => {
+ const pages = [...previous.pages];
+ pages[index] = image;
+ return { ...previous, pages };
+ });
+ })
+ .catch((reason: unknown) => {
+ requested.current.delete(index);
+ if (toError(reason).name !== 'AbortError') {
+ setState((previous) => ({ ...previous, error: toError(reason) }));
+ }
+ })
+ .finally(() => {
+ setState((previous) => {
+ const loading = new Set(previous.loading);
+ loading.delete(index);
+ return { ...previous, loading };
+ });
+ });
+ },
+ [document, resolution],
+ );
+
+ const loadRange = useCallback(
+ (from: number, to: number) => {
+ if (document === null) {
+ return;
+ }
+ const start = Math.max(0, from);
+ const end = Math.min(document.numPages, to);
+ for (let index = start; index < end; index += 1) {
+ render(index, { priority: 'low' });
+ }
+ },
+ [document, render],
+ );
+
+ const loadPage = useCallback(
+ (index: number) => {
+ if (document === null || index < 0 || index >= document.numPages) {
+ return;
+ }
+ render(index, { priority: 'high' });
+ loadRange(...batchBounds(index, document.numPages, prefetch, batch));
+ },
+ [document, render, loadRange, prefetch, batch],
+ );
+
+ useEffect(() => {
+ if (document !== null) {
+ loadRange(0, prefetch);
+ }
+ }, [document, loadRange, prefetch]);
+
+ return useMemo(
+ () => ({
+ status: state.status,
+ error: state.error,
+ numPages: document?.numPages ?? 0,
+ pages: state.pages,
+ loading: state.loading,
+ loadPage,
+ loadRange,
+ document,
+ }),
+ [state, document, loadPage, loadRange],
+ );
+};
diff --git a/src/index.ts b/src/index.ts
index 3c4c156..74cbfa2 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -10,6 +10,12 @@ import type {
} from './common/types';
import AnnotationLens from './components/AnnotationLens';
import AnnotationViewer from './components/AnnotationViewer';
+import {
+ usePDFDocument,
+ type UsePDFDocumentOptions,
+ type UsePDFDocumentResult,
+ type UsePDFDocumentStatus,
+} from './hooks/usePDFDocument';
import {
drawLayer,
drawShape,
@@ -20,6 +26,16 @@ import {
import getImagesFromPDF from './utils/getImagesFromPDF';
import getPDFPageCount from './utils/getPDFPageCount';
import { dataURItoBlob } from './utils/image';
+import {
+ openPDF,
+ type OpenPDFOptions,
+ type PDFDocumentHandle,
+ type PDFPageOptions,
+ type PDFPageOutput,
+ type PDFPagePriority,
+ type PDFRenderedPage,
+ type PDFSource,
+} from './utils/pdf';
import { getZoomScale } from './utils/zoom';
export type {
@@ -30,6 +46,16 @@ export type {
PointerPosition,
Orientation,
AnnotationData,
+ OpenPDFOptions,
+ PDFDocumentHandle,
+ PDFPageOptions,
+ PDFPageOutput,
+ PDFPagePriority,
+ PDFRenderedPage,
+ PDFSource,
+ UsePDFDocumentOptions,
+ UsePDFDocumentResult,
+ UsePDFDocumentStatus,
};
export {
toBase64,
@@ -37,9 +63,12 @@ export {
dataURItoBlob,
AnnotationLens,
AnnotationViewer,
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- still part of the public API
getImagesFromPDF,
getPDFPageCount,
getZoomScale,
+ openPDF,
+ usePDFDocument,
drawShape,
drawLayer,
setShapeConfig,
diff --git a/src/utils/getImagesFromPDF.ts b/src/utils/getImagesFromPDF.ts
index 4b00061..38fb971 100644
--- a/src/utils/getImagesFromPDF.ts
+++ b/src/utils/getImagesFromPDF.ts
@@ -1,49 +1,29 @@
-import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist';
-
-import { MAX_PDF_SCALE, PDF_RESOLUTION } from '@/common/constants';
-
-import { loadPdfDocument } from './pdf';
-
-const getImageFromPage = async (
- _document: PDFDocumentProxy,
- pageNumber: number,
- resolution = PDF_RESOLUTION,
-): Promise => {
- const page: PDFPageProxy = await _document.getPage(pageNumber);
- const canvas = document.createElement('canvas');
- const [, , width, height] = page.view;
- if (width === undefined || height === undefined) {
- throw new Error('Invalid PDF page view');
- }
- const newScale = (resolution / (height * width)) ** (1 / 2);
- const safeScale = Math.min(newScale, MAX_PDF_SCALE);
- const viewport = page.getViewport({ scale: safeScale });
- canvas.height = viewport.height;
- canvas.width = viewport.width;
- await page.render({ canvas, viewport }).promise;
- return canvas.toDataURL();
-};
+import { openPDF, type PDFSource } from './pdf';
+/**
+ * Renders every page of a PDF to a PNG data URL.
+ *
+ * @deprecated Renders and holds all pages in memory at once. Use `openPDF`
+ * (or the `usePDFDocument` hook) to render pages progressively instead.
+ */
export default async function getImagesFromPDF(
- file: string,
+ file: PDFSource,
maxPages = Infinity,
onSuccess?: () => void,
resolution?: number,
): Promise {
- const pdf = await loadPdfDocument(file);
- if (pdf.numPages > maxPages) {
- const error = new Error('Too many pages');
- error.name = 'TooManyPagesError';
- throw error;
+ const pdf = await openPDF(file, { maxPages });
+ try {
+ onSuccess?.();
+ const images: string[] = [];
+ for await (const { image } of pdf.getPages(1, pdf.numPages, {
+ resolution,
+ output: 'data-url',
+ })) {
+ images.push(image);
+ }
+ return images;
+ } finally {
+ await pdf.destroy();
}
- onSuccess?.();
- const results = await Promise.allSettled(
- Array.from(
- { length: pdf.numPages },
- async (_, index) => await getImageFromPage(pdf, index + 1, resolution),
- ),
- );
- return results.flatMap((result) =>
- result.status === 'fulfilled' ? [result.value] : [],
- );
}
diff --git a/src/utils/getPDFPageCount.ts b/src/utils/getPDFPageCount.ts
index 3dfbc69..3c23268 100644
--- a/src/utils/getPDFPageCount.ts
+++ b/src/utils/getPDFPageCount.ts
@@ -1,6 +1,16 @@
-import { loadPdfDocument } from './pdf';
+import { openPDF, type PDFSource } from './pdf';
-export default async function getPDFPageCount(file: string): Promise {
- const pdf = await loadPdfDocument(file);
- return pdf.numPages;
+/**
+ * Counts the pages of a PDF. If you also need to render pages, use `openPDF`
+ * instead so the document is only parsed once.
+ */
+export default async function getPDFPageCount(
+ file: PDFSource,
+): Promise {
+ const pdf = await openPDF(file);
+ try {
+ return pdf.numPages;
+ } finally {
+ await pdf.destroy();
+ }
}
diff --git a/src/utils/pdf.spec.ts b/src/utils/pdf.spec.ts
new file mode 100644
index 0000000..3a42727
--- /dev/null
+++ b/src/utils/pdf.spec.ts
@@ -0,0 +1,190 @@
+import brokenPDF from 'cypress/assets/broken-pdf.pdf';
+import multiPage from 'cypress/assets/multi-page.pdf';
+
+import { openPDF, type PDFDocumentHandle, type PDFRenderedPage } from './pdf';
+
+const PAGE_COUNT = 5;
+
+const collect = async (
+ iterable: AsyncIterable,
+): Promise => {
+ const pages: PDFRenderedPage[] = [];
+ for await (const page of iterable) {
+ pages.push(page);
+ }
+ return pages;
+};
+
+const run = (task: () => Promise): Cypress.Chainable =>
+ cy.then(async () => await task());
+
+const errorName = async (promise: Promise): Promise => {
+ try {
+ await promise;
+ return 'resolved';
+ } catch (error: unknown) {
+ return (error as Error).name;
+ }
+};
+
+describe('openPDF', () => {
+ let handle: PDFDocumentHandle | undefined;
+
+ afterEach(() => {
+ if (handle !== undefined) {
+ cy.wrap(handle.destroy());
+ handle = undefined;
+ }
+ });
+
+ it('exposes the page count without rendering anything', () => {
+ cy.wrap(openPDF(multiPage)).then((opened) => {
+ handle = opened as PDFDocumentHandle;
+ expect(handle.numPages).to.equal(PAGE_COUNT);
+ });
+ });
+
+ it('accepts a Blob source', () => {
+ cy.wrap(
+ fetch(multiPage)
+ .then(async (response) => await response.blob())
+ .then(async (blob) => await openPDF(blob)),
+ ).then((opened) => {
+ handle = opened as PDFDocumentHandle;
+ expect(handle.numPages).to.equal(PAGE_COUNT);
+ });
+ });
+
+ it('renders a page as an object URL by default and as a data URL on request', () => {
+ run(async () => {
+ const opened = await openPDF(multiPage);
+ handle = opened;
+ return await Promise.all([
+ opened.getPage(1),
+ opened.getPage(1, { output: 'data-url' }),
+ ]);
+ }).should(([objectUrl, dataUrl]) => {
+ expect(objectUrl).to.match(/^blob:/);
+ expect(dataUrl).to.match(/^data:image\/png;base64,/);
+ });
+ });
+
+ it('returns the cached image when the same page is requested again', () => {
+ run(async () => {
+ const opened = await openPDF(multiPage);
+ handle = opened;
+ const first = await opened.getPage(2);
+ const second = await opened.getPage(2);
+ const other = await opened.getPage(2, { resolution: 50_000 });
+ return { first, second, other };
+ }).should(({ first, second, other }) => {
+ expect(second).to.equal(first);
+ expect(other).to.not.equal(first);
+ });
+ });
+
+ it('rejects out of range page numbers', () => {
+ run(async () => {
+ const opened = await openPDF(multiPage);
+ handle = opened;
+ return await Promise.all([
+ errorName(opened.getPage(0)),
+ errorName(opened.getPage(PAGE_COUNT + 1)),
+ ]);
+ }).should('deep.equal', ['RangeError', 'RangeError']);
+ });
+
+ it('yields an inclusive 1-based range in order', () => {
+ run(async () => {
+ const opened = await openPDF(multiPage);
+ handle = opened;
+ return await collect(opened.getPages(2, 4));
+ }).should((pages) => {
+ expect(pages.map((page) => page.pageNumber)).to.deep.equal([2, 3, 4]);
+ pages.forEach((page) => {
+ expect(page.image).to.match(/^blob:/);
+ });
+ });
+ });
+
+ it('renders high priority requests before queued low priority ones', () => {
+ run(async () => {
+ const opened = await openPDF(multiPage);
+ handle = opened;
+ const order: number[] = [];
+ const track = async (
+ pageNumber: number,
+ priority: 'high' | 'low',
+ ): Promise => {
+ await opened.getPage(pageNumber, { priority });
+ order.push(pageNumber);
+ };
+ await Promise.all([
+ track(1, 'low'),
+ track(2, 'low'),
+ track(3, 'low'),
+ track(5, 'high'),
+ ]);
+ return order;
+ }).should((order) => {
+ expect(order[0]).to.equal(1);
+ expect(order[1]).to.equal(5);
+ expect(order.slice(2)).to.deep.equal([2, 3]);
+ });
+ });
+
+ it('rejects with an AbortError when a queued request is aborted', () => {
+ run(async () => {
+ const opened = await openPDF(multiPage);
+ handle = opened;
+ const controller = new AbortController();
+ const first = opened.getPage(1);
+ const aborted = errorName(
+ opened.getPage(2, { signal: controller.signal }),
+ );
+ controller.abort();
+ await first;
+ return await aborted;
+ }).should('equal', 'AbortError');
+ });
+
+ it('rejects immediately when the signal is already aborted', () => {
+ run(async () => {
+ const opened = await openPDF(multiPage);
+ handle = opened;
+ return await errorName(
+ opened.getPage(1, { signal: AbortSignal.abort() }),
+ );
+ }).should('equal', 'AbortError');
+ });
+
+ it('revokes object URLs and refuses further renders once destroyed', () => {
+ run(async () => {
+ const opened = await openPDF(multiPage);
+ const image = await opened.getPage(1);
+ await opened.destroy();
+ const revoked = await fetch(image).then(
+ () => false,
+ () => true,
+ );
+ return { revoked, afterDestroy: await errorName(opened.getPage(2)) };
+ }).should(({ revoked, afterDestroy }) => {
+ expect(revoked).to.equal(true);
+ expect(afterDestroy).to.not.equal('resolved');
+ });
+ });
+
+ it('rejects documents above maxPages with a TooManyPagesError', () => {
+ cy.wrap(errorName(openPDF(multiPage, { maxPages: 3 }))).should(
+ 'equal',
+ 'TooManyPagesError',
+ );
+ });
+
+ it('rejects a broken PDF with an InvalidPDFException', () => {
+ cy.wrap(errorName(openPDF(brokenPDF))).should(
+ 'equal',
+ 'InvalidPDFException',
+ );
+ });
+});
diff --git a/src/utils/pdf.ts b/src/utils/pdf.ts
index 0bd1c68..ded1119 100644
--- a/src/utils/pdf.ts
+++ b/src/utils/pdf.ts
@@ -1,5 +1,7 @@
import type * as Pdfjs from 'pdfjs-dist';
-import type { PDFDocumentProxy } from 'pdfjs-dist';
+import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist';
+
+import { MAX_PDF_SCALE, PDF_RESOLUTION } from '@/common/constants';
let pdfjsModule: Promise | undefined;
@@ -20,9 +22,365 @@ const loadPdfjs = async (): Promise => {
return await pdfjsModule;
};
-export const loadPdfDocument = async (
- file: string,
-): Promise => {
+export type PDFSource = string | Blob | ArrayBuffer | Uint8Array;
+
+export type PDFPageOutput = 'object-url' | 'data-url';
+
+export type PDFPagePriority = 'high' | 'low';
+
+export type PDFPageOptions = {
+ /**
+ * Target pixel count of the rendered page (width × height). Defaults to
+ * 1.5 Mpx, which is what the viewer displays; use a much lower value such
+ * as 50 000 for thumbnails. Renders at different resolutions are cached
+ * independently.
+ */
+ resolution?: number;
+ /**
+ * `object-url` (default) returns a `blob:` URL backed by a PNG blob, which
+ * is cheaper to hold in memory and is revoked by `destroy()`. `data-url`
+ * returns a base64 PNG data URL for callers that need a self-contained
+ * string.
+ */
+ output?: PDFPageOutput;
+ /**
+ * `high` (default for `getPage`) puts the request ahead of everything still
+ * waiting, so that a page the user explicitly asked for is rendered before
+ * background prefetches. `low` (default for `getPages`) appends to the
+ * queue.
+ */
+ priority?: PDFPagePriority;
+ /**
+ * Aborting removes the request from the queue, or cancels the in-progress
+ * render, and rejects the returned promise with an `AbortError`.
+ */
+ signal?: AbortSignal;
+};
+
+export type PDFRenderedPage = {
+ /** 1-based page number. */
+ pageNumber: number;
+ image: string;
+};
+
+/**
+ * A parsed PDF document from which pages can be rendered on demand. Pages
+ * render one at a time (pdf.js renders serially per document anyway), in
+ * priority order, and every successful render is cached for the lifetime of
+ * the handle.
+ */
+export type PDFDocumentHandle = {
+ readonly numPages: number;
+ /** Renders a single page. `pageNumber` is 1-based, like pdf.js. */
+ getPage: (pageNumber: number, options?: PDFPageOptions) => Promise;
+ /**
+ * Renders pages `from` to `to` inclusive (1-based), yielding each page as
+ * soon as it is ready. Iterate with `for await`.
+ */
+ getPages: (
+ from: number,
+ to: number,
+ options?: PDFPageOptions,
+ ) => AsyncIterable;
+ /**
+ * Cancels pending renders, revokes every object URL handed out by this
+ * handle and releases the pdf.js document. The handle is unusable after.
+ */
+ destroy: () => Promise;
+};
+
+export type OpenPDFOptions = {
+ /** Rejects with a `TooManyPagesError` when the document has more pages. */
+ maxPages?: number;
+};
+
+type RenderRequest = {
+ key: string;
+ pageNumber: number;
+ resolution: number;
+ output: PDFPageOutput;
+ signal: AbortSignal | undefined;
+ resolve: (image: string) => void;
+ reject: (error: unknown) => void;
+};
+
+const abortError = (): Error => {
+ const error = new Error('PDF page rendering was aborted');
+ error.name = 'AbortError';
+ return error;
+};
+
+const tooManyPagesError = (): Error => {
+ const error = new Error('Too many pages');
+ error.name = 'TooManyPagesError';
+ return error;
+};
+
+const destroyedError = (): Error =>
+ new Error('This PDF document handle has been destroyed');
+
+const toDocumentParameters = async (
+ source: PDFSource,
+): Promise<{ url: string } | { data: Uint8Array }> => {
+ if (typeof source === 'string') {
+ return { url: source };
+ }
+ if (source instanceof Blob) {
+ return { data: new Uint8Array(await source.arrayBuffer()) };
+ }
+ if (source instanceof Uint8Array) {
+ return { data: source };
+ }
+ return { data: new Uint8Array(source) };
+};
+
+const canvasToBlob = async (canvas: HTMLCanvasElement): Promise =>
+ await new Promise((resolve, reject) => {
+ canvas.toBlob((blob) => {
+ if (blob === null) {
+ reject(new Error('Could not encode the rendered PDF page'));
+ } else {
+ resolve(blob);
+ }
+ }, 'image/png');
+ });
+
+/**
+ * Serial, priority-ordered render queue bound to one pdf.js document. Only
+ * one render task is in flight at any time, so a high-priority request can
+ * only be delayed by the single page currently being drawn.
+ */
+class PDFRenderQueue {
+ private readonly queue: RenderRequest[] = [];
+ private readonly cache = new Map();
+ private readonly inFlight = new Map>();
+ private readonly objectUrls = new Set();
+ private current: { request: RenderRequest; task?: RenderTask } | undefined;
+ private destroyed = false;
+
+ constructor(private readonly document: PDFDocumentProxy) {}
+
+ public async request(
+ pageNumber: number,
+ options: PDFPageOptions,
+ defaultPriority: PDFPagePriority,
+ ): Promise {
+ if (this.destroyed) {
+ throw destroyedError();
+ }
+ if (
+ !Number.isInteger(pageNumber) ||
+ pageNumber < 1 ||
+ pageNumber > this.document.numPages
+ ) {
+ throw new RangeError(
+ `Page ${String(pageNumber)} is out of range (1-${String(this.document.numPages)})`,
+ );
+ }
+ if (options.signal?.aborted === true) {
+ throw abortError();
+ }
+
+ const resolution = options.resolution ?? PDF_RESOLUTION;
+ const output = options.output ?? 'object-url';
+ const key = `${String(pageNumber)}@${String(resolution)}:${output}`;
+
+ const cached = this.cache.get(key);
+ if (cached !== undefined) {
+ return cached;
+ }
+ const pending = this.inFlight.get(key);
+ if (pending !== undefined) {
+ return await this.promoteIfNeeded(pending, key, options, defaultPriority);
+ }
+
+ const promise = new Promise((resolve, reject) => {
+ const request: RenderRequest = {
+ key,
+ pageNumber,
+ resolution,
+ output,
+ signal: options.signal,
+ resolve,
+ reject,
+ };
+ if ((options.priority ?? defaultPriority) === 'high') {
+ this.queue.unshift(request);
+ } else {
+ this.queue.push(request);
+ }
+ options.signal?.addEventListener(
+ 'abort',
+ () => {
+ this.abort(request);
+ },
+ { once: true },
+ );
+ });
+ this.inFlight.set(key, promise);
+ promise
+ .finally(() => {
+ this.inFlight.delete(key);
+ })
+ .catch(() => undefined);
+ void this.drain();
+ return await promise;
+ }
+
+ public async destroy(): Promise {
+ if (this.destroyed) {
+ return;
+ }
+ this.destroyed = true;
+ this.queue.splice(0).forEach((request) => {
+ request.reject(destroyedError());
+ });
+ this.current?.task?.cancel();
+ this.objectUrls.forEach((url) => {
+ URL.revokeObjectURL(url);
+ });
+ this.objectUrls.clear();
+ this.cache.clear();
+ await this.document.loadingTask.destroy();
+ }
+
+ /**
+ * A duplicate request for a page that is already queued with low priority
+ * moves it to the front instead of rendering it twice.
+ */
+ private async promoteIfNeeded(
+ pending: Promise,
+ key: string,
+ options: PDFPageOptions,
+ defaultPriority: PDFPagePriority,
+ ): Promise {
+ if ((options.priority ?? defaultPriority) === 'high') {
+ const index = this.queue.findIndex((request) => request.key === key);
+ if (index > 0) {
+ const [request] = this.queue.splice(index, 1);
+ if (request !== undefined) {
+ this.queue.unshift(request);
+ }
+ }
+ }
+ return await pending;
+ }
+
+ private abort(request: RenderRequest): void {
+ const index = this.queue.indexOf(request);
+ if (index !== -1) {
+ this.queue.splice(index, 1);
+ request.reject(abortError());
+ return;
+ }
+ if (this.current?.request === request) {
+ this.current.task?.cancel();
+ }
+ }
+
+ private async drain(): Promise {
+ if (this.current !== undefined) {
+ return;
+ }
+ let request = this.queue.shift();
+ while (request !== undefined) {
+ this.current = { request };
+ try {
+ const image = await this.render(request);
+ this.cache.set(request.key, image);
+ request.resolve(image);
+ } catch (error: unknown) {
+ request.reject(
+ request.signal?.aborted === true || this.destroyed
+ ? abortError()
+ : error,
+ );
+ } finally {
+ this.current = undefined;
+ }
+ request = this.queue.shift();
+ }
+ }
+
+ private async render(request: RenderRequest): Promise {
+ const page = await this.document.getPage(request.pageNumber);
+ const [, , width, height] = page.view;
+ if (width === undefined || height === undefined) {
+ throw new Error('Invalid PDF page view');
+ }
+ const scale = Math.min(
+ (request.resolution / (height * width)) ** (1 / 2),
+ MAX_PDF_SCALE,
+ );
+ const viewport = page.getViewport({ scale });
+ const canvas = document.createElement('canvas');
+ canvas.width = viewport.width;
+ canvas.height = viewport.height;
+
+ const task = page.render({ canvas, viewport });
+ if (this.current?.request === request) {
+ this.current.task = task;
+ }
+ if (request.signal?.aborted === true || this.destroyed) {
+ task.cancel();
+ }
+ await task.promise;
+
+ if (request.output === 'data-url') {
+ return canvas.toDataURL('image/png');
+ }
+ const url = URL.createObjectURL(await canvasToBlob(canvas));
+ this.objectUrls.add(url);
+ return url;
+ }
+}
+
+/**
+ * Parses a PDF once and returns a handle to render its pages on demand.
+ * Prefer this over `getImagesFromPDF` whenever the document may have more
+ * than a handful of pages: only the pages you ask for are rendered, the
+ * requested one first, and memory is released by `destroy()`.
+ *
+ * @example
+ * const doc = await openPDF(file);
+ * const firstPage = await doc.getPage(1);
+ * for await (const { pageNumber, image } of doc.getPages(2, 5)) {
+ * show(pageNumber, image);
+ * }
+ * await doc.destroy();
+ */
+export const openPDF = async (
+ source: PDFSource,
+ options: OpenPDFOptions = {},
+): Promise => {
const { getDocument } = await loadPdfjs();
- return await getDocument({ url: file }).promise;
+ const pdf = await getDocument(await toDocumentParameters(source)).promise;
+ if (options.maxPages !== undefined && pdf.numPages > options.maxPages) {
+ await pdf.loadingTask.destroy();
+ throw tooManyPagesError();
+ }
+
+ const queue = new PDFRenderQueue(pdf);
+ return {
+ numPages: pdf.numPages,
+ getPage: async (pageNumber, pageOptions = {}) =>
+ await queue.request(pageNumber, pageOptions, 'high'),
+ getPages: (from, to, pageOptions = {}) => ({
+ async *[Symbol.asyncIterator]() {
+ const requests = Array.from({ length: to - from + 1 }, (_, index) => {
+ const pageNumber = from + index;
+ return {
+ pageNumber,
+ image: queue.request(pageNumber, pageOptions, 'low'),
+ };
+ });
+ for (const { pageNumber, image } of requests) {
+ yield { pageNumber, image: await image };
+ }
+ },
+ }),
+ destroy: async () => {
+ await queue.destroy();
+ },
+ };
};
From 187fb6bcae259d74eea4ddb58c02baccac98387e Mon Sep 17 00:00:00 2001
From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com>
Date: Fri, 2 Oct 2026 18:18:17 +0200
Subject: [PATCH 02/17] address review
---
README.md | 1 +
src/hooks/usePDFDocument.spec.tsx | 44 ++++++++
src/hooks/usePDFDocument.ts | 31 ++++--
src/utils/getImagesFromPDF.ts | 29 ++++--
src/utils/image.spec.ts | 26 +++++
src/utils/image.ts | 9 +-
src/utils/pdf.spec.ts | 63 ++++++++++++
src/utils/pdf.ts | 164 +++++++++++++++++-------------
8 files changed, 280 insertions(+), 87 deletions(-)
diff --git a/README.md b/README.md
index 21c2c48..f673492 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,7 @@ PDF pages are rendered in the browser with [pdf.js](https://mozilla.github.io/pd
The simplest way to display a PDF in a React component:
```jsx
+import { useState } from 'react';
import { AnnotationViewer, usePDFDocument } from 'react-mindee-js';
function Document({ file }) {
diff --git a/src/hooks/usePDFDocument.spec.tsx b/src/hooks/usePDFDocument.spec.tsx
index 504083b..2e5d8d8 100644
--- a/src/hooks/usePDFDocument.spec.tsx
+++ b/src/hooks/usePDFDocument.spec.tsx
@@ -134,6 +134,50 @@ describe('usePDFDocument', () => {
});
});
+ it('promotes a page still queued behind the prefetch when loadPage asks for it', () => {
+ const harness = mountHarness(multiPage, { prefetch: 5, batch: 1 });
+ cy.wrap(null).should(() => {
+ expect(harness.latest().status).to.equal('ready');
+ expect(harness.latest().loading.size).to.be.greaterThan(0);
+ });
+ cy.then(() => {
+ harness.latest().loadPage(4);
+ });
+ cy.wrap(null).should(() => {
+ const result = harness.latest();
+ expect(result.pages[4]).to.be.a('string');
+ expect(result.pages[2]).to.equal(undefined);
+ });
+ });
+
+ it('resets to an error without exposing the previous document when maxPages tightens', () => {
+ const Limiter = (): JSX.Element => {
+ const [maxPages, setMaxPages] = useState(undefined);
+ const result = usePDFDocument(multiPage, { prefetch: 1, maxPages });
+ return (
+
+ {result.status}
+ {result.document === null ? 'null' : 'set'}
+ {String(result.numPages)}
+ {
+ setMaxPages(2);
+ }}
+ >
+ limit
+
+
+ );
+ };
+ cy.mount( );
+ cy.get('[data-cy=status]').should('have.text', 'ready');
+ cy.get('[data-cy=limit]').click();
+ cy.get('[data-cy=status]').should('have.text', 'error');
+ cy.get('[data-cy=doc]').should('have.text', 'null');
+ cy.get('[data-cy=count]').should('have.text', '0');
+ });
+
it('reports an error status for an unreadable source', () => {
const harness = mountHarness('data:application/pdf;base64,AAAA');
cy.wrap(null).should(() => {
diff --git a/src/hooks/usePDFDocument.ts b/src/hooks/usePDFDocument.ts
index 7f4a896..664b035 100644
--- a/src/hooks/usePDFDocument.ts
+++ b/src/hooks/usePDFDocument.ts
@@ -33,9 +33,9 @@ export type UsePDFDocumentResult = {
loading: ReadonlySet;
/**
* Ensures the page at `index` (0-based) and the rest of its batch are
- * rendered. Safe to call repeatedly: already rendered or queued pages are
- * skipped. The page itself is requested with high priority so it is drawn
- * before any pending prefetch.
+ * rendered. Safe to call repeatedly: already rendered pages are skipped and
+ * a page still waiting in the queue is promoted. The page itself is
+ * requested with high priority so it is drawn before any pending prefetch.
*/
loadPage: (index: number) => void;
/** Renders every page in `[from, to)` (0-based, `to` exclusive). */
@@ -51,6 +51,7 @@ const EMPTY_SET: ReadonlySet = new Set();
type State = {
source: PDFSource | null | undefined;
+ maxPages: number | undefined;
status: UsePDFDocumentStatus;
error: Error | null;
document: PDFDocumentHandle | null;
@@ -58,8 +59,12 @@ type State = {
loading: ReadonlySet;
};
-const initialState = (source: PDFSource | null | undefined): State => ({
+const initialState = (
+ source: PDFSource | null | undefined,
+ maxPages: number | undefined,
+): State => ({
source,
+ maxPages,
status: source === null || source === undefined ? 'idle' : 'opening',
error: null,
document: null,
@@ -110,11 +115,13 @@ export const usePDFDocument = (
maxPages,
} = options;
- const [state, setState] = useState(() => initialState(source));
+ const [state, setState] = useState(() =>
+ initialState(source, maxPages),
+ );
const requested = useRef(new Set());
- if (state.source !== source) {
- setState(initialState(source));
+ if (state.source !== source || state.maxPages !== maxPages) {
+ setState(initialState(source, maxPages));
}
useEffect(() => {
@@ -212,10 +219,16 @@ export const usePDFDocument = (
if (document === null || index < 0 || index >= document.numPages) {
return;
}
- render(index, { priority: 'high' });
+ if (requested.current.has(index)) {
+ void document
+ .getPage(index + 1, { priority: 'high', resolution })
+ .catch(() => undefined);
+ } else {
+ render(index, { priority: 'high' });
+ }
loadRange(...batchBounds(index, document.numPages, prefetch, batch));
},
- [document, render, loadRange, prefetch, batch],
+ [document, render, loadRange, prefetch, batch, resolution],
);
useEffect(() => {
diff --git a/src/utils/getImagesFromPDF.ts b/src/utils/getImagesFromPDF.ts
index 38fb971..c41194a 100644
--- a/src/utils/getImagesFromPDF.ts
+++ b/src/utils/getImagesFromPDF.ts
@@ -1,7 +1,9 @@
import { openPDF, type PDFSource } from './pdf';
/**
- * Renders every page of a PDF to a PNG data URL.
+ * Renders every page of a PDF to a PNG data URL. Pages that fail to render
+ * are skipped rather than failing the whole call, so the result may be
+ * shorter than the page count.
*
* @deprecated Renders and holds all pages in memory at once. Use `openPDF`
* (or the `usePDFDocument` hook) to render pages progressively instead.
@@ -15,14 +17,23 @@ export default async function getImagesFromPDF(
const pdf = await openPDF(file, { maxPages });
try {
onSuccess?.();
- const images: string[] = [];
- for await (const { image } of pdf.getPages(1, pdf.numPages, {
- resolution,
- output: 'data-url',
- })) {
- images.push(image);
- }
- return images;
+ const pageNumbers = Array.from({ length: pdf.numPages }, (_, i) => i + 1);
+ const results = await Promise.allSettled(
+ pageNumbers.map(
+ async (pageNumber) =>
+ await pdf.getPage(pageNumber, {
+ resolution,
+ output: 'data-url',
+ priority: 'low',
+ }),
+ ),
+ );
+ return results
+ .filter(
+ (result): result is PromiseFulfilledResult =>
+ result.status === 'fulfilled',
+ )
+ .map((result) => result.value);
} finally {
await pdf.destroy();
}
diff --git a/src/utils/image.spec.ts b/src/utils/image.spec.ts
index 85ca3d3..b67efab 100644
--- a/src/utils/image.spec.ts
+++ b/src/utils/image.spec.ts
@@ -1,13 +1,39 @@
+import demoHeic from 'cypress/assets/demo.heic';
import type { Stage } from 'konva/lib/Stage';
import type { ImageBoundingBox } from '@/common/types';
import {
computeImageBoundingBox,
+ prepareImage,
setStageBasedImagePosition,
} from '@/utils/image';
describe('image', () => {
+ describe('prepareImage', () => {
+ const withMimeType = async (type: string): Promise => {
+ const blob = await fetch(demoHeic).then(async (r) => await r.blob());
+ return URL.createObjectURL(new Blob([blob], { type }));
+ };
+
+ ['image/heic', 'image/heif', 'image/heif-sequence'].forEach((type) => {
+ it(`converts a ${type} blob to a JPEG data URL`, () => {
+ cy.then(
+ async () => await prepareImage(await withMimeType(type)),
+ ).should('match', /^data:image\/jpeg;base64,/);
+ });
+ });
+
+ it('returns other image types untouched', () => {
+ cy.then(async () => {
+ const url = await withMimeType('image/png');
+ return [url, await prepareImage(url)];
+ }).should(([url, prepared]) => {
+ expect(prepared).to.equal(url);
+ });
+ });
+ });
+
describe('computeImageBoundingBox', () => {
it('should return the correct bounding box', () => {
const htmlDivElement = { clientWidth: 700, clientHeight: 800 };
diff --git a/src/utils/image.ts b/src/utils/image.ts
index 9eacac4..5c55cbc 100644
--- a/src/utils/image.ts
+++ b/src/utils/image.ts
@@ -33,9 +33,16 @@ export const dataURItoBlob = (dataURI: string): Blob => {
return new Blob([ia], { type: mimeString });
};
+const HEIF_MIME_TYPES = new Set([
+ 'image/heic',
+ 'image/heif',
+ 'image/heic-sequence',
+ 'image/heif-sequence',
+]);
+
export const prepareImage = async (image: string): Promise => {
const blob = await urlToBlob(image);
- if (blob.type === 'image/heic') {
+ if (HEIF_MIME_TYPES.has(blob.type)) {
return await heicToJpg(blob);
}
if (blob.type === 'image/tiff') {
diff --git a/src/utils/pdf.spec.ts b/src/utils/pdf.spec.ts
index 3a42727..27193e5 100644
--- a/src/utils/pdf.spec.ts
+++ b/src/utils/pdf.spec.ts
@@ -148,6 +148,69 @@ describe('openPDF', () => {
}).should('equal', 'AbortError');
});
+ it('lets one caller abort without affecting another waiting on the same page', () => {
+ run(async () => {
+ const opened = await openPDF(multiPage);
+ handle = opened;
+ const controller = new AbortController();
+ const blocker = opened.getPage(1);
+ const kept = opened.getPage(3);
+ const aborted = errorName(
+ opened.getPage(3, { signal: controller.signal }),
+ );
+ controller.abort();
+ await blocker;
+ return { aborted: await aborted, kept: await kept };
+ }).should(({ aborted, kept }) => {
+ expect(aborted).to.equal('AbortError');
+ expect(kept).to.match(/^blob:/);
+ });
+ });
+
+ it('cancels a shared render only once every caller has aborted', () => {
+ run(async () => {
+ const opened = await openPDF(multiPage);
+ handle = opened;
+ const first = new AbortController();
+ const second = new AbortController();
+ const blocker = opened.getPage(1);
+ const a = errorName(opened.getPage(4, { signal: first.signal }));
+ const b = errorName(opened.getPage(4, { signal: second.signal }));
+ first.abort();
+ second.abort();
+ await blocker;
+ const fresh = opened.getPage(4);
+ return { a: await a, b: await b, fresh: await fresh };
+ }).should(({ a, b, fresh }) => {
+ expect(a).to.equal('AbortError');
+ expect(b).to.equal('AbortError');
+ expect(fresh).to.match(/^blob:/);
+ });
+ });
+
+ it('promotes an already queued low priority page when requested again with high priority', () => {
+ run(async () => {
+ const opened = await openPDF(multiPage);
+ handle = opened;
+ const order: number[] = [];
+ const track = async (promise: Promise, page: number) => {
+ await promise;
+ order.push(page);
+ };
+ const tasks = [
+ track(opened.getPage(1, { priority: 'low' }), 1),
+ track(opened.getPage(2, { priority: 'low' }), 2),
+ track(opened.getPage(3, { priority: 'low' }), 3),
+ track(opened.getPage(4, { priority: 'low' }), 4),
+ ];
+ tasks.push(track(opened.getPage(4, { priority: 'high' }), 44));
+ await Promise.all(tasks);
+ return order;
+ }).should((order) => {
+ expect(order.slice(0, 2)).to.deep.equal([1, 4]);
+ });
+ });
+
it('rejects immediately when the signal is already aborted', () => {
run(async () => {
const opened = await openPDF(multiPage);
diff --git a/src/utils/pdf.ts b/src/utils/pdf.ts
index ded1119..623ca67 100644
--- a/src/utils/pdf.ts
+++ b/src/utils/pdf.ts
@@ -94,14 +94,24 @@ export type OpenPDFOptions = {
maxPages?: number;
};
+/** One awaiting caller of a render; several callers can share a request. */
+type Caller = {
+ resolve: (image: string) => void;
+ reject: (error: unknown) => void;
+ signal: AbortSignal | undefined;
+ onAbort: (() => void) | undefined;
+};
+
+/**
+ * A page render shared by every caller that asked for the same page at the
+ * same resolution and output. It is cancelled only once all of them aborted.
+ */
type RenderRequest = {
key: string;
pageNumber: number;
resolution: number;
output: PDFPageOutput;
- signal: AbortSignal | undefined;
- resolve: (image: string) => void;
- reject: (error: unknown) => void;
+ callers: Caller[];
};
const abortError = (): Error => {
@@ -153,7 +163,7 @@ const canvasToBlob = async (canvas: HTMLCanvasElement): Promise =>
class PDFRenderQueue {
private readonly queue: RenderRequest[] = [];
private readonly cache = new Map();
- private readonly inFlight = new Map>();
+ private readonly inFlight = new Map();
private readonly objectUrls = new Set();
private current: { request: RenderRequest; task?: RenderTask } | undefined;
private destroyed = false;
@@ -189,42 +199,20 @@ class PDFRenderQueue {
if (cached !== undefined) {
return cached;
}
- const pending = this.inFlight.get(key);
- if (pending !== undefined) {
- return await this.promoteIfNeeded(pending, key, options, defaultPriority);
- }
- const promise = new Promise((resolve, reject) => {
- const request: RenderRequest = {
- key,
- pageNumber,
- resolution,
- output,
- signal: options.signal,
- resolve,
- reject,
- };
- if ((options.priority ?? defaultPriority) === 'high') {
- this.queue.unshift(request);
- } else {
- this.queue.push(request);
- }
- options.signal?.addEventListener(
- 'abort',
- () => {
- this.abort(request);
- },
- { once: true },
- );
- });
- this.inFlight.set(key, promise);
- promise
- .finally(() => {
- this.inFlight.delete(key);
- })
- .catch(() => undefined);
+ const priority = options.priority ?? defaultPriority;
+ let request = this.inFlight.get(key);
+ if (request === undefined) {
+ request = { key, pageNumber, resolution, output, callers: [] };
+ this.inFlight.set(key, request);
+ this.queue.push(request);
+ }
+ if (priority === 'high') {
+ this.promote(request);
+ }
+ const image = this.attach(request, options.signal);
void this.drain();
- return await promise;
+ return await image;
}
public async destroy(): Promise {
@@ -233,7 +221,9 @@ class PDFRenderQueue {
}
this.destroyed = true;
this.queue.splice(0).forEach((request) => {
- request.reject(destroyedError());
+ this.settle(request, (caller) => {
+ caller.reject(destroyedError());
+ });
});
this.current?.task?.cancel();
this.objectUrls.forEach((url) => {
@@ -244,33 +234,54 @@ class PDFRenderQueue {
await this.document.loadingTask.destroy();
}
+ /** Moves a queued request to the front; no-op if it is already rendering. */
+ private promote(request: RenderRequest): void {
+ const index = this.queue.indexOf(request);
+ if (index > 0) {
+ this.queue.splice(index, 1);
+ this.queue.unshift(request);
+ }
+ }
+
/**
- * A duplicate request for a page that is already queued with low priority
- * moves it to the front instead of rendering it twice.
+ * Registers a caller on a request and returns its own promise, which
+ * rejects with an `AbortError` as soon as *its* signal aborts without
+ * affecting the other callers.
*/
- private async promoteIfNeeded(
- pending: Promise,
- key: string,
- options: PDFPageOptions,
- defaultPriority: PDFPagePriority,
+ private async attach(
+ request: RenderRequest,
+ signal: AbortSignal | undefined,
): Promise {
- if ((options.priority ?? defaultPriority) === 'high') {
- const index = this.queue.findIndex((request) => request.key === key);
- if (index > 0) {
- const [request] = this.queue.splice(index, 1);
- if (request !== undefined) {
- this.queue.unshift(request);
- }
+ return await new Promise((resolve, reject) => {
+ const caller: Caller = { resolve, reject, signal, onAbort: undefined };
+ if (signal !== undefined) {
+ caller.onAbort = () => {
+ this.detach(request, caller);
+ };
+ signal.addEventListener('abort', caller.onAbort, { once: true });
}
- }
- return await pending;
+ request.callers.push(caller);
+ });
}
- private abort(request: RenderRequest): void {
- const index = this.queue.indexOf(request);
- if (index !== -1) {
- this.queue.splice(index, 1);
- request.reject(abortError());
+ /**
+ * Removes an aborting caller. The shared render is dropped (or cancelled
+ * if in progress) only when nobody is waiting for it anymore.
+ */
+ private detach(request: RenderRequest, caller: Caller): void {
+ const index = request.callers.indexOf(caller);
+ if (index === -1) {
+ return;
+ }
+ request.callers.splice(index, 1);
+ caller.reject(abortError());
+ if (request.callers.length > 0) {
+ return;
+ }
+ const queued = this.queue.indexOf(request);
+ if (queued !== -1) {
+ this.queue.splice(queued, 1);
+ this.inFlight.delete(request.key);
return;
}
if (this.current?.request === request) {
@@ -278,6 +289,20 @@ class PDFRenderQueue {
}
}
+ /** Settles every remaining caller and releases the request. */
+ private settle(
+ request: RenderRequest,
+ settleCaller: (caller: Caller) => void,
+ ): void {
+ this.inFlight.delete(request.key);
+ request.callers.splice(0).forEach((caller) => {
+ if (caller.signal !== undefined && caller.onAbort !== undefined) {
+ caller.signal.removeEventListener('abort', caller.onAbort);
+ }
+ settleCaller(caller);
+ });
+ }
+
private async drain(): Promise {
if (this.current !== undefined) {
return;
@@ -287,14 +312,17 @@ class PDFRenderQueue {
this.current = { request };
try {
const image = await this.render(request);
- this.cache.set(request.key, image);
- request.resolve(image);
+ if (!this.destroyed) {
+ this.cache.set(request.key, image);
+ }
+ this.settle(request, (caller) => {
+ caller.resolve(image);
+ });
} catch (error: unknown) {
- request.reject(
- request.signal?.aborted === true || this.destroyed
- ? abortError()
- : error,
- );
+ const reason = this.destroyed ? destroyedError() : error;
+ this.settle(request, (caller) => {
+ caller.reject(reason);
+ });
} finally {
this.current = undefined;
}
@@ -321,7 +349,7 @@ class PDFRenderQueue {
if (this.current?.request === request) {
this.current.task = task;
}
- if (request.signal?.aborted === true || this.destroyed) {
+ if (request.callers.length === 0 || this.destroyed) {
task.cancel();
}
await task.promise;
From e78bc24e4cc8c2da33a9da77486e839cc285cdd0 Mon Sep 17 00:00:00 2001
From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com>
Date: Mon, 5 Oct 2026 10:45:00 +0200
Subject: [PATCH 03/17] address review + fix readme
---
README.md | 15 ++-------------
src/utils/pdf.spec.ts | 17 +++++++++++++++++
src/utils/pdf.ts | 29 ++++++++++++++++++++---------
3 files changed, 39 insertions(+), 22 deletions(-)
diff --git a/README.md b/README.md
index f673492..f8811db 100644
--- a/README.md
+++ b/README.md
@@ -93,7 +93,8 @@ function App() {
## PDF documents
-PDF pages are rendered in the browser with [pdf.js](https://mozilla.github.io/pdf.js/) (loaded lazily, in a Web Worker, the first time a PDF is processed). Pages are rendered **on demand**, so large documents can be displayed progressively instead of being rasterized up front.
+PDF pages are rendered in the browser with [pdf.js](https://mozilla.github.io/pdf.js/) (the first time a PDF is processed, it's loaded lazily in a Web Worker).
+Pages are now loaded by batches (5 initial pre-fetched pages, then 3 by 3 by default) to accomodate for very large documents.
### `usePDFDocument` hook
@@ -136,14 +137,6 @@ function Document({ file }) {
}
```
-- **`source`** : a URL / data URL string, `Blob`/`File`, `ArrayBuffer` or `Uint8Array`. Keep it referentially stable between renders; a new value closes the previous document and opens the new one.
-- **`options.prefetch`** (default `5`) : pages rendered as soon as the document opens.
-- **`options.batch`** (default `3`) : pages rendered together when `loadPage` asks for one that is not rendered yet.
-- **`options.resolution`** : target pixel count of each render (default 1.5 Mpx; use ~50 000 for thumbnails).
-- **`options.maxPages`** : reject documents with more pages (`TooManyPagesError`).
-
-The hook returns `status` (`idle` | `opening` | `ready` | `error`), `numPages`, `pages` (a **0-based** array with `undefined` for pages not rendered yet), `loading` (set of page indexes being rendered), `loadPage(index)`, `loadRange(from, to)`, `error` and the underlying `document` handle. Call `loadPage(index)` whenever a page comes into view: it is idempotent, renders that page with high priority and queues the rest of its batch. The document and every image URL it produced are released automatically when `source` changes or the component unmounts.
-
### `openPDF` (framework-agnostic)
For custom scheduling, use the handle directly. Page numbers are **1-based**, like pdf.js.
@@ -162,10 +155,6 @@ for await (const { pageNumber, image } of doc.getPages(2, 4)) {
await doc.destroy(); // revokes every blob: URL handed out by this handle
```
-`getPage` / `getPages` options: `resolution`, `output` (`'object-url'` default, or `'data-url'` for a base64 string), `priority` (`'high'` jumps ahead of pending renders, `'low'` appends) and `signal` (`AbortSignal`; rejects with an `AbortError`). Renders are cached per page and resolution for the lifetime of the handle.
-
-Images are `blob:` object URLs by default: they only live in the current tab until `destroy()`. To upload one, read it back first (`await fetch(url).then((r) => r.blob())`), or request `{ output: 'data-url' }`.
-
### `getPDFPageCount` / `getImagesFromPDF`
`getPDFPageCount(source)` returns the page count without rendering. `getImagesFromPDF` still works but is **deprecated**: it renders and holds every page in memory at once. Prefer `usePDFDocument` or `openPDF`.
diff --git a/src/utils/pdf.spec.ts b/src/utils/pdf.spec.ts
index 27193e5..f62e1a6 100644
--- a/src/utils/pdf.spec.ts
+++ b/src/utils/pdf.spec.ts
@@ -94,6 +94,23 @@ describe('openPDF', () => {
}).should('deep.equal', ['RangeError', 'RangeError']);
});
+ it('rejects an invalid range as a whole instead of rendering the valid part', () => {
+ run(async () => {
+ const opened = await openPDF(multiPage);
+ handle = opened;
+ const rejected = await Promise.all([
+ errorName(collect(opened.getPages(0, PAGE_COUNT))),
+ errorName(collect(opened.getPages(1, PAGE_COUNT + 1))),
+ errorName(collect(opened.getPages(1, Number.MAX_SAFE_INTEGER))),
+ errorName(collect(opened.getPages(3, 2))),
+ ]);
+ return rejected;
+ }).should(
+ 'deep.equal',
+ Array.from({ length: 4 }, () => 'RangeError'),
+ );
+ });
+
it('yields an inclusive 1-based range in order', () => {
run(async () => {
const opened = await openPDF(multiPage);
diff --git a/src/utils/pdf.ts b/src/utils/pdf.ts
index 623ca67..af6dba9 100644
--- a/src/utils/pdf.ts
+++ b/src/utils/pdf.ts
@@ -129,6 +129,18 @@ const tooManyPagesError = (): Error => {
const destroyedError = (): Error =>
new Error('This PDF document handle has been destroyed');
+const assertPageInRange = (pageNumber: number, numPages: number): void => {
+ if (
+ !Number.isInteger(pageNumber) ||
+ pageNumber < 1 ||
+ pageNumber > numPages
+ ) {
+ throw new RangeError(
+ `Page ${String(pageNumber)} is out of range (1-${String(numPages)})`,
+ );
+ }
+};
+
const toDocumentParameters = async (
source: PDFSource,
): Promise<{ url: string } | { data: Uint8Array }> => {
@@ -178,15 +190,7 @@ class PDFRenderQueue {
if (this.destroyed) {
throw destroyedError();
}
- if (
- !Number.isInteger(pageNumber) ||
- pageNumber < 1 ||
- pageNumber > this.document.numPages
- ) {
- throw new RangeError(
- `Page ${String(pageNumber)} is out of range (1-${String(this.document.numPages)})`,
- );
- }
+ assertPageInRange(pageNumber, this.document.numPages);
if (options.signal?.aborted === true) {
throw abortError();
}
@@ -395,6 +399,13 @@ export const openPDF = async (
await queue.request(pageNumber, pageOptions, 'high'),
getPages: (from, to, pageOptions = {}) => ({
async *[Symbol.asyncIterator]() {
+ assertPageInRange(from, pdf.numPages);
+ assertPageInRange(to, pdf.numPages);
+ if (to < from) {
+ throw new RangeError(
+ `Invalid page range: ${String(from)}-${String(to)}`,
+ );
+ }
const requests = Array.from({ length: to - from + 1 }, (_, index) => {
const pageNumber = from + index;
return {
From d42c8926e8f05bcb87d53195c399a70f9f83923b Mon Sep 17 00:00:00 2001
From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com>
Date: Mon, 5 Oct 2026 11:19:44 +0200
Subject: [PATCH 04/17] update doc
---
docs/docs/API/annotation-viewer-api.mdx | 6 +-
docs/docs/Tutorial/pdf-file.md | 55 ++++++++--
docs/docs/Utils/draw-layer.mdx | 2 +-
docs/docs/Utils/draw-shape.mdx | 2 +-
docs/docs/Utils/get-images-form-pdf-api.mdx | 18 +++-
docs/docs/Utils/get-pdf-page-count.mdx | 12 ++-
docs/docs/Utils/open-pdf.mdx | 99 ++++++++++++++++++
docs/docs/Utils/set-shape-config.mdx | 2 +-
docs/docs/Utils/use-pdf-document.mdx | 105 ++++++++++++++++++++
docs/docs/customization.md | 2 +-
docs/docs/intro.md | 12 ++-
docs/docs/migration.md | 34 ++++++-
12 files changed, 319 insertions(+), 30 deletions(-)
create mode 100644 docs/docs/Utils/open-pdf.mdx
create mode 100644 docs/docs/Utils/use-pdf-document.mdx
diff --git a/docs/docs/API/annotation-viewer-api.mdx b/docs/docs/API/annotation-viewer-api.mdx
index d726353..e5f392f 100644
--- a/docs/docs/API/annotation-viewer-api.mdx
+++ b/docs/docs/API/annotation-viewer-api.mdx
@@ -28,7 +28,7 @@ type AnnotationShape = {
}
```
-> **image** property should be either an URL object or a base64. If you have an object of type [File](https://developer.mozilla.org/fr/docs/Web/API/File) ( typically generated by file upload libraries) instead you may need to use [URL.createObjectURL()](https://developer.mozilla.org/fr/docs/Web/API/URL/createObjectURL) to generate a valid URL object.
+> **image** property should be either an URL (including `blob:` object URLs) or a base64 data URL. If you have an object of type [File](https://developer.mozilla.org/fr/docs/Web/API/File) (typically generated by file upload libraries), you may need to use [URL.createObjectURL()](https://developer.mozilla.org/fr/docs/Web/API/URL/createObjectURL) to generate a valid URL object instead. JPEG, PNG, TIFF (first page) and HEIC/HEIF (primary image) are supported; TIFF and HEIF are converted in the browser, the HEIF decoder being downloaded on first use. For PDF files, see [usePDFDocument](/docs/Utils/use-pdf-document).
:::caution
@@ -54,8 +54,8 @@ interface AnnotationViewerOptions {
onMouseLeave?: (polygon: Line) => void
onClick?: (polygon: Line) => void
zoom?: {
- modifier: number
- max: number
+ modifier: number // multiplier applied per wheel step
+ max: number // highest magnification, relative to the fitted image (1 = fits the viewer)
defaultZoom: number
}
}
diff --git a/docs/docs/Tutorial/pdf-file.md b/docs/docs/Tutorial/pdf-file.md
index fb060e2..eba272c 100644
--- a/docs/docs/Tutorial/pdf-file.md
+++ b/docs/docs/Tutorial/pdf-file.md
@@ -4,17 +4,54 @@ sidebar_position: 2
# PDF file
-> If you got a PDF file in hand, you can use [getImagesFromPDF](/docs/Utils/get-images-form-pdf-api) to extract pages in base64 format and pass them to `` using a proper state.
+> PDF pages are rendered in the browser on demand. Use the [usePDFDocument](/docs/Utils/use-pdf-document) hook to display a document progressively: the first pages are rendered right away, the rest only when the user reaches them.
```tsx
-getImagesFromPDF(pdfFile).then((images: string[]) => {
- console.log(images) // ===> [base64_1, base64_2, ...]
-})
+import { useState } from 'react'
+import { AnnotationViewer, usePDFDocument } from 'react-mindee-js'
+
+function PdfViewer({ file }: { file: File }) {
+ const { status, numPages, pages, loading, loadPage } = usePDFDocument(file)
+ const [current, setCurrent] = useState(0)
+
+ const goTo = (index: number) => {
+ setCurrent(index)
+ loadPage(index) // renders the page and the rest of its batch if needed
+ }
+
+ if (status !== 'ready') return {status}
+
+ return (
+ <>
+ goTo(current - 1)} disabled={current === 0}>
+ Previous
+
+
+ {current + 1} / {numPages}
+
+ goTo(current + 1)} disabled={current === numPages - 1}>
+ Next
+
+
+ {pages[current] ? (
+
+ ) : (
+ {loading.has(current) ? 'Rendering…' : 'Not loaded'}
+ )}
+ >
+ )
+}
```
-### Full example
+Outside React, or for custom scheduling, use [openPDF](/docs/Utils/open-pdf) directly:
+
+```ts
+const doc = await openPDF(file)
+const firstPage = await doc.getPage(1)
+// ...
+await doc.destroy()
+```
-
+:::caution
+Page images are `blob:` object URLs that live until the document is destroyed. If you need to send one to a server, read it back with `fetch(url).then((r) => r.blob())` first.
+:::
diff --git a/docs/docs/Utils/draw-layer.mdx b/docs/docs/Utils/draw-layer.mdx
index b5f75f0..af0d9ee 100644
--- a/docs/docs/Utils/draw-layer.mdx
+++ b/docs/docs/Utils/draw-layer.mdx
@@ -1,5 +1,5 @@
---
-sidebar_position: 4
+sidebar_position: 7
---
# drawLayer
diff --git a/docs/docs/Utils/draw-shape.mdx b/docs/docs/Utils/draw-shape.mdx
index 2a55804..35672f4 100644
--- a/docs/docs/Utils/draw-shape.mdx
+++ b/docs/docs/Utils/draw-shape.mdx
@@ -1,5 +1,5 @@
---
-sidebar_position: 2
+sidebar_position: 5
---
# drawShape
diff --git a/docs/docs/Utils/get-images-form-pdf-api.mdx b/docs/docs/Utils/get-images-form-pdf-api.mdx
index 4dd0e30..d893c2e 100644
--- a/docs/docs/Utils/get-images-form-pdf-api.mdx
+++ b/docs/docs/Utils/get-images-form-pdf-api.mdx
@@ -1,15 +1,23 @@
---
-sidebar_position: 1
+sidebar_position: 3
---
-# getImageFromPDF
+# getImagesFromPDF
-- Type : `(file: string) => Promise`
+:::caution Deprecated
+`getImagesFromPDF` renders and holds **every** page in memory at once. Use [openPDF](/docs/Utils/open-pdf) or the [usePDFDocument](/docs/Utils/use-pdf-document) hook to render pages progressively instead.
+:::
-> The parameter should be a valid Object URL of type string. If you have an object of type [File](https://developer.mozilla.org/fr/docs/Web/API/File) ( typically generated by file upload libraries) in hand you must use [URL.createObjectURL()](https://developer.mozilla.org/fr/docs/Web/API/URL/createObjectURL) to generate a valid URL object.
+- Type : `(source: PDFSource, maxPages?: number, onSuccess?: () => void, resolution?: number) => Promise`
+
+> Renders every page of a PDF to a PNG data URL. Pages that fail to render are skipped, so the result may be shorter than the page count. `source` accepts a URL string, a `File`/`Blob`, an `ArrayBuffer` or a `Uint8Array`.
```javascript
getImagesFromPDF(file).then((images) => {
- // Do something with images pdf pages
+ // images: ['data:image/png;base64,...', ...]
})
```
+
+- **`maxPages`** : rejects with a `TooManyPagesError` when the document has more pages.
+- **`onSuccess`** : called once the document has been accepted, before pages are rendered.
+- **`resolution`** : target pixel count of each page, see [PDFPageOptions](/docs/Utils/open-pdf#page-options).
diff --git a/docs/docs/Utils/get-pdf-page-count.mdx b/docs/docs/Utils/get-pdf-page-count.mdx
index 4d2c976..7c1388b 100644
--- a/docs/docs/Utils/get-pdf-page-count.mdx
+++ b/docs/docs/Utils/get-pdf-page-count.mdx
@@ -1,15 +1,19 @@
---
-sidebar_position: 2
+sidebar_position: 4
---
# getPDFPageCount
-- Type : `(file: string) => Promise`
+- Type PDFSource : `(source: string | Blob | ArrayBuffer | Uint8Array) => Promise`
-> The parameter should be a valid Object URL of type string. If you have an object of type [File](https://developer.mozilla.org/fr/docs/Web/API/File) ( typically generated by file upload libraries) in hand you must use [URL.createObjectURL()](https://developer.mozilla.org/fr/docs/Web/API/URL/createObjectURL) to generate a valid URL object.
+> Returns the number of pages without rendering anything.
```javascript
-getPDFPageCount(file).then((pageCount) => {
+getPDFPageCount(file: PDFSource).then((pageCount) => {
// Do something with the page count
})
```
+
+:::tip
+If you also need to render pages, call [openPDF](/docs/Utils/open-pdf) once and read `numPages` from the handle so the document is only parsed once.
+:::
diff --git a/docs/docs/Utils/open-pdf.mdx b/docs/docs/Utils/open-pdf.mdx
new file mode 100644
index 0000000..97460da
--- /dev/null
+++ b/docs/docs/Utils/open-pdf.mdx
@@ -0,0 +1,99 @@
+---
+sidebar_position: 1
+---
+
+# openPDF
+
+- Type : `(source: PDFSource, options?: OpenPDFOptions) => Promise`
+
+> Parses a PDF once and returns a handle from which pages are rendered **on demand**. Only the pages you ask for are rasterized, the requested one first, and memory is released by `destroy()`. This is the primitive behind [usePDFDocument](/docs/Utils/use-pdf-document) and the recommended way to display documents with more than a handful of pages.
+
+pdf.js and its Web Worker are downloaded lazily, the first time a PDF is processed, and a single worker is shared by every document in the tab.
+
+```ts
+import { openPDF } from 'react-mindee-js'
+
+const doc = await openPDF(file, { maxPages: 100 })
+doc.numPages // 5
+
+const first = await doc.getPage(1, { priority: 'high' }) // blob: URL
+for await (const { pageNumber, image } of doc.getPages(2, 4)) {
+ // inclusive range, yielded as soon as each page is ready
+}
+
+await doc.destroy() // revokes every blob: URL handed out by this handle
+```
+
+## Source
+
+`PDFSource = string | Blob | ArrayBuffer | Uint8Array`
+
+A URL or data URL string, a [File](https://developer.mozilla.org/docs/Web/API/File)/`Blob` (typically from a file input or upload library), or raw bytes. You no longer need `URL.createObjectURL()` to pass a `File`.
+
+## Options
+
+```ts
+interface OpenPDFOptions {
+ /** Rejects with a `TooManyPagesError` when the document has more pages. */
+ maxPages?: number
+}
+```
+
+## Handle
+
+```ts
+interface PDFDocumentHandle {
+ readonly numPages: number
+ /** Renders a single page. `pageNumber` is 1-based, like pdf.js. */
+ getPage: (pageNumber: number, options?: PDFPageOptions) => Promise
+ /** Renders pages `from` to `to` inclusive (1-based), yielding each page as soon as it is ready. */
+ getPages: (from: number, to: number, options?: PDFPageOptions) => AsyncIterable
+ /** Cancels pending renders, revokes every object URL and releases the pdf.js document. */
+ destroy: () => Promise
+}
+
+interface PDFRenderedPage {
+ pageNumber: number // 1-based
+ image: string
+}
+```
+
+:::caution
+Page numbers are **1-based**. `getPage(0)`, a page above `numPages` or an inverted range reject with a `RangeError` before anything is rendered.
+:::
+
+## Page options
+
+```ts
+interface PDFPageOptions {
+ resolution?: number // default 1 500 000 px (width × height)
+ output?: 'object-url' | 'data-url' // default 'object-url'
+ priority?: 'high' | 'low' // default 'high' for getPage, 'low' for getPages
+ signal?: AbortSignal
+}
+```
+
+- **`resolution`** : target pixel count of the render. The viewer displays 1.5 Mpx; use a much lower value such as `50_000` for thumbnails. Renders at different resolutions are cached independently.
+- **`output`** : `object-url` returns a `blob:` URL backed by a PNG blob, which is light to hold in memory and is revoked by `destroy()`. `data-url` returns a self-contained base64 PNG string.
+- **`priority`** : `high` puts the request ahead of everything still waiting, so a page the user explicitly asked for is drawn before background prefetches. Requesting an already queued page again with `high` promotes it.
+- **`signal`** : aborting removes the request from the queue (or cancels the in-progress render) and rejects **that caller's** promise with an `AbortError`. Other callers waiting on the same page are unaffected; the render itself is only cancelled once nobody waits for it.
+
+## Behaviour
+
+- Pages render one at a time per document, in priority order. Every successful render is cached per page, resolution and output for the lifetime of the handle.
+- Several calls for the same page share one render.
+- After `destroy()`, every pending call rejects and the handle is unusable.
+
+:::caution
+Images are `blob:` object URLs by default: they only exist in the current tab and only until `destroy()`. To upload one, read it back first (`await fetch(url).then((r) => r.blob())`) or request `{ output: 'data-url' }`.
+:::
+
+## Errors
+
+| `error.name` | Cause |
+| :-------------------- | :------------------------------------------------------ |
+| `InvalidPDFException` | The source is not a readable PDF |
+| `TooManyPagesError` | The document has more pages than `maxPages` |
+| `RangeError` | A page number or range is outside `1..numPages` |
+| `AbortError` | The request's `signal` was aborted |
+| `Error` | A render was requested after `destroy()` |
diff --git a/docs/docs/Utils/set-shape-config.mdx b/docs/docs/Utils/set-shape-config.mdx
index 57d0840..e6a4299 100644
--- a/docs/docs/Utils/set-shape-config.mdx
+++ b/docs/docs/Utils/set-shape-config.mdx
@@ -1,5 +1,5 @@
---
-sidebar_position: 3
+sidebar_position: 6
---
# setShapeConfig
diff --git a/docs/docs/Utils/use-pdf-document.mdx b/docs/docs/Utils/use-pdf-document.mdx
new file mode 100644
index 0000000..2c934ab
--- /dev/null
+++ b/docs/docs/Utils/use-pdf-document.mdx
@@ -0,0 +1,105 @@
+---
+sidebar_position: 2
+---
+
+# usePDFDocument
+
+- Type : `(source: PDFSource | null | undefined, options?: UsePDFDocumentOptions) => UsePDFDocumentResult`
+
+> React hook that opens a PDF with [openPDF](/docs/Utils/open-pdf) and renders its pages progressively: the first `prefetch` pages right away, then `batch` pages at a time whenever `loadPage` is called for a page that is not rendered yet. The document and every image URL it produced are released automatically when `source` changes or the component unmounts.
+
+```tsx
+import { useState } from 'react'
+import { AnnotationViewer, usePDFDocument } from 'react-mindee-js'
+
+function Document({ file }: { file: File }) {
+ const { status, pages, loading, loadPage, error } = usePDFDocument(file, {
+ prefetch: 5,
+ batch: 3,
+ })
+ const [current, setCurrent] = useState(0)
+
+ if (status === 'error') return {error?.message}
+ if (status !== 'ready') return Opening…
+
+ return (
+ <>
+
+ {pages.map((image, index) => (
+ {
+ setCurrent(index)
+ loadPage(index)
+ }}
+ >
+ {loading.has(index) ? '…' : index + 1}
+
+ ))}
+
+ {pages[current] && (
+
+ )}
+ >
+ )
+}
+```
+
+## Options
+
+```ts
+interface UsePDFDocumentOptions {
+ prefetch?: number // default 5
+ batch?: number // default 3
+ resolution?: number // see PDFPageOptions.resolution
+ maxPages?: number // see OpenPDFOptions.maxPages
+}
+```
+
+- **`prefetch`** : pages rendered as soon as the document opens.
+- **`batch`** : pages rendered together when `loadPage` asks for one that is not rendered yet. The first `prefetch` pages form one block, the remaining pages are grouped `batch` at a time after it.
+
+## Result
+
+```ts
+interface UsePDFDocumentResult {
+ status: 'idle' | 'opening' | 'ready' | 'error'
+ error: Error | null
+ numPages: number
+ pages: (string | undefined)[]
+ loading: ReadonlySet
+ loadPage: (index: number) => void
+ loadRange: (from: number, to: number) => void
+ document: PDFDocumentHandle | null
+}
+```
+
+- **`pages`** : one slot per page, **0-based**. A slot is `undefined` until the page has been rendered, then holds an image URL usable as `data.image`.
+- **`loading`** : indexes whose render is queued or in progress, handy for per-page skeletons.
+- **`loadPage(index)`** : ensures the page and the rest of its batch are rendered. Idempotent and cheap, so call it liberally (on scroll, click, keyboard navigation…). The page itself is requested with high priority so it is drawn before any pending prefetch, even if it was already queued.
+- **`loadRange(from, to)`** : renders every page in `[from, to)` with low priority.
+- **`document`** : the underlying handle for advanced use, `null` until `status` is `ready`.
+
+:::caution
+The hook is **0-based** (array semantics); the handle is **1-based** (pdf.js semantics). Do not mix them up when using `document` directly.
+:::
+
+:::tip
+Keep `source` referentially stable between renders (store the `File` in state, do not rebuild a data URL on every render), otherwise the hook closes and reopens the document each time. Changing `maxPages` also reopens the document.
+:::
+
+### Example: load pages as they scroll into view
+
+```tsx
+const { pages, loadPage } = usePDFDocument(file)
+
+useEffect(() => {
+ const observer = new IntersectionObserver((entries) => {
+ entries
+ .filter((entry) => entry.isIntersecting)
+ .forEach((entry) => loadPage(Number(entry.target.dataset.index)))
+ })
+ document.querySelectorAll('[data-index]').forEach((el) => observer.observe(el))
+ return () => observer.disconnect()
+}, [pages.length, loadPage])
+```
diff --git a/docs/docs/customization.md b/docs/docs/customization.md
index 92a64ef..6cd35a3 100644
--- a/docs/docs/customization.md
+++ b/docs/docs/customization.md
@@ -24,7 +24,7 @@ sidebar_position: 6
individual shape [**config**](https://konvajs.org/api/Konva.Shape.html) passed on each item of the list of shapes override the **shapeConfig** property
:::
-- `zoom`:
+- `zoom`: wheel zoom settings. `modifier` is the multiplier applied per wheel step; `max` is the highest magnification reachable (`1` = fits, `3` = three times larger), independently of the image pixel size; `defaultZoom` is the initial level.
##### Example
diff --git a/docs/docs/intro.md b/docs/docs/intro.md
index 2a1aabb..a90f581 100644
--- a/docs/docs/intro.md
+++ b/docs/docs/intro.md
@@ -16,7 +16,7 @@ This library was made for building frontend interfaces on top of **[Mindee](http
## Features
-- Support for image and PDF files
+- Support for image (JPEG, PNG, TIFF, HEIC/HEIF) and PDF files, with progressive page rendering for PDFs
- Interactive shapes with events binding
- Extensible styling API
- Controllable state props and modular architecture
@@ -25,20 +25,20 @@ This library was made for building frontend interfaces on top of **[Mindee](http
## Compatibility
-The React SDK is compatible with `React 16.8.0 +`
+The React SDK is compatible with `React 18` and `React 19`, and requires `Node.js >= 22.13` to build.
## Installation and dependencies
The easiest way to use react-select is to install it from npm and build it into your app with Webpack.
```bash
-npm install --save react-mindee-js@1.3.0
+npm install --save react-mindee-js
```
or using yarn
```
-yarn add react-mindee-js@1.3.0
+yarn add react-mindee-js
```
## Usage
@@ -94,6 +94,10 @@ function App() {
- **`style`** : style object to change container css properties
- **`className`** : apply a className to the control
+## PDF documents
+
+PDF pages are rendered on demand with the [usePDFDocument](/docs/Utils/use-pdf-document) hook or the lower-level [openPDF](/docs/Utils/open-pdf) handle. See the [PDF tutorial](/docs/Tutorial/pdf-file).
+
## Browser support
React mindee supports all recent browsers and works where React works. However, you may need check the [SSR](/docs/ssr) section.
diff --git a/docs/docs/migration.md b/docs/docs/migration.md
index fff6dd6..901ecb5 100644
--- a/docs/docs/migration.md
+++ b/docs/docs/migration.md
@@ -2,7 +2,39 @@
sidebar_position: 5
---
-# Migration from v1.2 to v1.3
+# Migration
+
+## From v1.x to v2.0
+
+### Requirements
+
+- `Node.js >= 22.13` to build. The published package targets modern browsers and is compatible with React 18 and 19.
+- The package is ESM only. pdf.js and the HEIC decoder are loaded lazily as separate chunks, so your bundler must support dynamic `import()` (every mainstream bundler does).
+
+### `zoom.max` is now relative to the fitted image
+
+Previously `options.zoom.max` was compared against the absolute canvas scale, so the reachable magnification depended on the pixel size of the image. It is now a factor relative to the image **fitted in the container** (`1` = fits), like `defaultZoom`. If you relied on the old behaviour, lower large values (a `max` of `10` now means "ten times the fitted size").
+
+### PDF helpers
+
+| v1 | v2 |
+|:---------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------|
+| `getImagesFromPDF(url)` renders every page | **Deprecated.** Use [usePDFDocument](/docs/Utils/use-pdf-document) or [openPDF](/docs/Utils/open-pdf) to render on demand |
+| `getImagesFromPDF(url, maxPages, onSuccess)` | Still supported, now also accepts a `File`/`Blob`/`ArrayBuffer` and a trailing `resolution` |
+| `getPDFPageCount(url)` | Unchanged, also accepts a `File`/`Blob`/`ArrayBuffer` |
+| URL string required | Any `PDFSource`; `URL.createObjectURL()` is no longer needed for a `File` |
+| Pages returned as base64 data URLs | New APIs return `blob:` object URLs by default (lighter, revoked on `destroy()`); pass `{ output: 'data-url' }` for base64 |
+
+### Image formats
+
+- HEIC/HEIF files are now detected by every HEIF MIME type (`image/heic`, `image/heif` and their `-sequence` variants); previously only `image/heic` was converted.
+- The HEIC decoder (`heic2any`) is loaded lazily, only when such a file is displayed.
+
+### Removed
+
+- `loadPdfDocument` (internal pdf.js loader) is no longer exported. Use `openPDF`.
+
+## From v1.2 to v1.3
> Version 1.3 API becomes simpler. Here is the changes :
From 3c4638d16ef38ff48102ce05ddf747852ecad6a9 Mon Sep 17 00:00:00 2001
From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com>
Date: Mon, 5 Oct 2026 11:30:29 +0200
Subject: [PATCH 05/17] fix typos
---
README.md | 2 +-
docs/docs/Utils/get-pdf-page-count.mdx | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index f8811db..228c6af 100644
--- a/README.md
+++ b/README.md
@@ -94,7 +94,7 @@ function App() {
## PDF documents
PDF pages are rendered in the browser with [pdf.js](https://mozilla.github.io/pdf.js/) (the first time a PDF is processed, it's loaded lazily in a Web Worker).
-Pages are now loaded by batches (5 initial pre-fetched pages, then 3 by 3 by default) to accomodate for very large documents.
+Pages are now loaded by batches (5 initial pre-fetched pages, then 3 by 3 by default) to accommodate for very large documents.
### `usePDFDocument` hook
diff --git a/docs/docs/Utils/get-pdf-page-count.mdx b/docs/docs/Utils/get-pdf-page-count.mdx
index 7c1388b..e3627c4 100644
--- a/docs/docs/Utils/get-pdf-page-count.mdx
+++ b/docs/docs/Utils/get-pdf-page-count.mdx
@@ -9,7 +9,7 @@ sidebar_position: 4
> Returns the number of pages without rendering anything.
```javascript
-getPDFPageCount(file: PDFSource).then((pageCount) => {
+getPDFPageCount(file).then((pageCount) => {
// Do something with the page count
})
```
From 8bea751f6e304f6293b69c6f27ec29de171618b6 Mon Sep 17 00:00:00 2001
From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com>
Date: Mon, 5 Oct 2026 11:48:54 +0200
Subject: [PATCH 06/17] address review
---
docs/docs/Utils/open-pdf.mdx | 5 +--
docs/docs/Utils/use-pdf-document.mdx | 2 +-
src/hooks/usePDFDocument.spec.tsx | 54 ++++++++++++++++++++++++++++
src/hooks/usePDFDocument.ts | 54 +++++++++++++++++++++++-----
src/utils/image.spec.ts | 7 +++-
src/utils/pdf.spec.ts | 29 +++++++++++++++
src/utils/pdf.ts | 29 +++++++++------
7 files changed, 156 insertions(+), 24 deletions(-)
diff --git a/docs/docs/Utils/open-pdf.mdx b/docs/docs/Utils/open-pdf.mdx
index 97460da..3f1087d 100644
--- a/docs/docs/Utils/open-pdf.mdx
+++ b/docs/docs/Utils/open-pdf.mdx
@@ -46,7 +46,7 @@ interface PDFDocumentHandle {
readonly numPages: number
/** Renders a single page. `pageNumber` is 1-based, like pdf.js. */
getPage: (pageNumber: number, options?: PDFPageOptions) => Promise
- /** Renders pages `from` to `to` inclusive (1-based), yielding each page as soon as it is ready. */
+ /** Renders pages `from` to `to` inclusive (1-based) one after the other, yielding each as soon as it is ready. */
getPages: (from: number, to: number, options?: PDFPageOptions) => AsyncIterable
/** Cancels pending renders, revokes every object URL and releases the pdf.js document. */
destroy: () => Promise
@@ -82,7 +82,8 @@ interface PDFPageOptions {
- Pages render one at a time per document, in priority order. Every successful render is cached per page, resolution and output for the lifetime of the handle.
- Several calls for the same page share one render.
-- After `destroy()`, every pending call rejects and the handle is unusable.
+- `getPages` requests one page at a time, so breaking out of the `for await` loop leaves nothing pending.
+- After `destroy()`, every pending call rejects (even a render that was about to complete) and the handle is unusable.
:::caution
Images are `blob:` object URLs by default: they only exist in the current tab and only until `destroy()`. To upload one, read it back first (`await fetch(url).then((r) => r.blob())`) or request `{ output: 'data-url' }`.
diff --git a/docs/docs/Utils/use-pdf-document.mdx b/docs/docs/Utils/use-pdf-document.mdx
index 2c934ab..4ce949e 100644
--- a/docs/docs/Utils/use-pdf-document.mdx
+++ b/docs/docs/Utils/use-pdf-document.mdx
@@ -85,7 +85,7 @@ The hook is **0-based** (array semantics); the handle is **1-based** (pdf.js sem
:::
:::tip
-Keep `source` referentially stable between renders (store the `File` in state, do not rebuild a data URL on every render), otherwise the hook closes and reopens the document each time. Changing `maxPages` also reopens the document.
+Keep `source` referentially stable between renders (store the `File` in state, do not rebuild a data URL on every render), otherwise the hook closes and reopens the document each time. Changing `maxPages` also reopens the document. Changing `resolution` keeps the document but clears `pages` and re-renders them at the new value (renders are cached per resolution, so switching back is instant).
:::
### Example: load pages as they scroll into view
diff --git a/src/hooks/usePDFDocument.spec.tsx b/src/hooks/usePDFDocument.spec.tsx
index 2e5d8d8..9bd2b63 100644
--- a/src/hooks/usePDFDocument.spec.tsx
+++ b/src/hooks/usePDFDocument.spec.tsx
@@ -59,6 +59,18 @@ const mountHarness = (
};
};
+const imageSize = async (url: string): Promise =>
+ await new Promise((resolve, reject) => {
+ const image = new Image();
+ image.onload = () => {
+ resolve(image.naturalWidth * image.naturalHeight);
+ };
+ image.onerror = () => {
+ reject(new Error(`Could not decode ${url}`));
+ };
+ image.src = url;
+ });
+
const readyCount = (result: UsePDFDocumentResult): number =>
result.pages.filter((page) => page !== undefined).length;
@@ -178,6 +190,48 @@ describe('usePDFDocument', () => {
cy.get('[data-cy=count]').should('have.text', '0');
});
+ it('re-renders every page at the new resolution when the option changes', () => {
+ const Resizer = (): JSX.Element => {
+ const [resolution, setResolution] = useState(100_000);
+ const result = usePDFDocument(multiPage, { prefetch: 2, resolution });
+ return (
+
+ {result.status}
+
+ {String(result.pages.filter((page) => page !== undefined).length)}
+
+ {result.pages[0] ?? ''}
+ {
+ setResolution(400_000);
+ }}
+ >
+ grow
+
+
+ );
+ };
+ cy.mount( );
+ cy.get('[data-cy=ready]').should('have.text', '2');
+ cy.get('[data-cy=first]')
+ .invoke('text')
+ .then((before) => {
+ cy.get('[data-cy=grow]').click();
+ cy.get('[data-cy=ready]').should('have.text', '2');
+ cy.get('[data-cy=first]')
+ .invoke('text')
+ .should('not.equal', before)
+ .then(async (after) => {
+ const [small, large] = await Promise.all([
+ imageSize(before),
+ imageSize(after),
+ ]);
+ expect(large).to.be.greaterThan(small);
+ });
+ });
+ });
+
it('reports an error status for an unreadable source', () => {
const harness = mountHarness('data:application/pdf;base64,AAAA');
cy.wrap(null).should(() => {
diff --git a/src/hooks/usePDFDocument.ts b/src/hooks/usePDFDocument.ts
index 664b035..6eb2c9b 100644
--- a/src/hooks/usePDFDocument.ts
+++ b/src/hooks/usePDFDocument.ts
@@ -12,7 +12,11 @@ export type UsePDFDocumentOptions = {
prefetch?: number;
/** Pages rendered together when one of them is requested. Defaults to 3. */
batch?: number;
- /** Rendering resolution in pixels, see `PDFPageOptions.resolution`. */
+ /**
+ * Rendering resolution in pixels, see `PDFPageOptions.resolution`.
+ * Changing it clears `pages` and re-renders them at the new value; renders
+ * are cached per resolution, so switching back is instant.
+ */
resolution?: number;
/** Rejects documents with more pages, see `OpenPDFOptions.maxPages`. */
maxPages?: number;
@@ -52,6 +56,7 @@ const EMPTY_SET: ReadonlySet = new Set();
type State = {
source: PDFSource | null | undefined;
maxPages: number | undefined;
+ resolution: number | undefined;
status: UsePDFDocumentStatus;
error: Error | null;
document: PDFDocumentHandle | null;
@@ -59,12 +64,23 @@ type State = {
loading: ReadonlySet;
};
+/**
+ * Pages requested so far, tagged with the resolution they were requested at
+ * so that a resolution change starts over without re-rendering twice.
+ */
+type Requested = {
+ resolution: number | undefined;
+ indexes: Set;
+};
+
const initialState = (
source: PDFSource | null | undefined,
maxPages: number | undefined,
+ resolution: number | undefined,
): State => ({
source,
maxPages,
+ resolution,
status: source === null || source === undefined ? 'idle' : 'opening',
error: null,
document: null,
@@ -116,16 +132,25 @@ export const usePDFDocument = (
} = options;
const [state, setState] = useState(() =>
- initialState(source, maxPages),
+ initialState(source, maxPages, resolution),
);
- const requested = useRef(new Set());
+ const requested = useRef({ resolution, indexes: new Set() });
if (state.source !== source || state.maxPages !== maxPages) {
- setState(initialState(source, maxPages));
+ setState(initialState(source, maxPages, resolution));
+ } else if (state.resolution !== resolution) {
+ setState((previous) => ({
+ ...previous,
+ resolution,
+ pages: new Array(previous.pages.length).fill(
+ undefined,
+ ),
+ loading: EMPTY_SET,
+ }));
}
useEffect(() => {
- requested.current = new Set();
+ requested.current.indexes = new Set();
if (source === null || source === undefined) {
return undefined;
}
@@ -166,10 +191,15 @@ export const usePDFDocument = (
const render = useCallback(
(index: number, pageOptions: PDFPageOptions) => {
- if (document === null || requested.current.has(index)) {
+ const tracker = requested.current;
+ if (tracker.resolution !== resolution) {
+ tracker.resolution = resolution;
+ tracker.indexes = new Set();
+ }
+ if (document === null || tracker.indexes.has(index)) {
return;
}
- requested.current.add(index);
+ tracker.indexes.add(index);
setState((previous) => ({
...previous,
loading: new Set(previous.loading).add(index),
@@ -177,6 +207,9 @@ export const usePDFDocument = (
document
.getPage(index + 1, { ...pageOptions, resolution })
.then((image) => {
+ if (requested.current.resolution !== resolution) {
+ return;
+ }
setState((previous) => {
const pages = [...previous.pages];
pages[index] = image;
@@ -184,7 +217,7 @@ export const usePDFDocument = (
});
})
.catch((reason: unknown) => {
- requested.current.delete(index);
+ tracker.indexes.delete(index);
if (toError(reason).name !== 'AbortError') {
setState((previous) => ({ ...previous, error: toError(reason) }));
}
@@ -219,7 +252,10 @@ export const usePDFDocument = (
if (document === null || index < 0 || index >= document.numPages) {
return;
}
- if (requested.current.has(index)) {
+ if (
+ requested.current.resolution === resolution &&
+ requested.current.indexes.has(index)
+ ) {
void document
.getPage(index + 1, { priority: 'high', resolution })
.catch(() => undefined);
diff --git a/src/utils/image.spec.ts b/src/utils/image.spec.ts
index b67efab..ce4f9df 100644
--- a/src/utils/image.spec.ts
+++ b/src/utils/image.spec.ts
@@ -16,7 +16,12 @@ describe('image', () => {
return URL.createObjectURL(new Blob([blob], { type }));
};
- ['image/heic', 'image/heif', 'image/heif-sequence'].forEach((type) => {
+ [
+ 'image/heic',
+ 'image/heif',
+ 'image/heif-sequence',
+ 'image/heic-sequence',
+ ].forEach((type) => {
it(`converts a ${type} blob to a JPEG data URL`, () => {
cy.then(
async () => await prepareImage(await withMimeType(type)),
diff --git a/src/utils/pdf.spec.ts b/src/utils/pdf.spec.ts
index f62e1a6..b20f6a3 100644
--- a/src/utils/pdf.spec.ts
+++ b/src/utils/pdf.spec.ts
@@ -254,6 +254,35 @@ describe('openPDF', () => {
});
});
+ it('rejects a render that completes after destroy instead of resolving it', () => {
+ run(async () => {
+ const opened = await openPDF(multiPage);
+ const pending = errorName(opened.getPage(1));
+ await opened.destroy();
+ return await pending;
+ }).should('not.equal', 'resolved');
+ });
+
+ it('renders getPages lazily so stopping early leaves nothing queued', () => {
+ run(async () => {
+ const opened = await openPDF(multiPage);
+ handle = opened;
+ for await (const page of opened.getPages(1, PAGE_COUNT)) {
+ if (page.pageNumber === 1) {
+ break;
+ }
+ }
+ const order: number[] = [];
+ await Promise.all(
+ [5, 2].map(async (pageNumber) => {
+ await opened.getPage(pageNumber, { priority: 'low' });
+ order.push(pageNumber);
+ }),
+ );
+ return order;
+ }).should('deep.equal', [5, 2]);
+ });
+
it('rejects documents above maxPages with a TooManyPagesError', () => {
cy.wrap(errorName(openPDF(multiPage, { maxPages: 3 }))).should(
'equal',
diff --git a/src/utils/pdf.ts b/src/utils/pdf.ts
index af6dba9..1fc2dde 100644
--- a/src/utils/pdf.ts
+++ b/src/utils/pdf.ts
@@ -74,8 +74,9 @@ export type PDFDocumentHandle = {
/** Renders a single page. `pageNumber` is 1-based, like pdf.js. */
getPage: (pageNumber: number, options?: PDFPageOptions) => Promise;
/**
- * Renders pages `from` to `to` inclusive (1-based), yielding each page as
- * soon as it is ready. Iterate with `for await`.
+ * Renders pages `from` to `to` inclusive (1-based) one after the other,
+ * yielding each page as soon as it is ready. Iterate with `for await`;
+ * stopping early leaves no pending render behind.
*/
getPages: (
from: number,
@@ -293,6 +294,14 @@ class PDFRenderQueue {
}
}
+ /** Releases an image produced after `destroy()` so it does not leak. */
+ private discard(request: RenderRequest, image: string): void {
+ if (request.output === 'object-url') {
+ this.objectUrls.delete(image);
+ URL.revokeObjectURL(image);
+ }
+ }
+
/** Settles every remaining caller and releases the request. */
private settle(
request: RenderRequest,
@@ -316,9 +325,11 @@ class PDFRenderQueue {
this.current = { request };
try {
const image = await this.render(request);
- if (!this.destroyed) {
- this.cache.set(request.key, image);
+ if (this.destroyed) {
+ this.discard(request, image);
+ throw destroyedError();
}
+ this.cache.set(request.key, image);
this.settle(request, (caller) => {
caller.resolve(image);
});
@@ -406,15 +417,11 @@ export const openPDF = async (
`Invalid page range: ${String(from)}-${String(to)}`,
);
}
- const requests = Array.from({ length: to - from + 1 }, (_, index) => {
- const pageNumber = from + index;
- return {
+ for (let pageNumber = from; pageNumber <= to; pageNumber += 1) {
+ yield {
pageNumber,
- image: queue.request(pageNumber, pageOptions, 'low'),
+ image: await queue.request(pageNumber, pageOptions, 'low'),
};
- });
- for (const { pageNumber, image } of requests) {
- yield { pageNumber, image: await image };
}
},
}),
From 3d5730d005b010a399c025bfefceeb8c54ac09a8 Mon Sep 17 00:00:00 2001
From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com>
Date: Mon, 5 Oct 2026 12:01:35 +0200
Subject: [PATCH 07/17] address review
---
src/hooks/usePDFDocument.spec.tsx | 35 +++++++++++++++++++++++++++++++
src/utils/pdf.spec.ts | 11 ++++++++++
src/utils/pdf.ts | 35 ++++++++++++++++++++-----------
3 files changed, 69 insertions(+), 12 deletions(-)
diff --git a/src/hooks/usePDFDocument.spec.tsx b/src/hooks/usePDFDocument.spec.tsx
index 9bd2b63..325b78e 100644
--- a/src/hooks/usePDFDocument.spec.tsx
+++ b/src/hooks/usePDFDocument.spec.tsx
@@ -232,6 +232,41 @@ describe('usePDFDocument', () => {
});
});
+ it('switches directly from one source to another without erroring', () => {
+ const Switcher = (): JSX.Element => {
+ const [source, setSource] = useState(multiPage);
+ const result = usePDFDocument(source, { prefetch: 1 });
+ return (
+
+ {result.status}
+ {result.pages[0] ?? ''}
+ {
+ void fetch(multiPage)
+ .then(async (response) => await response.blob())
+ .then(setSource);
+ }}
+ >
+ swap
+
+
+ );
+ };
+ cy.mount( );
+ cy.get('[data-cy=first]')
+ .should('not.have.text', '')
+ .invoke('text')
+ .then((before) => {
+ cy.get('[data-cy=swap]').click();
+ cy.get('[data-cy=status]').should('have.text', 'ready');
+ cy.get('[data-cy=first]')
+ .should('not.have.text', '')
+ .should('not.have.text', before);
+ cy.get('[data-cy=status]').should('have.text', 'ready');
+ });
+ });
+
it('reports an error status for an unreadable source', () => {
const harness = mountHarness('data:application/pdf;base64,AAAA');
cy.wrap(null).should(() => {
diff --git a/src/utils/pdf.spec.ts b/src/utils/pdf.spec.ts
index b20f6a3..5c9b17e 100644
--- a/src/utils/pdf.spec.ts
+++ b/src/utils/pdf.spec.ts
@@ -283,6 +283,17 @@ describe('openPDF', () => {
}).should('deep.equal', [5, 2]);
});
+ it('opens a new document while the previous one is still being destroyed', () => {
+ run(async () => {
+ const previous = await openPDF(multiPage);
+ const destroying = previous.destroy();
+ const next = await openPDF(multiPage);
+ handle = next;
+ await destroying;
+ return next.numPages;
+ }).should('equal', PAGE_COUNT);
+ });
+
it('rejects documents above maxPages with a TooManyPagesError', () => {
cy.wrap(errorName(openPDF(multiPage, { maxPages: 3 }))).should(
'equal',
diff --git a/src/utils/pdf.ts b/src/utils/pdf.ts
index 1fc2dde..a4de7c2 100644
--- a/src/utils/pdf.ts
+++ b/src/utils/pdf.ts
@@ -1,25 +1,33 @@
import type * as Pdfjs from 'pdfjs-dist';
-import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist';
+import type { PDFDocumentProxy, PDFWorker, RenderTask } from 'pdfjs-dist';
import { MAX_PDF_SCALE, PDF_RESOLUTION } from '@/common/constants';
-let pdfjsModule: Promise | undefined;
+type PdfjsRuntime = { pdfjs: typeof Pdfjs; worker: PDFWorker };
+
+let runtime: Promise | undefined;
/**
* Loads pdf.js and its worker on first use, so consumers need no CDN access or
* manual worker configuration. Both live in lazy chunks outside the main bundle.
+ *
+ * The worker is wrapped in one long-lived `PDFWorker` handed explicitly to
+ * every `getDocument` call. Documents therefore never own the worker: pdf.js
+ * only flags a worker as "being destroyed" (making the next `getDocument`
+ * throw until the destroy settles) when it created the wrapper itself, which
+ * would otherwise break opening a document right after closing another.
*/
-const loadPdfjs = async (): Promise => {
- pdfjsModule ??= Promise.all([import('pdfjs-dist'), import('./pdfWorker')])
- .then(([pdfjs, { createPdfWorker }]) => {
- pdfjs.GlobalWorkerOptions.workerPort = createPdfWorker();
- return pdfjs;
- })
+const loadPdfjs = async (): Promise => {
+ runtime ??= Promise.all([import('pdfjs-dist'), import('./pdfWorker')])
+ .then(([pdfjs, { createPdfWorker }]) => ({
+ pdfjs,
+ worker: pdfjs.PDFWorker.create({ port: createPdfWorker() }),
+ }))
.catch((error: unknown) => {
- pdfjsModule = undefined;
+ runtime = undefined;
throw error;
});
- return await pdfjsModule;
+ return await runtime;
};
export type PDFSource = string | Blob | ArrayBuffer | Uint8Array;
@@ -396,8 +404,11 @@ export const openPDF = async (
source: PDFSource,
options: OpenPDFOptions = {},
): Promise => {
- const { getDocument } = await loadPdfjs();
- const pdf = await getDocument(await toDocumentParameters(source)).promise;
+ const { pdfjs, worker } = await loadPdfjs();
+ const pdf = await pdfjs.getDocument({
+ ...(await toDocumentParameters(source)),
+ worker,
+ }).promise;
if (options.maxPages !== undefined && pdf.numPages > options.maxPages) {
await pdf.loadingTask.destroy();
throw tooManyPagesError();
From e40a542889b8776a9531269bd8df0432d8679c8f Mon Sep 17 00:00:00 2001
From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com>
Date: Mon, 5 Oct 2026 12:20:03 +0200
Subject: [PATCH 08/17] address review again
---
src/hooks/usePDFDocument.spec.tsx | 32 +++++++++++++++++
src/hooks/usePDFDocument.ts | 39 ++++++++++++++++----
src/utils/pdf.spec.ts | 59 +++++++++++++++++++++++++++++++
src/utils/pdf.ts | 22 +++++++-----
4 files changed, 137 insertions(+), 15 deletions(-)
diff --git a/src/hooks/usePDFDocument.spec.tsx b/src/hooks/usePDFDocument.spec.tsx
index 325b78e..26c2323 100644
--- a/src/hooks/usePDFDocument.spec.tsx
+++ b/src/hooks/usePDFDocument.spec.tsx
@@ -267,6 +267,38 @@ describe('usePDFDocument', () => {
});
});
+ it('ignores renders of the previous document after a source swap mid-prefetch', () => {
+ const Swapper = (): JSX.Element => {
+ const [source, setSource] = useState(multiPage);
+ const result = usePDFDocument(source, { prefetch: 5 });
+ return (
+
+ {result.status}
+ {result.error?.message ?? 'none'}
+ {String(result.loading.size)}
+ {String(readyCount(result))}
+ {
+ void fetch(multiPage)
+ .then(async (response) => await response.blob())
+ .then(setSource);
+ }}
+ >
+ swap
+
+
+ );
+ };
+ cy.mount( );
+ cy.get('[data-cy=status]').should('have.text', 'ready');
+ cy.get('[data-cy=swap]').click();
+ cy.get('[data-cy=ready]').should('have.text', String(PAGE_COUNT));
+ cy.get('[data-cy=loading]').should('have.text', '0');
+ cy.get('[data-cy=error]').should('have.text', 'none');
+ cy.get('[data-cy=status]').should('have.text', 'ready');
+ });
+
it('reports an error status for an unreadable source', () => {
const harness = mountHarness('data:application/pdf;base64,AAAA');
cy.wrap(null).should(() => {
diff --git a/src/hooks/usePDFDocument.ts b/src/hooks/usePDFDocument.ts
index 6eb2c9b..dee8bb7 100644
--- a/src/hooks/usePDFDocument.ts
+++ b/src/hooks/usePDFDocument.ts
@@ -65,10 +65,13 @@ type State = {
};
/**
- * Pages requested so far, tagged with the resolution they were requested at
- * so that a resolution change starts over without re-rendering twice.
+ * Pages requested so far, tagged with the document and resolution they were
+ * requested for. A change of either starts a new generation; callbacks from
+ * an older generation are ignored so a closed document cannot touch the
+ * state of its replacement.
*/
type Requested = {
+ document: PDFDocumentHandle | null;
resolution: number | undefined;
indexes: Set;
};
@@ -134,7 +137,11 @@ export const usePDFDocument = (
const [state, setState] = useState(() =>
initialState(source, maxPages, resolution),
);
- const requested = useRef({ resolution, indexes: new Set() });
+ const requested = useRef({
+ document: null,
+ resolution,
+ indexes: new Set(),
+ });
if (state.source !== source || state.maxPages !== maxPages) {
setState(initialState(source, maxPages, resolution));
@@ -150,13 +157,17 @@ export const usePDFDocument = (
}
useEffect(() => {
- requested.current.indexes = new Set();
if (source === null || source === undefined) {
return undefined;
}
let handle: PDFDocumentHandle | undefined;
let cancelled = false;
+ requested.current = {
+ ...requested.current,
+ document: null,
+ indexes: new Set(),
+ };
openPDF(source, { maxPages })
.then(async (opened) => {
if (cancelled) {
@@ -191,15 +202,22 @@ export const usePDFDocument = (
const render = useCallback(
(index: number, pageOptions: PDFPageOptions) => {
+ if (document === null) {
+ return;
+ }
const tracker = requested.current;
- if (tracker.resolution !== resolution) {
+ if (tracker.document !== document || tracker.resolution !== resolution) {
+ tracker.document = document;
tracker.resolution = resolution;
tracker.indexes = new Set();
}
- if (document === null || tracker.indexes.has(index)) {
+ if (tracker.indexes.has(index)) {
return;
}
tracker.indexes.add(index);
+ const stale = (): boolean =>
+ requested.current.document !== document ||
+ requested.current.resolution !== resolution;
setState((previous) => ({
...previous,
loading: new Set(previous.loading).add(index),
@@ -207,7 +225,7 @@ export const usePDFDocument = (
document
.getPage(index + 1, { ...pageOptions, resolution })
.then((image) => {
- if (requested.current.resolution !== resolution) {
+ if (stale()) {
return;
}
setState((previous) => {
@@ -217,12 +235,18 @@ export const usePDFDocument = (
});
})
.catch((reason: unknown) => {
+ if (stale()) {
+ return;
+ }
tracker.indexes.delete(index);
if (toError(reason).name !== 'AbortError') {
setState((previous) => ({ ...previous, error: toError(reason) }));
}
})
.finally(() => {
+ if (stale()) {
+ return;
+ }
setState((previous) => {
const loading = new Set(previous.loading);
loading.delete(index);
@@ -253,6 +277,7 @@ export const usePDFDocument = (
return;
}
if (
+ requested.current.document === document &&
requested.current.resolution === resolution &&
requested.current.indexes.has(index)
) {
diff --git a/src/utils/pdf.spec.ts b/src/utils/pdf.spec.ts
index 5c9b17e..21186d6 100644
--- a/src/utils/pdf.spec.ts
+++ b/src/utils/pdf.spec.ts
@@ -55,6 +55,45 @@ describe('openPDF', () => {
});
});
+ it('leaves a caller-owned byte source intact and reusable', () => {
+ run(async () => {
+ const buffer: ArrayBuffer = await fetch(multiPage).then(
+ async (r) => await r.arrayBuffer(),
+ );
+ const bytes = new Uint8Array(buffer);
+ const length = bytes.byteLength;
+ const first = await openPDF(bytes);
+ await first.destroy();
+ const second = await openPDF(bytes.buffer);
+ handle = second;
+ return { length, after: bytes.byteLength, numPages: second.numPages };
+ }).should(({ length, after, numPages }) => {
+ expect(length).to.be.greaterThan(0);
+ expect(after).to.equal(length);
+ expect(numPages).to.equal(PAGE_COUNT);
+ });
+ });
+
+ it('renders close to the requested pixel resolution', () => {
+ run(async () => {
+ const opened = await openPDF(multiPage);
+ handle = opened;
+ const image = await opened.getPage(1, { resolution: 300_000 });
+ return await new Promise((resolve, reject) => {
+ const element = new Image();
+ element.onload = () => {
+ resolve(element.naturalWidth * element.naturalHeight);
+ };
+ element.onerror = () => {
+ reject(new Error('undecodable page'));
+ };
+ element.src = image;
+ });
+ }).should((area) => {
+ expect(area).to.be.within(300_000 * 0.9, 300_000 * 1.1);
+ });
+ });
+
it('renders a page as an object URL by default and as a data URL on request', () => {
run(async () => {
const opened = await openPDF(multiPage);
@@ -205,6 +244,26 @@ describe('openPDF', () => {
});
});
+ it('starts a fresh render when a page is requested right after its only caller aborted mid-render', () => {
+ run(async () => {
+ const opened = await openPDF(multiPage);
+ handle = opened;
+ const controller = new AbortController();
+ const aborted = errorName(
+ opened.getPage(2, { signal: controller.signal }),
+ );
+ await new Promise((resolve) => {
+ setTimeout(resolve, 10);
+ });
+ controller.abort();
+ const retried = opened.getPage(2);
+ return { aborted: await aborted, retried: await retried };
+ }).should(({ aborted, retried }) => {
+ expect(aborted).to.equal('AbortError');
+ expect(retried).to.match(/^blob:/);
+ });
+ });
+
it('promotes an already queued low priority page when requested again with high priority', () => {
run(async () => {
const opened = await openPDF(multiPage);
diff --git a/src/utils/pdf.ts b/src/utils/pdf.ts
index a4de7c2..04cefcf 100644
--- a/src/utils/pdf.ts
+++ b/src/utils/pdf.ts
@@ -150,6 +150,10 @@ const assertPageInRange = (pageNumber: number, numPages: number): void => {
}
};
+/**
+ * pdf.js transfers `data` to its worker and takes ownership of the buffer, so
+ * caller-owned bytes are copied first to keep the source reusable.
+ */
const toDocumentParameters = async (
source: PDFSource,
): Promise<{ url: string } | { data: Uint8Array }> => {
@@ -160,9 +164,9 @@ const toDocumentParameters = async (
return { data: new Uint8Array(await source.arrayBuffer()) };
}
if (source instanceof Uint8Array) {
- return { data: source };
+ return { data: source.slice() };
}
- return { data: new Uint8Array(source) };
+ return { data: new Uint8Array(source.slice(0)) };
};
const canvasToBlob = async (canvas: HTMLCanvasElement): Promise =>
@@ -279,7 +283,9 @@ class PDFRenderQueue {
/**
* Removes an aborting caller. The shared render is dropped (or cancelled
- * if in progress) only when nobody is waiting for it anymore.
+ * if in progress) only when nobody is waiting for it anymore, and leaves
+ * deduplication immediately so a new request for the same page starts a
+ * fresh render instead of joining the cancelled one.
*/
private detach(request: RenderRequest, caller: Caller): void {
const index = request.callers.indexOf(caller);
@@ -298,6 +304,7 @@ class PDFRenderQueue {
return;
}
if (this.current?.request === request) {
+ this.inFlight.delete(request.key);
this.current.task?.cancel();
}
}
@@ -315,7 +322,9 @@ class PDFRenderQueue {
request: RenderRequest,
settleCaller: (caller: Caller) => void,
): void {
- this.inFlight.delete(request.key);
+ if (this.inFlight.get(request.key) === request) {
+ this.inFlight.delete(request.key);
+ }
request.callers.splice(0).forEach((caller) => {
if (caller.signal !== undefined && caller.onAbort !== undefined) {
caller.signal.removeEventListener('abort', caller.onAbort);
@@ -355,10 +364,7 @@ class PDFRenderQueue {
private async render(request: RenderRequest): Promise {
const page = await this.document.getPage(request.pageNumber);
- const [, , width, height] = page.view;
- if (width === undefined || height === undefined) {
- throw new Error('Invalid PDF page view');
- }
+ const { width, height } = page.getViewport({ scale: 1 });
const scale = Math.min(
(request.resolution / (height * width)) ** (1 / 2),
MAX_PDF_SCALE,
From 9f66c5546b17ae37b933aca697a88b6835c28e12 Mon Sep 17 00:00:00 2001
From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com>
Date: Mon, 5 Oct 2026 12:34:50 +0200
Subject: [PATCH 09/17] address latest review
---
docs/docs/Utils/get-pdf-page-count.mdx | 3 +-
docs/docs/Utils/use-pdf-document.mdx | 2 +-
src/hooks/usePDFDocument.spec.tsx | 16 ++++++++++-
src/hooks/usePDFDocument.ts | 40 +++++++++++++++++++-------
src/utils/pdf.spec.ts | 5 ++--
5 files changed, 50 insertions(+), 16 deletions(-)
diff --git a/docs/docs/Utils/get-pdf-page-count.mdx b/docs/docs/Utils/get-pdf-page-count.mdx
index e3627c4..e6caca7 100644
--- a/docs/docs/Utils/get-pdf-page-count.mdx
+++ b/docs/docs/Utils/get-pdf-page-count.mdx
@@ -4,7 +4,8 @@ sidebar_position: 4
# getPDFPageCount
-- Type PDFSource : `(source: string | Blob | ArrayBuffer | Uint8Array) => Promise`
+- Type : `(source: PDFSource) => Promise`
+- `PDFSource` : `string | Blob | ArrayBuffer | Uint8Array`
> Returns the number of pages without rendering anything.
diff --git a/docs/docs/Utils/use-pdf-document.mdx b/docs/docs/Utils/use-pdf-document.mdx
index 4ce949e..77d2032 100644
--- a/docs/docs/Utils/use-pdf-document.mdx
+++ b/docs/docs/Utils/use-pdf-document.mdx
@@ -85,7 +85,7 @@ The hook is **0-based** (array semantics); the handle is **1-based** (pdf.js sem
:::
:::tip
-Keep `source` referentially stable between renders (store the `File` in state, do not rebuild a data URL on every render), otherwise the hook closes and reopens the document each time. Changing `maxPages` also reopens the document. Changing `resolution` keeps the document but clears `pages` and re-renders them at the new value (renders are cached per resolution, so switching back is instant).
+Keep `source` referentially stable between renders (store the `File` in state, do not rebuild a data URL on every render), otherwise the hook closes and reopens the document each time. Changing `maxPages` also reopens the document. Changing `resolution` keeps the document but clears `pages` and re-renders every page that was loaded (or loading) at the new value (renders are cached per resolution, so switching back is instant).
:::
### Example: load pages as they scroll into view
diff --git a/src/hooks/usePDFDocument.spec.tsx b/src/hooks/usePDFDocument.spec.tsx
index 26c2323..959da98 100644
--- a/src/hooks/usePDFDocument.spec.tsx
+++ b/src/hooks/usePDFDocument.spec.tsx
@@ -201,6 +201,15 @@ describe('usePDFDocument', () => {
{String(result.pages.filter((page) => page !== undefined).length)}
{result.pages[0] ?? ''}
+ {result.pages[PAGE_COUNT - 1] ?? ''}
+ {
+ result.loadPage(PAGE_COUNT - 1);
+ }}
+ >
+ load last
+
{
@@ -214,11 +223,16 @@ describe('usePDFDocument', () => {
};
cy.mount( );
cy.get('[data-cy=ready]').should('have.text', '2');
+ cy.get('[data-cy=load-last]').click();
+ cy.get('[data-cy=ready]').should('have.text', '3');
cy.get('[data-cy=first]')
.invoke('text')
.then((before) => {
cy.get('[data-cy=grow]').click();
- cy.get('[data-cy=ready]').should('have.text', '2');
+ cy.get('[data-cy=ready]').should('have.text', '3');
+ cy.get('[data-cy=last]')
+ .invoke('text')
+ .should('match', /^blob:/);
cy.get('[data-cy=first]')
.invoke('text')
.should('not.equal', before)
diff --git a/src/hooks/usePDFDocument.ts b/src/hooks/usePDFDocument.ts
index dee8bb7..3dda89f 100644
--- a/src/hooks/usePDFDocument.ts
+++ b/src/hooks/usePDFDocument.ts
@@ -14,8 +14,9 @@ export type UsePDFDocumentOptions = {
batch?: number;
/**
* Rendering resolution in pixels, see `PDFPageOptions.resolution`.
- * Changing it clears `pages` and re-renders them at the new value; renders
- * are cached per resolution, so switching back is instant.
+ * Changing it clears `pages` and re-renders every page that was loaded (or
+ * loading) at the new value; renders are cached per resolution, so
+ * switching back is instant.
*/
resolution?: number;
/** Rejects documents with more pages, see `OpenPDFOptions.maxPages`. */
@@ -62,6 +63,8 @@ type State = {
document: PDFDocumentHandle | null;
pages: (string | undefined)[];
loading: ReadonlySet;
+ /** Indexes to render again after a resolution change. */
+ reload: ReadonlySet;
};
/**
@@ -89,6 +92,7 @@ const initialState = (
document: null,
pages: [],
loading: EMPTY_SET,
+ reload: EMPTY_SET,
});
const toError = (error: unknown): Error =>
@@ -146,14 +150,23 @@ export const usePDFDocument = (
if (state.source !== source || state.maxPages !== maxPages) {
setState(initialState(source, maxPages, resolution));
} else if (state.resolution !== resolution) {
- setState((previous) => ({
- ...previous,
- resolution,
- pages: new Array(previous.pages.length).fill(
- undefined,
- ),
- loading: EMPTY_SET,
- }));
+ setState((previous) => {
+ const reload = new Set(previous.loading);
+ previous.pages.forEach((page, index) => {
+ if (page !== undefined) {
+ reload.add(index);
+ }
+ });
+ return {
+ ...previous,
+ resolution,
+ pages: new Array(previous.pages.length).fill(
+ undefined,
+ ),
+ loading: EMPTY_SET,
+ reload,
+ };
+ });
}
useEffect(() => {
@@ -298,6 +311,13 @@ export const usePDFDocument = (
}
}, [document, loadRange, prefetch]);
+ const reload = state.reload;
+ useEffect(() => {
+ reload.forEach((index) => {
+ render(index, { priority: 'low' });
+ });
+ }, [reload, render]);
+
return useMemo(
() => ({
status: state.status,
diff --git a/src/utils/pdf.spec.ts b/src/utils/pdf.spec.ts
index 21186d6..1077d4b 100644
--- a/src/utils/pdf.spec.ts
+++ b/src/utils/pdf.spec.ts
@@ -249,12 +249,11 @@ describe('openPDF', () => {
const opened = await openPDF(multiPage);
handle = opened;
const controller = new AbortController();
+ const first = opened.getPage(1);
const aborted = errorName(
opened.getPage(2, { signal: controller.signal }),
);
- await new Promise((resolve) => {
- setTimeout(resolve, 10);
- });
+ await first;
controller.abort();
const retried = opened.getPage(2);
return { aborted: await aborted, retried: await retried };
From 11265ef27aa64ff77b19caee27bcafe1df0ae1fb Mon Sep 17 00:00:00 2001
From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com>
Date: Mon, 5 Oct 2026 14:25:26 +0200
Subject: [PATCH 10/17] address review again
---
docs/docs/Utils/open-pdf.mdx | 2 +-
docs/docs/Utils/use-pdf-document.mdx | 4 ++--
src/hooks/usePDFDocument.spec.tsx | 24 ++++++++++++++++++++++++
src/hooks/usePDFDocument.ts | 20 ++++++++++++++++++--
src/utils/pdf.spec.ts | 15 +++++++++++++++
src/utils/pdf.ts | 13 +++++++++++--
6 files changed, 71 insertions(+), 7 deletions(-)
diff --git a/docs/docs/Utils/open-pdf.mdx b/docs/docs/Utils/open-pdf.mdx
index 3f1087d..8be8da1 100644
--- a/docs/docs/Utils/open-pdf.mdx
+++ b/docs/docs/Utils/open-pdf.mdx
@@ -66,7 +66,7 @@ Page numbers are **1-based**. `getPage(0)`, a page above `numPages` or an invert
```ts
interface PDFPageOptions {
- resolution?: number // default 1 500 000 px (width × height)
+ resolution?: number // default 1 500 000 px (width × height), must be > 0
output?: 'object-url' | 'data-url' // default 'object-url'
priority?: 'high' | 'low' // default 'high' for getPage, 'low' for getPages
signal?: AbortSignal
diff --git a/docs/docs/Utils/use-pdf-document.mdx b/docs/docs/Utils/use-pdf-document.mdx
index 77d2032..a31a422 100644
--- a/docs/docs/Utils/use-pdf-document.mdx
+++ b/docs/docs/Utils/use-pdf-document.mdx
@@ -49,8 +49,8 @@ function Document({ file }: { file: File }) {
```ts
interface UsePDFDocumentOptions {
- prefetch?: number // default 5
- batch?: number // default 3
+ prefetch?: number // default 5, integer >= 0
+ batch?: number // default 3, integer >= 1
resolution?: number // see PDFPageOptions.resolution
maxPages?: number // see OpenPDFOptions.maxPages
}
diff --git a/src/hooks/usePDFDocument.spec.tsx b/src/hooks/usePDFDocument.spec.tsx
index 959da98..3bb7162 100644
--- a/src/hooks/usePDFDocument.spec.tsx
+++ b/src/hooks/usePDFDocument.spec.tsx
@@ -313,6 +313,30 @@ describe('usePDFDocument', () => {
cy.get('[data-cy=status]').should('have.text', 'ready');
});
+ it('throws a RangeError for invalid prefetch or batch options', () => {
+ const Probe = ({
+ options,
+ }: {
+ options: { prefetch?: number; batch?: number };
+ }): JSX.Element => {
+ let message = 'ok';
+ try {
+ usePDFDocument(null, options);
+ } catch (error: unknown) {
+ message = error instanceof RangeError ? error.name : 'other';
+ }
+ return {message} ;
+ };
+ cy.mount( );
+ cy.get('[data-cy=probe]').should('have.text', 'RangeError');
+ cy.mount( );
+ cy.get('[data-cy=probe]').should('have.text', 'RangeError');
+ cy.mount( );
+ cy.get('[data-cy=probe]').should('have.text', 'RangeError');
+ cy.mount( );
+ cy.get('[data-cy=probe]').should('have.text', 'ok');
+ });
+
it('reports an error status for an unreadable source', () => {
const harness = mountHarness('data:application/pdf;base64,AAAA');
cy.wrap(null).should(() => {
diff --git a/src/hooks/usePDFDocument.ts b/src/hooks/usePDFDocument.ts
index 3dda89f..9db8d0a 100644
--- a/src/hooks/usePDFDocument.ts
+++ b/src/hooks/usePDFDocument.ts
@@ -8,9 +8,15 @@ import {
} from '@/utils/pdf';
export type UsePDFDocumentOptions = {
- /** Pages rendered as soon as the document opens. Defaults to 5. */
+ /**
+ * Pages rendered as soon as the document opens. Defaults to 5. Must be a
+ * non-negative integer.
+ */
prefetch?: number;
- /** Pages rendered together when one of them is requested. Defaults to 3. */
+ /**
+ * Pages rendered together when one of them is requested. Defaults to 3.
+ * Must be a positive integer.
+ */
batch?: number;
/**
* Rendering resolution in pixels, see `PDFPageOptions.resolution`.
@@ -115,6 +121,14 @@ const batchBounds = (
return [from, Math.min(from + batch, numPages)];
};
+const assertInteger = (name: string, value: number, min: number): void => {
+ if (!Number.isInteger(value) || value < min) {
+ throw new RangeError(
+ `${name} must be an integer >= ${String(min)}, got ${String(value)}`,
+ );
+ }
+};
+
/**
* Opens a PDF and renders its pages progressively: the first `prefetch`
* pages right away, then `batch` pages at a time whenever `loadPage` is
@@ -137,6 +151,8 @@ export const usePDFDocument = (
resolution,
maxPages,
} = options;
+ assertInteger('prefetch', prefetch, 0);
+ assertInteger('batch', batch, 1);
const [state, setState] = useState(() =>
initialState(source, maxPages, resolution),
diff --git a/src/utils/pdf.spec.ts b/src/utils/pdf.spec.ts
index 1077d4b..fca1e66 100644
--- a/src/utils/pdf.spec.ts
+++ b/src/utils/pdf.spec.ts
@@ -74,6 +74,21 @@ describe('openPDF', () => {
});
});
+ it('rejects an invalid resolution with a RangeError', () => {
+ run(async () => {
+ const opened = await openPDF(multiPage);
+ handle = opened;
+ return await Promise.all(
+ [0, -1, Number.NaN, Number.POSITIVE_INFINITY].map(
+ async (resolution) =>
+ await errorName(opened.getPage(1, { resolution })),
+ ),
+ );
+ }).should((names) => {
+ expect(names).to.deep.equal(Array(4).fill('RangeError'));
+ });
+ });
+
it('renders close to the requested pixel resolution', () => {
run(async () => {
const opened = await openPDF(multiPage);
diff --git a/src/utils/pdf.ts b/src/utils/pdf.ts
index 04cefcf..20710ce 100644
--- a/src/utils/pdf.ts
+++ b/src/utils/pdf.ts
@@ -41,7 +41,7 @@ export type PDFPageOptions = {
* Target pixel count of the rendered page (width × height). Defaults to
* 1.5 Mpx, which is what the viewer displays; use a much lower value such
* as 50 000 for thumbnails. Renders at different resolutions are cached
- * independently.
+ * independently. Must be a finite positive number.
*/
resolution?: number;
/**
@@ -138,6 +138,14 @@ const tooManyPagesError = (): Error => {
const destroyedError = (): Error =>
new Error('This PDF document handle has been destroyed');
+const assertResolution = (resolution: number): void => {
+ if (!Number.isFinite(resolution) || resolution <= 0) {
+ throw new RangeError(
+ `resolution must be a positive number, got ${String(resolution)}`,
+ );
+ }
+};
+
const assertPageInRange = (pageNumber: number, numPages: number): void => {
if (
!Number.isInteger(pageNumber) ||
@@ -204,11 +212,12 @@ class PDFRenderQueue {
throw destroyedError();
}
assertPageInRange(pageNumber, this.document.numPages);
+ const resolution = options.resolution ?? PDF_RESOLUTION;
+ assertResolution(resolution);
if (options.signal?.aborted === true) {
throw abortError();
}
- const resolution = options.resolution ?? PDF_RESOLUTION;
const output = options.output ?? 'object-url';
const key = `${String(pageNumber)}@${String(resolution)}:${output}`;
From 184d9ed7c2ddc530a12b89f16d8cac55d5e4d100 Mon Sep 17 00:00:00 2001
From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com>
Date: Mon, 5 Oct 2026 14:39:38 +0200
Subject: [PATCH 11/17] address latest review
---
docs/docs/Utils/open-pdf.mdx | 2 +
docs/docs/Utils/use-pdf-document.mdx | 2 +-
src/hooks/usePDFDocument.spec.tsx | 30 ++++++++++++
src/hooks/usePDFDocument.ts | 71 ++++++++++++++++++----------
src/utils/pdf.spec.ts | 24 ++++++++++
src/utils/pdf.ts | 37 +++++++++++++--
6 files changed, 134 insertions(+), 32 deletions(-)
diff --git a/docs/docs/Utils/open-pdf.mdx b/docs/docs/Utils/open-pdf.mdx
index 8be8da1..5ab9cd6 100644
--- a/docs/docs/Utils/open-pdf.mdx
+++ b/docs/docs/Utils/open-pdf.mdx
@@ -36,6 +36,8 @@ A URL or data URL string, a [File](https://developer.mozilla.org/docs/Web/API/Fi
interface OpenPDFOptions {
/** Rejects with a `TooManyPagesError` when the document has more pages. */
maxPages?: number
+ /** Aborting stops the download/parse and rejects with an `AbortError`. */
+ signal?: AbortSignal
}
```
diff --git a/docs/docs/Utils/use-pdf-document.mdx b/docs/docs/Utils/use-pdf-document.mdx
index a31a422..c338583 100644
--- a/docs/docs/Utils/use-pdf-document.mdx
+++ b/docs/docs/Utils/use-pdf-document.mdx
@@ -85,7 +85,7 @@ The hook is **0-based** (array semantics); the handle is **1-based** (pdf.js sem
:::
:::tip
-Keep `source` referentially stable between renders (store the `File` in state, do not rebuild a data URL on every render), otherwise the hook closes and reopens the document each time. Changing `maxPages` also reopens the document. Changing `resolution` keeps the document but clears `pages` and re-renders every page that was loaded (or loading) at the new value (renders are cached per resolution, so switching back is instant).
+Keep `source` referentially stable between renders (store the `File` in state, do not rebuild a data URL on every render), otherwise the hook closes and reopens the document each time. Changing `maxPages` also reopens the document. A document still downloading or parsing when the source changes (or the component unmounts) is aborted immediately. Changing `resolution` keeps the document but clears `pages` and aborts any pending render at the old value and re-renders every page that was loaded (or loading) at the new value (renders are cached per resolution, so switching back is instant).
:::
### Example: load pages as they scroll into view
diff --git a/src/hooks/usePDFDocument.spec.tsx b/src/hooks/usePDFDocument.spec.tsx
index 3bb7162..d5e5b20 100644
--- a/src/hooks/usePDFDocument.spec.tsx
+++ b/src/hooks/usePDFDocument.spec.tsx
@@ -281,6 +281,36 @@ describe('usePDFDocument', () => {
});
});
+ it('aborts pending renders of the previous resolution', () => {
+ const Resizer = (): JSX.Element => {
+ const [resolution, setResolution] = useState(100_000);
+ const result = usePDFDocument(multiPage, { prefetch: 5, resolution });
+ return (
+
+ {result.status}
+ {String(result.loading.size)}
+ {String(readyCount(result))}
+ {result.error?.message ?? 'none'}
+ {
+ setResolution(400_000);
+ }}
+ >
+ grow
+
+
+ );
+ };
+ cy.mount( );
+ // Switch while the 5-page prefetch is still rendering.
+ cy.get('[data-cy=status]').should('have.text', 'ready');
+ cy.get('[data-cy=grow]').click();
+ cy.get('[data-cy=ready]').should('have.text', String(PAGE_COUNT));
+ cy.get('[data-cy=loading]').should('have.text', '0');
+ cy.get('[data-cy=error]').should('have.text', 'none');
+ });
+
it('ignores renders of the previous document after a source swap mid-prefetch', () => {
const Swapper = (): JSX.Element => {
const [source, setSource] = useState(multiPage);
diff --git a/src/hooks/usePDFDocument.ts b/src/hooks/usePDFDocument.ts
index 9db8d0a..a66386c 100644
--- a/src/hooks/usePDFDocument.ts
+++ b/src/hooks/usePDFDocument.ts
@@ -75,14 +75,29 @@ type State = {
/**
* Pages requested so far, tagged with the document and resolution they were
- * requested for. A change of either starts a new generation; callbacks from
- * an older generation are ignored so a closed document cannot touch the
- * state of its replacement.
+ * requested for. A change of either starts a new generation: the previous
+ * generation's pending renders are aborted and its callbacks ignored, so a
+ * closed document cannot touch the state of its replacement.
*/
type Requested = {
document: PDFDocumentHandle | null;
resolution: number | undefined;
indexes: Set;
+ controller: AbortController;
+};
+
+const nextGeneration = (
+ previous: Requested,
+ document: PDFDocumentHandle | null,
+ resolution: number | undefined,
+): Requested => {
+ previous.controller.abort();
+ return {
+ document,
+ resolution,
+ indexes: new Set(),
+ controller: new AbortController(),
+ };
};
const initialState = (
@@ -161,6 +176,7 @@ export const usePDFDocument = (
document: null,
resolution,
indexes: new Set(),
+ controller: new AbortController(),
});
if (state.source !== source || state.maxPages !== maxPages) {
@@ -191,18 +207,14 @@ export const usePDFDocument = (
}
let handle: PDFDocumentHandle | undefined;
- let cancelled = false;
- requested.current = {
- ...requested.current,
- document: null,
- indexes: new Set(),
- };
- openPDF(source, { maxPages })
- .then(async (opened) => {
- if (cancelled) {
- await opened.destroy();
- return;
- }
+ const opening = new AbortController();
+ requested.current = nextGeneration(
+ requested.current,
+ null,
+ requested.current.resolution,
+ );
+ openPDF(source, { maxPages, signal: opening.signal })
+ .then((opened) => {
handle = opened;
setState((previous) => ({
...previous,
@@ -212,7 +224,7 @@ export const usePDFDocument = (
}));
})
.catch((reason: unknown) => {
- if (!cancelled) {
+ if (!opening.signal.aborted) {
setState((previous) => ({
...previous,
status: 'error',
@@ -222,7 +234,7 @@ export const usePDFDocument = (
});
return () => {
- cancelled = true;
+ opening.abort();
void handle?.destroy();
};
}, [source, maxPages]);
@@ -234,25 +246,32 @@ export const usePDFDocument = (
if (document === null) {
return;
}
- const tracker = requested.current;
- if (tracker.document !== document || tracker.resolution !== resolution) {
- tracker.document = document;
- tracker.resolution = resolution;
- tracker.indexes = new Set();
+ if (
+ requested.current.document !== document ||
+ requested.current.resolution !== resolution
+ ) {
+ requested.current = nextGeneration(
+ requested.current,
+ document,
+ resolution,
+ );
}
+ const tracker = requested.current;
if (tracker.indexes.has(index)) {
return;
}
tracker.indexes.add(index);
- const stale = (): boolean =>
- requested.current.document !== document ||
- requested.current.resolution !== resolution;
+ const stale = (): boolean => requested.current !== tracker;
setState((previous) => ({
...previous,
loading: new Set(previous.loading).add(index),
}));
document
- .getPage(index + 1, { ...pageOptions, resolution })
+ .getPage(index + 1, {
+ ...pageOptions,
+ resolution,
+ signal: tracker.controller.signal,
+ })
.then((image) => {
if (stale()) {
return;
diff --git a/src/utils/pdf.spec.ts b/src/utils/pdf.spec.ts
index fca1e66..0e48f1d 100644
--- a/src/utils/pdf.spec.ts
+++ b/src/utils/pdf.spec.ts
@@ -204,6 +204,30 @@ describe('openPDF', () => {
});
});
+ it('rejects opening with an AbortError when the signal fires while loading', () => {
+ run(async () => {
+ const controller = new AbortController();
+ const opening = openPDF(multiPage, { signal: controller.signal });
+ controller.abort();
+ const name = await errorName(opening);
+ // The shared worker stays usable for the next document.
+ const opened = await openPDF(multiPage);
+ handle = opened;
+ return { name, numPages: opened.numPages };
+ }).should(({ name, numPages }) => {
+ expect(name).to.equal('AbortError');
+ expect(numPages).to.equal(PAGE_COUNT);
+ });
+ });
+
+ it('rejects opening immediately with an already aborted signal', () => {
+ run(async () => {
+ const controller = new AbortController();
+ controller.abort();
+ return await errorName(openPDF(multiPage, { signal: controller.signal }));
+ }).should('equal', 'AbortError');
+ });
+
it('rejects with an AbortError when a queued request is aborted', () => {
run(async () => {
const opened = await openPDF(multiPage);
diff --git a/src/utils/pdf.ts b/src/utils/pdf.ts
index 20710ce..4f190fb 100644
--- a/src/utils/pdf.ts
+++ b/src/utils/pdf.ts
@@ -101,6 +101,11 @@ export type PDFDocumentHandle = {
export type OpenPDFOptions = {
/** Rejects with a `TooManyPagesError` when the document has more pages. */
maxPages?: number;
+ /**
+ * Aborting stops the download/parse, releases the partial document and
+ * rejects with an `AbortError`.
+ */
+ signal?: AbortSignal;
};
/** One awaiting caller of a render; several callers can share a request. */
@@ -419,13 +424,35 @@ export const openPDF = async (
source: PDFSource,
options: OpenPDFOptions = {},
): Promise => {
+ const { signal } = options;
+ const isAborted = (): boolean => signal?.aborted === true;
+ if (isAborted()) {
+ throw abortError();
+ }
const { pdfjs, worker } = await loadPdfjs();
- const pdf = await pdfjs.getDocument({
- ...(await toDocumentParameters(source)),
- worker,
- }).promise;
+ const parameters = await toDocumentParameters(source);
+ if (isAborted()) {
+ throw abortError();
+ }
+ const loadingTask = pdfjs.getDocument({ ...parameters, worker });
+ const onAbort = (): void => {
+ void loadingTask.destroy();
+ };
+ signal?.addEventListener('abort', onAbort, { once: true });
+ let pdf: PDFDocumentProxy;
+ try {
+ pdf = await loadingTask.promise;
+ } catch (error: unknown) {
+ throw isAborted() ? abortError() : error;
+ } finally {
+ signal?.removeEventListener('abort', onAbort);
+ }
+ if (isAborted()) {
+ await loadingTask.destroy();
+ throw abortError();
+ }
if (options.maxPages !== undefined && pdf.numPages > options.maxPages) {
- await pdf.loadingTask.destroy();
+ await loadingTask.destroy();
throw tooManyPagesError();
}
From e9ceb42662063291700bdb169899d2d5136470f9 Mon Sep 17 00:00:00 2001
From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com>
Date: Mon, 5 Oct 2026 15:01:18 +0200
Subject: [PATCH 12/17] fixes
---
README.md | 2 +-
docs/docs/Utils/use-pdf-document.mdx | 2 +-
src/hooks/usePDFDocument.spec.tsx | 27 ++++++++++++++++++++
src/hooks/usePDFDocument.ts | 37 ++++++++++++++++++++++------
src/utils/pdf.spec.ts | 8 ++++++
src/utils/pdf.ts | 4 +--
6 files changed, 68 insertions(+), 12 deletions(-)
diff --git a/README.md b/README.md
index 228c6af..0275021 100644
--- a/README.md
+++ b/README.md
@@ -94,7 +94,7 @@ function App() {
## PDF documents
PDF pages are rendered in the browser with [pdf.js](https://mozilla.github.io/pdf.js/) (the first time a PDF is processed, it's loaded lazily in a Web Worker).
-Pages are now loaded by batches (5 initial pre-fetched pages, then 3 by 3 by default) to accommodate for very large documents.
+Pages are now loaded by batches (5 initial pre-fetched pages, then 3 by 3 by default) to accommodate very large documents.
### `usePDFDocument` hook
diff --git a/docs/docs/Utils/use-pdf-document.mdx b/docs/docs/Utils/use-pdf-document.mdx
index c338583..f1890bc 100644
--- a/docs/docs/Utils/use-pdf-document.mdx
+++ b/docs/docs/Utils/use-pdf-document.mdx
@@ -77,7 +77,7 @@ interface UsePDFDocumentResult {
- **`pages`** : one slot per page, **0-based**. A slot is `undefined` until the page has been rendered, then holds an image URL usable as `data.image`.
- **`loading`** : indexes whose render is queued or in progress, handy for per-page skeletons.
- **`loadPage(index)`** : ensures the page and the rest of its batch are rendered. Idempotent and cheap, so call it liberally (on scroll, click, keyboard navigation…). The page itself is requested with high priority so it is drawn before any pending prefetch, even if it was already queued.
-- **`loadRange(from, to)`** : renders every page in `[from, to)` with low priority.
+- **`loadRange(from, to)`** : renders every page in `[from, to)` with low priority. Bounds are clamped to the document; non-integer indexes (here and in `loadPage`) are ignored.
- **`document`** : the underlying handle for advanced use, `null` until `status` is `ready`.
:::caution
diff --git a/src/hooks/usePDFDocument.spec.tsx b/src/hooks/usePDFDocument.spec.tsx
index d5e5b20..591b5d0 100644
--- a/src/hooks/usePDFDocument.spec.tsx
+++ b/src/hooks/usePDFDocument.spec.tsx
@@ -343,6 +343,33 @@ describe('usePDFDocument', () => {
cy.get('[data-cy=status]').should('have.text', 'ready');
});
+ it('ignores non-integer indexes in loadPage and loadRange', () => {
+ const Probe = (): JSX.Element => {
+ const result = usePDFDocument(multiPage, { prefetch: 0 });
+ return (
+
+ {result.status}
+ {String(result.loading.size)}
+ {result.error?.message ?? 'none'}
+ {
+ result.loadRange(0.5, 2);
+ result.loadPage(1.5);
+ }}
+ >
+ fractional
+
+
+ );
+ };
+ cy.mount( );
+ cy.get('[data-cy=status]').should('have.text', 'ready');
+ cy.get('[data-cy=fractional]').click();
+ cy.get('[data-cy=loading]').should('have.text', '0');
+ cy.get('[data-cy=error]').should('have.text', 'none');
+ });
+
it('throws a RangeError for invalid prefetch or batch options', () => {
const Probe = ({
options,
diff --git a/src/hooks/usePDFDocument.ts b/src/hooks/usePDFDocument.ts
index a66386c..e899c58 100644
--- a/src/hooks/usePDFDocument.ts
+++ b/src/hooks/usePDFDocument.ts
@@ -49,7 +49,10 @@ export type UsePDFDocumentResult = {
* requested with high priority so it is drawn before any pending prefetch.
*/
loadPage: (index: number) => void;
- /** Renders every page in `[from, to)` (0-based, `to` exclusive). */
+ /**
+ * Renders every page in `[from, to)` (0-based integers, `to` exclusive).
+ * Bounds are clamped to the document; non-integer bounds are ignored.
+ */
loadRange: (from: number, to: number) => void;
/** Underlying handle for advanced use, `null` until `status` is `ready`. */
document: PDFDocumentHandle | null;
@@ -214,7 +217,11 @@ export const usePDFDocument = (
requested.current.resolution,
);
openPDF(source, { maxPages, signal: opening.signal })
- .then((opened) => {
+ .then(async (opened) => {
+ if (opening.signal.aborted) {
+ await opened.destroy();
+ return;
+ }
handle = opened;
setState((previous) => ({
...previous,
@@ -307,7 +314,11 @@ export const usePDFDocument = (
const loadRange = useCallback(
(from: number, to: number) => {
- if (document === null) {
+ if (
+ document === null ||
+ !Number.isInteger(from) ||
+ !Number.isInteger(to)
+ ) {
return;
}
const start = Math.max(0, from);
@@ -321,16 +332,26 @@ export const usePDFDocument = (
const loadPage = useCallback(
(index: number) => {
- if (document === null || index < 0 || index >= document.numPages) {
+ if (
+ document === null ||
+ !Number.isInteger(index) ||
+ index < 0 ||
+ index >= document.numPages
+ ) {
return;
}
+ const tracker = requested.current;
if (
- requested.current.document === document &&
- requested.current.resolution === resolution &&
- requested.current.indexes.has(index)
+ tracker.document === document &&
+ tracker.resolution === resolution &&
+ tracker.indexes.has(index)
) {
void document
- .getPage(index + 1, { priority: 'high', resolution })
+ .getPage(index + 1, {
+ priority: 'high',
+ resolution,
+ signal: tracker.controller.signal,
+ })
.catch(() => undefined);
} else {
render(index, { priority: 'high' });
diff --git a/src/utils/pdf.spec.ts b/src/utils/pdf.spec.ts
index 0e48f1d..87e1b04 100644
--- a/src/utils/pdf.spec.ts
+++ b/src/utils/pdf.spec.ts
@@ -89,6 +89,14 @@ describe('openPDF', () => {
});
});
+ it('still produces an image for a tiny resolution', () => {
+ run(async () => {
+ const opened = await openPDF(multiPage);
+ handle = opened;
+ return await opened.getPage(1, { resolution: 1, output: 'data-url' });
+ }).should('match', /^data:image\/png;base64,/);
+ });
+
it('renders close to the requested pixel resolution', () => {
run(async () => {
const opened = await openPDF(multiPage);
diff --git a/src/utils/pdf.ts b/src/utils/pdf.ts
index 4f190fb..4577991 100644
--- a/src/utils/pdf.ts
+++ b/src/utils/pdf.ts
@@ -385,8 +385,8 @@ class PDFRenderQueue {
);
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
- canvas.width = viewport.width;
- canvas.height = viewport.height;
+ canvas.width = Math.max(1, Math.floor(viewport.width));
+ canvas.height = Math.max(1, Math.floor(viewport.height));
const task = page.render({ canvas, viewport });
if (this.current?.request === request) {
From a2eb983d63c4cd4945d41e15c39626d77d09b73e Mon Sep 17 00:00:00 2001
From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com>
Date: Mon, 5 Oct 2026 15:11:23 +0200
Subject: [PATCH 13/17] fix review
---
src/hooks/usePDFDocument.spec.tsx | 29 +++++++++++++++++++++++++++++
src/hooks/usePDFDocument.ts | 10 +++++-----
2 files changed, 34 insertions(+), 5 deletions(-)
diff --git a/src/hooks/usePDFDocument.spec.tsx b/src/hooks/usePDFDocument.spec.tsx
index 591b5d0..3e146f8 100644
--- a/src/hooks/usePDFDocument.spec.tsx
+++ b/src/hooks/usePDFDocument.spec.tsx
@@ -343,6 +343,35 @@ describe('usePDFDocument', () => {
cy.get('[data-cy=status]').should('have.text', 'ready');
});
+ it('returns to idle without error when the source is cleared mid-prefetch', () => {
+ const Clearer = (): JSX.Element => {
+ const [source, setSource] = useState(multiPage);
+ const result = usePDFDocument(source, { prefetch: 5 });
+ return (
+
+ {result.status}
+ {result.error?.message ?? 'none'}
+ {String(result.loading.size)}
+ {
+ setSource(null);
+ }}
+ >
+ clear
+
+
+ );
+ };
+ cy.mount( );
+ cy.get('[data-cy=status]').should('have.text', 'ready');
+ cy.get('[data-cy=clear]').click();
+ cy.get('[data-cy=status]').should('have.text', 'idle');
+ cy.wait(300);
+ cy.get('[data-cy=error]').should('have.text', 'none');
+ cy.get('[data-cy=loading]').should('have.text', '0');
+ });
+
it('ignores non-integer indexes in loadPage and loadRange', () => {
const Probe = (): JSX.Element => {
const result = usePDFDocument(multiPage, { prefetch: 0 });
diff --git a/src/hooks/usePDFDocument.ts b/src/hooks/usePDFDocument.ts
index e899c58..27dbbd2 100644
--- a/src/hooks/usePDFDocument.ts
+++ b/src/hooks/usePDFDocument.ts
@@ -211,11 +211,6 @@ export const usePDFDocument = (
let handle: PDFDocumentHandle | undefined;
const opening = new AbortController();
- requested.current = nextGeneration(
- requested.current,
- null,
- requested.current.resolution,
- );
openPDF(source, { maxPages, signal: opening.signal })
.then(async (opened) => {
if (opening.signal.aborted) {
@@ -242,6 +237,11 @@ export const usePDFDocument = (
return () => {
opening.abort();
+ requested.current = nextGeneration(
+ requested.current,
+ null,
+ requested.current.resolution,
+ );
void handle?.destroy();
};
}, [source, maxPages]);
From 94bed5b837386e876ca49a60965cd4d80e64d3fa Mon Sep 17 00:00:00 2001
From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com>
Date: Mon, 5 Oct 2026 15:23:13 +0200
Subject: [PATCH 14/17] fix latest issues
---
src/hooks/usePDFDocument.spec.tsx | 51 +++++++++++++++++++++++--------
src/utils/pdf.ts | 20 +++++++++++-
2 files changed, 58 insertions(+), 13 deletions(-)
diff --git a/src/hooks/usePDFDocument.spec.tsx b/src/hooks/usePDFDocument.spec.tsx
index 3e146f8..27d9d7f 100644
--- a/src/hooks/usePDFDocument.spec.tsx
+++ b/src/hooks/usePDFDocument.spec.tsx
@@ -1,4 +1,10 @@
-import { useEffect, useState, type JSX } from 'react';
+import {
+ Component,
+ useEffect,
+ useState,
+ type JSX,
+ type ReactNode,
+} from 'react';
import multiPage from 'cypress/assets/multi-page.pdf';
import {
@@ -74,6 +80,24 @@ const imageSize = async (url: string): Promise =>
const readyCount = (result: UsePDFDocumentResult): number =>
result.pages.filter((page) => page !== undefined).length;
+class RenderErrorBoundary extends Component<
+ { children: ReactNode },
+ { error: Error | null }
+> {
+ override state: { error: Error | null } = { error: null };
+
+ static getDerivedStateFromError(error: Error): { error: Error } {
+ return { error };
+ }
+
+ override render(): ReactNode {
+ if (this.state.error !== null) {
+ return {this.state.error.name} ;
+ }
+ return this.props.children;
+ }
+}
+
describe('usePDFDocument', () => {
it('stays idle without a source', () => {
const harness = mountHarness(null);
@@ -405,21 +429,24 @@ describe('usePDFDocument', () => {
}: {
options: { prefetch?: number; batch?: number };
}): JSX.Element => {
- let message = 'ok';
- try {
- usePDFDocument(null, options);
- } catch (error: unknown) {
- message = error instanceof RangeError ? error.name : 'other';
- }
- return {message} ;
+ usePDFDocument(null, options);
+ return ok ;
+ };
+ const mountProbe = (options: { prefetch?: number; batch?: number }) => {
+ cy.mount(
+
+
+ ,
+ );
};
- cy.mount( );
+ cy.on('uncaught:exception', () => false);
+ mountProbe({ batch: 0 });
cy.get('[data-cy=probe]').should('have.text', 'RangeError');
- cy.mount( );
+ mountProbe({ prefetch: 1.5 });
cy.get('[data-cy=probe]').should('have.text', 'RangeError');
- cy.mount( );
+ mountProbe({ prefetch: -1 });
cy.get('[data-cy=probe]').should('have.text', 'RangeError');
- cy.mount( );
+ mountProbe({ prefetch: 0, batch: 1 });
cy.get('[data-cy=probe]').should('have.text', 'ok');
});
diff --git a/src/utils/pdf.ts b/src/utils/pdf.ts
index 4577991..1765de6 100644
--- a/src/utils/pdf.ts
+++ b/src/utils/pdf.ts
@@ -1,5 +1,10 @@
import type * as Pdfjs from 'pdfjs-dist';
-import type { PDFDocumentProxy, PDFWorker, RenderTask } from 'pdfjs-dist';
+import type {
+ PDFDocumentProxy,
+ PDFPageProxy,
+ PDFWorker,
+ RenderTask,
+} from 'pdfjs-dist';
import { MAX_PDF_SCALE, PDF_RESOLUTION } from '@/common/constants';
@@ -378,6 +383,19 @@ class PDFRenderQueue {
private async render(request: RenderRequest): Promise {
const page = await this.document.getPage(request.pageNumber);
+ try {
+ return await this.paint(page, request);
+ } finally {
+ // The encoded image is cached by the queue; drop pdf.js's own
+ // operator list and decoded resources for this page.
+ page.cleanup();
+ }
+ }
+
+ private async paint(
+ page: PDFPageProxy,
+ request: RenderRequest,
+ ): Promise {
const { width, height } = page.getViewport({ scale: 1 });
const scale = Math.min(
(request.resolution / (height * width)) ** (1 / 2),
From c29aab7d2697cf77bc329393a2c623d735ad1a4b Mon Sep 17 00:00:00 2001
From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com>
Date: Mon, 5 Oct 2026 15:27:57 +0200
Subject: [PATCH 15/17] dedupe handle check
---
src/hooks/usePDFDocument.ts | 37 +++++++++++++++++++++----------------
1 file changed, 21 insertions(+), 16 deletions(-)
diff --git a/src/hooks/usePDFDocument.ts b/src/hooks/usePDFDocument.ts
index 27dbbd2..db2a239 100644
--- a/src/hooks/usePDFDocument.ts
+++ b/src/hooks/usePDFDocument.ts
@@ -248,22 +248,30 @@ export const usePDFDocument = (
const document = state.document;
- const render = useCallback(
- (index: number, pageOptions: PDFPageOptions) => {
- if (document === null) {
- return;
- }
+ /** Current generation for `document` at `resolution`, started if needed. */
+ const generation = useCallback(
+ (handle: PDFDocumentHandle): Requested => {
if (
- requested.current.document !== document ||
+ requested.current.document !== handle ||
requested.current.resolution !== resolution
) {
requested.current = nextGeneration(
requested.current,
- document,
+ handle,
resolution,
);
}
- const tracker = requested.current;
+ return requested.current;
+ },
+ [resolution],
+ );
+
+ const render = useCallback(
+ (index: number, pageOptions: PDFPageOptions) => {
+ if (document === null) {
+ return;
+ }
+ const tracker = generation(document);
if (tracker.indexes.has(index)) {
return;
}
@@ -309,7 +317,7 @@ export const usePDFDocument = (
});
});
},
- [document, resolution],
+ [document, generation, resolution],
);
const loadRange = useCallback(
@@ -340,12 +348,9 @@ export const usePDFDocument = (
) {
return;
}
- const tracker = requested.current;
- if (
- tracker.document === document &&
- tracker.resolution === resolution &&
- tracker.indexes.has(index)
- ) {
+ const tracker = generation(document);
+ if (tracker.indexes.has(index)) {
+ // Already queued: a second high-priority caller promotes the render.
void document
.getPage(index + 1, {
priority: 'high',
@@ -358,7 +363,7 @@ export const usePDFDocument = (
}
loadRange(...batchBounds(index, document.numPages, prefetch, batch));
},
- [document, render, loadRange, prefetch, batch, resolution],
+ [document, generation, render, loadRange, prefetch, batch, resolution],
);
useEffect(() => {
From 9a9cdbab4a2786183c5f44347bc11bdbbff5e59c Mon Sep 17 00:00:00 2001
From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com>
Date: Mon, 5 Oct 2026 15:52:20 +0200
Subject: [PATCH 16/17] fix dead code doc
---
docs/docs/API/annotation-viewer-api.mdx | 2 +-
docs/docs/customization.md | 2 +-
docs/docs/migration.md | 2 +-
src/common/types.ts | 1 +
4 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/docs/docs/API/annotation-viewer-api.mdx b/docs/docs/API/annotation-viewer-api.mdx
index e5f392f..ff7a868 100644
--- a/docs/docs/API/annotation-viewer-api.mdx
+++ b/docs/docs/API/annotation-viewer-api.mdx
@@ -56,7 +56,7 @@ interface AnnotationViewerOptions {
zoom?: {
modifier: number // multiplier applied per wheel step
max: number // highest magnification, relative to the fitted image (1 = fits the viewer)
- defaultZoom: number
+ defaultZoom: number // accepted for backward compatibility, currently unused
}
}
```
diff --git a/docs/docs/customization.md b/docs/docs/customization.md
index 6cd35a3..ce0ec2e 100644
--- a/docs/docs/customization.md
+++ b/docs/docs/customization.md
@@ -24,7 +24,7 @@ sidebar_position: 6
individual shape [**config**](https://konvajs.org/api/Konva.Shape.html) passed on each item of the list of shapes override the **shapeConfig** property
:::
-- `zoom`: wheel zoom settings. `modifier` is the multiplier applied per wheel step; `max` is the highest magnification reachable (`1` = fits, `3` = three times larger), independently of the image pixel size; `defaultZoom` is the initial level.
+- `zoom`: wheel zoom settings. `modifier` is the multiplier applied per wheel step; `max` is the highest magnification reachable (`1` = fits, `3` = three times larger), independently of the image pixel size. `defaultZoom` is accepted for backward compatibility but currently has no effect.
##### Example
diff --git a/docs/docs/migration.md b/docs/docs/migration.md
index 901ecb5..c4403c5 100644
--- a/docs/docs/migration.md
+++ b/docs/docs/migration.md
@@ -13,7 +13,7 @@ sidebar_position: 5
### `zoom.max` is now relative to the fitted image
-Previously `options.zoom.max` was compared against the absolute canvas scale, so the reachable magnification depended on the pixel size of the image. It is now a factor relative to the image **fitted in the container** (`1` = fits), like `defaultZoom`. If you relied on the old behaviour, lower large values (a `max` of `10` now means "ten times the fitted size").
+Previously `options.zoom.max` was compared against the absolute canvas scale, so the reachable magnification depended on the pixel size of the image. It is now a factor relative to the image **fitted in the container** (`1` = fits). If you relied on the old behaviour, lower large values (a `max` of `10` now means "ten times the fitted size").
### PDF helpers
diff --git a/src/common/types.ts b/src/common/types.ts
index db8bea7..f98081e 100644
--- a/src/common/types.ts
+++ b/src/common/types.ts
@@ -38,6 +38,7 @@ export type AnnotationViewerOptions = BaseOptions & {
modifier: number;
/** Highest magnification reachable through the wheel. */
max: number;
+ /** @deprecated Unused since 1.3.3; kept so existing option objects still type-check. */
defaultZoom: number;
};
};
From 8597cd2e60ebe0382582bdc27a1067e97e796afa Mon Sep 17 00:00:00 2001
From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com>
Date: Mon, 5 Oct 2026 16:20:07 +0200
Subject: [PATCH 17/17] Update README.md
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: ianaré <97107275+ianardee@users.noreply.github.com>
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 0275021..e0a61d7 100644
--- a/README.md
+++ b/README.md
@@ -94,7 +94,7 @@ function App() {
## PDF documents
PDF pages are rendered in the browser with [pdf.js](https://mozilla.github.io/pdf.js/) (the first time a PDF is processed, it's loaded lazily in a Web Worker).
-Pages are now loaded by batches (5 initial pre-fetched pages, then 3 by 3 by default) to accommodate very large documents.
+Pages are rendered by batch to accommodate very large documents.
### `usePDFDocument` hook