diff --git a/README.md b/README.md index 3376ce3..e0a61d7 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,74 @@ 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/) (the first time a PDF is processed, it's loaded lazily in a Web Worker). +Pages are rendered by batch to accommodate very large documents. + +### `usePDFDocument` hook + +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 }) { + 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[current] && ( + + )} + + ); +} +``` + +### `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 +``` + +### `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/docs/docs/API/annotation-viewer-api.mdx b/docs/docs/API/annotation-viewer-api.mdx index d726353..ff7a868 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,9 +54,9 @@ interface AnnotationViewerOptions { onMouseLeave?: (polygon: Line) => void onClick?: (polygon: Line) => void zoom?: { - modifier: number - max: number - defaultZoom: number + modifier: number // multiplier applied per wheel step + max: number // highest magnification, relative to the fitted image (1 = fits the viewer) + defaultZoom: number // accepted for backward compatibility, currently unused } } ``` 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 ( + <> + + + {current + 1} / {numPages} + + + + {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..e6caca7 100644 --- a/docs/docs/Utils/get-pdf-page-count.mdx +++ b/docs/docs/Utils/get-pdf-page-count.mdx @@ -1,15 +1,20 @@ --- -sidebar_position: 2 +sidebar_position: 4 --- # getPDFPageCount -- Type : `(file: string) => Promise` +- Type : `(source: PDFSource) => Promise` +- `PDFSource` : `string | Blob | ArrayBuffer | Uint8Array` -> 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) => { // 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..5ab9cd6 --- /dev/null +++ b/docs/docs/Utils/open-pdf.mdx @@ -0,0 +1,102 @@ +--- +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 + /** Aborting stops the download/parse and rejects with an `AbortError`. */ + signal?: AbortSignal +} +``` + +## 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) 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 +} + +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), must be > 0 + 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. +- `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' }`. +::: + +## 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..f1890bc --- /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[current] && ( + + )} + + ) +} +``` + +## Options + +```ts +interface UsePDFDocumentOptions { + prefetch?: number // default 5, integer >= 0 + batch?: number // default 3, integer >= 1 + 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. 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 +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. 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 + +```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..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`: +- `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/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..c4403c5 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). 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 : 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/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; }; }; diff --git a/src/hooks/usePDFDocument.spec.tsx b/src/hooks/usePDFDocument.spec.tsx new file mode 100644 index 0000000..27d9d7f --- /dev/null +++ b/src/hooks/usePDFDocument.spec.tsx @@ -0,0 +1,493 @@ +import { + Component, + useEffect, + useState, + type JSX, + type ReactNode, +} 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 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; + +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); + 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('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)} + +
+ ); + }; + 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('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] ?? ''} + {result.pages[PAGE_COUNT - 1] ?? ''} + + +
+ ); + }; + 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', '3'); + cy.get('[data-cy=last]') + .invoke('text') + .should('match', /^blob:/); + 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('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] ?? ''} + +
+ ); + }; + 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('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'} + +
+ ); + }; + 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); + const result = usePDFDocument(source, { prefetch: 5 }); + return ( +
+ {result.status} + {result.error?.message ?? 'none'} + {String(result.loading.size)} + {String(readyCount(result))} + +
+ ); + }; + 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('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)} + +
+ ); + }; + 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 }); + return ( +
+ {result.status} + {String(result.loading.size)} + {result.error?.message ?? 'none'} + +
+ ); + }; + 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, + }: { + options: { prefetch?: number; batch?: number }; + }): JSX.Element => { + usePDFDocument(null, options); + return ok; + }; + const mountProbe = (options: { prefetch?: number; batch?: number }) => { + cy.mount( + + + , + ); + }; + cy.on('uncaught:exception', () => false); + mountProbe({ batch: 0 }); + cy.get('[data-cy=probe]').should('have.text', 'RangeError'); + mountProbe({ prefetch: 1.5 }); + cy.get('[data-cy=probe]').should('have.text', 'RangeError'); + mountProbe({ prefetch: -1 }); + cy.get('[data-cy=probe]').should('have.text', 'RangeError'); + mountProbe({ prefetch: 0, batch: 1 }); + 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(() => { + 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] ?? ''} + +
+ ); + }; + 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..db2a239 --- /dev/null +++ b/src/hooks/usePDFDocument.ts @@ -0,0 +1,395 @@ +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. Must be a + * non-negative integer. + */ + prefetch?: number; + /** + * 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`. + * 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`. */ + 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 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 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; +}; + +const DEFAULT_PREFETCH = 5; +const DEFAULT_BATCH = 3; + +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; + pages: (string | undefined)[]; + loading: ReadonlySet; + /** Indexes to render again after a resolution change. */ + reload: ReadonlySet; +}; + +/** + * Pages requested so far, tagged with the document and resolution they were + * 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 = ( + 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, + pages: [], + loading: EMPTY_SET, + reload: 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)]; +}; + +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 + * 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; + assertInteger('prefetch', prefetch, 0); + assertInteger('batch', batch, 1); + + const [state, setState] = useState(() => + initialState(source, maxPages, resolution), + ); + const requested = useRef({ + document: null, + resolution, + indexes: new Set(), + controller: new AbortController(), + }); + + if (state.source !== source || state.maxPages !== maxPages) { + setState(initialState(source, maxPages, resolution)); + } else if (state.resolution !== resolution) { + 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(() => { + if (source === null || source === undefined) { + return undefined; + } + + let handle: PDFDocumentHandle | undefined; + const opening = new AbortController(); + openPDF(source, { maxPages, signal: opening.signal }) + .then(async (opened) => { + if (opening.signal.aborted) { + await opened.destroy(); + return; + } + handle = opened; + setState((previous) => ({ + ...previous, + status: 'ready', + document: opened, + pages: new Array(opened.numPages).fill(undefined), + })); + }) + .catch((reason: unknown) => { + if (!opening.signal.aborted) { + setState((previous) => ({ + ...previous, + status: 'error', + error: toError(reason), + })); + } + }); + + return () => { + opening.abort(); + requested.current = nextGeneration( + requested.current, + null, + requested.current.resolution, + ); + void handle?.destroy(); + }; + }, [source, maxPages]); + + const document = state.document; + + /** Current generation for `document` at `resolution`, started if needed. */ + const generation = useCallback( + (handle: PDFDocumentHandle): Requested => { + if ( + requested.current.document !== handle || + requested.current.resolution !== resolution + ) { + requested.current = nextGeneration( + requested.current, + handle, + resolution, + ); + } + 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; + } + tracker.indexes.add(index); + const stale = (): boolean => requested.current !== tracker; + setState((previous) => ({ + ...previous, + loading: new Set(previous.loading).add(index), + })); + document + .getPage(index + 1, { + ...pageOptions, + resolution, + signal: tracker.controller.signal, + }) + .then((image) => { + if (stale()) { + return; + } + setState((previous) => { + const pages = [...previous.pages]; + pages[index] = image; + return { ...previous, pages }; + }); + }) + .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); + return { ...previous, loading }; + }); + }); + }, + [document, generation, resolution], + ); + + const loadRange = useCallback( + (from: number, to: number) => { + if ( + document === null || + !Number.isInteger(from) || + !Number.isInteger(to) + ) { + 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 || + !Number.isInteger(index) || + index < 0 || + index >= document.numPages + ) { + return; + } + 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', + resolution, + signal: tracker.controller.signal, + }) + .catch(() => undefined); + } else { + render(index, { priority: 'high' }); + } + loadRange(...batchBounds(index, document.numPages, prefetch, batch)); + }, + [document, generation, render, loadRange, prefetch, batch, resolution], + ); + + useEffect(() => { + if (document !== null) { + loadRange(0, prefetch); + } + }, [document, loadRange, prefetch]); + + const reload = state.reload; + useEffect(() => { + reload.forEach((index) => { + render(index, { priority: 'low' }); + }); + }, [reload, render]); + + 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..c41194a 100644 --- a/src/utils/getImagesFromPDF.ts +++ b/src/utils/getImagesFromPDF.ts @@ -1,49 +1,40 @@ -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. 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. + */ 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 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(); } - 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/image.spec.ts b/src/utils/image.spec.ts index 85ca3d3..ce4f9df 100644 --- a/src/utils/image.spec.ts +++ b/src/utils/image.spec.ts @@ -1,13 +1,44 @@ +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', + 'image/heic-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 new file mode 100644 index 0000000..87e1b04 --- /dev/null +++ b/src/utils/pdf.spec.ts @@ -0,0 +1,415 @@ +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('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('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('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); + 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); + 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('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); + 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 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); + 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('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('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 first = opened.getPage(1); + const aborted = errorName( + opened.getPage(2, { signal: controller.signal }), + ); + await first; + 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); + 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); + 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 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('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', + '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..1765de6 100644 --- a/src/utils/pdf.ts +++ b/src/utils/pdf.ts @@ -1,28 +1,503 @@ import type * as Pdfjs from 'pdfjs-dist'; -import type { PDFDocumentProxy } from 'pdfjs-dist'; +import type { + PDFDocumentProxy, + PDFPageProxy, + PDFWorker, + RenderTask, +} from 'pdfjs-dist'; -let pdfjsModule: Promise | undefined; +import { MAX_PDF_SCALE, PDF_RESOLUTION } from '@/common/constants'; + +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; + +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. Must be a finite positive number. + */ + 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) 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, + 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; + /** + * Aborting stops the download/parse, releases the partial document and + * rejects with an `AbortError`. + */ + signal?: AbortSignal; }; -export const loadPdfDocument = async ( - file: string, -): Promise => { - const { getDocument } = await loadPdfjs(); - return await getDocument({ url: file }).promise; +/** 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; + callers: Caller[]; +}; + +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 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) || + pageNumber < 1 || + pageNumber > numPages + ) { + throw new RangeError( + `Page ${String(pageNumber)} is out of range (1-${String(numPages)})`, + ); + } +}; + +/** + * 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 }> => { + 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.slice() }; + } + return { data: new Uint8Array(source.slice(0)) }; +}; + +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(); + } + assertPageInRange(pageNumber, this.document.numPages); + const resolution = options.resolution ?? PDF_RESOLUTION; + assertResolution(resolution); + if (options.signal?.aborted === true) { + throw abortError(); + } + + 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 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 image; + } + + public async destroy(): Promise { + if (this.destroyed) { + return; + } + this.destroyed = true; + this.queue.splice(0).forEach((request) => { + this.settle(request, (caller) => { + caller.reject(destroyedError()); + }); + }); + this.current?.task?.cancel(); + this.objectUrls.forEach((url) => { + URL.revokeObjectURL(url); + }); + this.objectUrls.clear(); + this.cache.clear(); + 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); + } + } + + /** + * 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 attach( + request: RenderRequest, + signal: AbortSignal | undefined, + ): Promise { + 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 }); + } + request.callers.push(caller); + }); + } + + /** + * Removes an aborting caller. The shared render is dropped (or cancelled + * 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); + 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) { + this.inFlight.delete(request.key); + this.current.task?.cancel(); + } + } + + /** 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, + settleCaller: (caller: Caller) => void, + ): void { + 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); + } + settleCaller(caller); + }); + } + + 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); + if (this.destroyed) { + this.discard(request, image); + throw destroyedError(); + } + this.cache.set(request.key, image); + this.settle(request, (caller) => { + caller.resolve(image); + }); + } catch (error: unknown) { + const reason = this.destroyed ? destroyedError() : error; + this.settle(request, (caller) => { + caller.reject(reason); + }); + } finally { + this.current = undefined; + } + request = this.queue.shift(); + } + } + + 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), + MAX_PDF_SCALE, + ); + const viewport = page.getViewport({ scale }); + const canvas = document.createElement('canvas'); + 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) { + this.current.task = task; + } + if (request.callers.length === 0 || 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 { signal } = options; + const isAborted = (): boolean => signal?.aborted === true; + if (isAborted()) { + throw abortError(); + } + const { pdfjs, worker } = await loadPdfjs(); + 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 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]() { + assertPageInRange(from, pdf.numPages); + assertPageInRange(to, pdf.numPages); + if (to < from) { + throw new RangeError( + `Invalid page range: ${String(from)}-${String(to)}`, + ); + } + for (let pageNumber = from; pageNumber <= to; pageNumber += 1) { + yield { + pageNumber, + image: await queue.request(pageNumber, pageOptions, 'low'), + }; + } + }, + }), + destroy: async () => { + await queue.destroy(); + }, + }; };