Migrating from pdf.js
This guide moves a pdf.js integration to @sdk-pdf/react. It covers both common setups: the display layer alone (pdfjsLib.getDocument and your own canvas rendering) and the viewer components (pdfjsViewer.PDFViewer with an EventBus and a PDFLinkService). Work through the checklist below; each step links to the section that covers it.
Beta:
@sdk-pdf/reactis in beta, and its exports and APIs may change between releases. Pin the exact package version and review the changes when you update.
Migration checklist
- Read the overview: how the two integrations differ, and the API map for every pdf.js API you use.
- Install the package and set up
PdfProviders, with a viewer content policy if you ship a reader. - Load and reset documents with
loadPdfandresetPdf, including the password flow. - Replace canvas rendering or
PDFViewerwithViewport. - Move page navigation from numbers to page ids.
- Move zoom and the fit presets.
- Rebuild thumbnails with
PageCanvas. - Move form reading and filling to widgets.
- Map annotation editor modes to interaction tools.
- Replace undo/redo and modified-state tracking.
- Replace
saveDocument()and downloads. - Move error handling and logging.
- Remove manual cleanup.
- Move text extraction and plan for features without an equivalent.
- Remove
pdfjs-dist, the worker file, cMaps, fonts,pdf_viewer.css, and the event bus wiring. Test encrypted, malformed, large, and form-heavy documents, and the save round-trip.
Overview
How the two integrations differ
pdf.js is split into three layers: the core layer (pdf.worker.mjs) parses the PDF in a worker, the display layer (pdf.mjs) renders pages and reads document data, and the viewer layer is the Firefox viewer UI, whose building blocks (PDFViewer, EventBus, PDFLinkService) ship as components in pdfjs-dist/web/pdf_viewer.mjs. An integration built on it is imperative and object-based:
- you point
GlobalWorkerOptions.workerSrcat the worker file and keep it on exactly the same version as the library; getDocument()returns aPDFDocumentLoadingTaskwhosepromiseresolves with aPDFDocumentProxy;- you get pages by 1-based number (
getPage(pageNumber)), compute aPageViewport, and either render each page into a canvas yourself or hand the document toPDFViewer, which manages page views, text and annotation layers, scrolling, and zoom; - state lives in objects you hold (the document proxy, its
annotationStorage, the viewer), observed through promises, callbacks, andEventBusevents; - you release resources yourself:
RenderTask.cancel(),PDFPageProxy.cleanup(),PDFDocumentProxy.cleanup(),PDFDocumentLoadingTask.destroy().
pdf.js is built for reading; its annotation editor adds a subset of annotation types on top of the page.
@sdk-pdf/react owns the document, and you own the UI:
PdfProvidershosts the document and viewport state; every hook and component renders inside it.useLoadPdfloads a document from bytes and reports a load state.Viewportrenders the pages and handles selection, text editing, form filling, link navigation, and zoom and drag gestures on its own. There is no per-page render call, text layer, or annotation layer to assemble.- Toolbars, sidebars, and shortcuts are your React components: state hooks re-render them on change, and command hooks change the document.
- Pages are addressed by id, not by number: an id keeps pointing at the same page while pages are added, removed, and rearranged.
- Edits don't rewrite the PDF file. Each change is an operation in a CRDT, which powers undo/redo; the final PDF is built when you save.
| Concern | pdf.js | @sdk-pdf/react |
|---|---|---|
| Integration style | Imperative objects, promises, callbacks, EventBus | A React provider, state hooks, and command hooks |
| Rendering | Your render loop (display layer) or PDFViewer | Viewport for the main view, PageCanvas for thumbnails and previews |
| Page addressing | 1-based page numbers | Session-scoped page ids from usePages |
| Document state | Objects you hold: PDFDocumentProxy, annotationStorage | Owned by the SDK, read through hooks |
| Default capability | Reading and form filling; the annotation editor idle (NONE) | Everything editable (PDF_EDITOR) unless you configure a read-only policy |
| Editing model | Annotation editors (FreeText, Highlight, Stamp, Ink, and others) | Page text, form widgets, image stamps, and pages; ink from code; no highlights or comments |
| Saving | saveDocument() | prepareDocument() and downloadDocument() |
| Diagnostics | Rejected promises, verbosity console logging | Typed errors thrown before any change; logMessages, never printed to the console |
| Framework | Framework-agnostic | React |
What this means for your code:
- Delete the render loop. Page rendering, render queues (such as
pageRenderingandpageNumPendingflags), HiDPI canvas sizing, and text and annotation layers move intoViewport. - Replace numbers with ids. Every page reference in your state becomes a page id; convert at the edges (deep links, analytics) with
usePagesandusePageIndexGetter. - Replace event-bus listeners with hooks. State you mirrored from events (current page, page count, scale, undo availability) comes from state hooks, so
pagechangingandscalechangingbecomeuseCurrentPageIdanduseViewportScale.useViewportEventreports only user interactions, such as drags and zoom gestures. - Rethink forms as widgets. Field maps become widget lists; values are read and written through
useWidgetValue. - Rethink editing. pdf.js adds annotations on top of the page;
@sdk-pdf/reactedits page text, widgets, images, and pages, and restyles the borders of selected shapes, lines, ink, and widgets. - Choose the policy. The default is
PDF_EDITOR; a reader needsPDF_VIEWERorPDF_PRINTER. - Move into React. Code that drives pdf.js from outside React needs a React boundary.
API map
Rows marked No equivalent are covered in Features without an equivalent.
| pdf.js | @sdk-pdf/react | Notes |
|---|---|---|
GlobalWorkerOptions.workerSrc, cMapUrl, standardFontDataUrl, wasmUrl | Nothing to configure | The install is the package and PdfProviders. |
getDocument({ url }) | fetch the bytes, then loadPdf(data) from useLoadPdf | loadPdf takes a Uint8Array. |
getDocument({ data }) | loadPdf(data) | Both take over the buffer; pass a copy to reuse it. |
getDocument({ password }), loadingTask.onPassword, PasswordResponses | loadPdf(data, { password }), catch InvalidPasswordError | A rejection replaces the callback. |
loadingTask.onProgress | No equivalent | The load state is 'initial', 'loading', or 'ready'. |
loadingTask.promise | The loadPdf promise and loadState.status === 'ready' | |
loadingTask.destroy() to close a document | resetPdf() from useResetPdf | Returns the load state to 'initial'. |
pdfDocument.numPages | usePageCount | 0 while no document is loaded. |
pdfDocument.getPage(pageNumber) | usePages()[pageNumber - 1].id with usePages | Each page has id, width, height, and rotation. |
page.getViewport(), page.render(), TextLayer, AnnotationLayer | Viewport | One component, no layers to assemble. |
new PDFViewer({ container, eventBus, linkService }), setDocument() | PdfProviders plus Viewport, rendered when the load state is 'ready' | |
pdf_viewer.css | The theme configuration, see Theming and customization | The SDK renders unstyled. |
pdfViewer.currentPageNumber = n, nextPage(), previousPage(), PDFLinkService.goToPage() | scrollToPage(pageId) from useScrollToPage | |
eventBus.on('pagechanging') | useCurrentPageId | Re-renders with the id of the page in the middle of the viewport. |
pdfViewer.currentScale, increaseScale(), decreaseScale() | useViewportScale | Scale 1 is one user-space unit per CSS pixel. |
pdfViewer.currentScaleValue = 'page-width' or 'page-height' | useViewportAutoFitScale, useViewportFitScale | A one-time fit, not a persistent mode. |
currentScaleValue = 'page-fit' | setScale(Math.min(fitWidthScale, fitHeightScale)) | See Zoom and fit modes. |
currentScaleValue = 'auto' | No equivalent | |
eventBus.on('scalechanging') | scale from useViewportScale in a useEffect | zoomGestureEnd from useViewportEvent reports pinch and wheel zoom. |
pdfViewer.pagesRotation (view only) | No equivalent | useSetPageRotation edits the document. |
Thumbnails rendered with a small getViewport({ scale }) | PageCanvas | Lazy; shows the page as loaded. Showing session edits is coming soon. |
AnnotationMode.ENABLE_FORMS | Content policy widgets: 'interactive' (CONTENT_POLICY_PRESET.PDF_VIEWER) | |
pdfDocument.getFieldObjects() | useWidgets, useWidgetsGetter | Widgets in reading order, not a map of fields. |
annotationStorage.getValue(), setValue() | useWidgetValue | One widget per hook call. |
PDFScriptingManager, enableXfa | No equivalent | Acrobat date formats are recognized without running scripts. |
pdfViewer.annotationEditorMode = { mode } | useSetInteractionMode | See Editing and interaction tools. |
AnnotationEditorType.FREETEXT | { type: 'addText' } | The nearest tool; it creates a text paragraph. |
AnnotationEditorType.STAMP | { type: 'addImage', image } with stageImage | Places a stamp annotation. |
AnnotationEditorType.INK | useInkAnnotationCreate | Creates ink from strokes your code collects; there is no drawing tool. |
AnnotationEditorType.HIGHLIGHT, SIGNATURE, COMMENT | No equivalent | |
eventBus.dispatch('editingaction', { name: 'undo' }), 'editingstateschanged' | useUndoRedo | |
annotationStorage.onSetModified, onResetModified | useDocumentRevision, useDocumentRevisionGetter | Compare revisions to tell whether there are unsaved changes. |
pdfDocument.saveDocument() | prepareDocument() from useDocumentSaving | Resolves with a Uint8Array. |
pdfDocument.getData() | prepareDocument() with the default returnOriginalIfUnchanged: true | Returns the original buffer only while there are no changes. |
DownloadManager | downloadDocument() from useDocumentSaving | |
InvalidPDFException | DocumentFormatError | See Errors and logging. |
ResponseException | Errors from your own fetch | loadPdf takes bytes; fetching is yours. |
verbosity, VerbosityLevel | logMessages, LogLevel | The SDK never prints to the console. |
RenderTask.cancel(), PDFPageProxy.cleanup(), PDFDocumentProxy.cleanup(), PDFWorker.destroy() | Nothing to call | See Cleanup and lifecycle. |
page.getTextContent() | getParagraphsByPage(pageId) from useProgrammaticEditor | Paragraph text and bounds, not text items. |
PDFFindController | useDocumentSearch | Highlights the matches and scrolls to the active one. |
pdfDocument.extractPages() | prepareDocument({ pageIds }) | Saves only the listed pages, in the listed order. |
Install and set up
Before: pdf.js
pdf.js ships as pdfjs-dist. A page loads pdfjs-dist/build/pdf.mjs (plus pdfjs-dist/web/pdf_viewer.mjs and pdf_viewer.css for the viewer components), works through the pdfjsLib and pdfjsViewer namespaces, and sets up the worker and the cMaps before loading anything:
pdfjsLib.GlobalWorkerOptions.workerSrc =
'../../node_modules/pdfjs-dist/build/pdf.worker.mjs'
// Some PDFs need external cMaps.
const CMAP_URL = '../../node_modules/pdfjs-dist/cmaps/'
const CMAP_PACKED = trueAfter: @sdk-pdf/react
The package comes from a private registry. Add the @sdk-pdf scope to an .npmrc next to your package.json, keep the token in an environment variable, and install; Getting started has the details:
@sdk-pdf:registry=https://registry.sdk.pdf.net
//registry.sdk.pdf.net/:_authToken=${PDF_NET_REGISTRY_TOKEN}npm install @sdk-pdf/reactSet up the project as Getting started describes: import the stylesheet, render the SDK on the client only, alias canvas in the bundler, and turn off React Strict Mode.
Then wrap the part of your app that shows documents in PdfProviders. Its props are optional; the PDF application re-renders whenever a prop changes identity, so define static configuration outside the component and memoize the rest:
import { CONTENT_POLICY_PRESET, InteractionTool, PdfProviders } from '@sdk-pdf/react'
import '@sdk-pdf/react/style.css'
// Static configuration lives outside the component, so its identity never changes.
const initialInteractionTool: InteractionTool = { type: 'textSelect' }
export function App() {
return (
<PdfProviders
initialInteractionTool={initialInteractionTool}
initialContentPolicy={CONTENT_POLICY_PRESET.PDF_VIEWER}
>
<DocumentLoader url="/files/document.pdf" />
</PdfProviders>
)
}What to know
- Pick the content policy deliberately. A pdf.js viewer reads and fills forms by default:
PDFViewer'sannotationModedefaults toAnnotationMode.ENABLE_FORMS, andannotationEditorModetoAnnotationEditorType.NONE.PdfProvidersdefaults to theselecttool andCONTENT_POLICY_PRESET.PDF_EDITOR, which makes text, annotations, links, and widgets editable. To keep a reader's behavior, start withPDF_VIEWER(text and annotations read-only, links and widgets interactive) and thetextSelecttool, as above. - No asset URLs. There is no worker file, cMap folder, font folder, or WebAssembly URL to point at.
- React only. Code that drives pdf.js from outside React moves into components and hooks.
Load and reset a document
Before: pdf.js
getDocument() takes a URL or binary data. The loading task asks for passwords and reports progress through callbacks; onPassword receives a function to call with the password and a PasswordResponses reason:
const loadingTask = pdfjsLib.getDocument({ url })
loadingTask.onPassword = (updatePassword, reason) => {
const isRetry = reason === pdfjsLib.PasswordResponses.INCORRECT_PASSWORD
askForPassword({ isRetry }).then(updatePassword)
}
loadingTask.onProgress = ({ loaded, total }) => {
showProgress(loaded, total)
}
const pdfDocument = await loadingTask.promiseTo open another document, destroy the old loading task first; destroy() aborts its network requests and destroys the worker:
await loadingTask.destroy()After: @sdk-pdf/react
useLoadPdf returns the load state and loadPdf(pdfFileContent: Uint8Array, options?: { password?: string }): Promise<void>. The bytes can come from a file input, a fetch, or drag-and-drop, so a URL becomes a fetch:
import { useRef, useState } from 'react'
import { InvalidPasswordError, useLoadPdf } from '@sdk-pdf/react'
function DocumentLoader({ url }: { url: string }) {
const [loadState, loadPdf] = useLoadPdf()
const bytesRef = useRef<Uint8Array | null>(null)
const [needsPassword, setNeedsPassword] = useState(false)
async function open(password?: string) {
if (!bytesRef.current) {
const response = await fetch(url)
bytesRef.current = new Uint8Array(await response.arrayBuffer())
}
try {
// loadPdf reads from the buffer it receives for the document's lifetime,
// so keep the original for a retry and pass a copy.
await loadPdf(bytesRef.current.slice(), { password })
setNeedsPassword(false)
} catch (error) {
if (error instanceof InvalidPasswordError) {
// The password is missing or wrong: ask for it, then call open(password) again.
setNeedsPassword(true)
return
}
throw error
}
}
if (needsPassword) return <PasswordPrompt onSubmit={open} />
if (loadState.status === 'initial')
return <button onClick={() => void open()}>Open</button>
if (loadState.status === 'loading') return <p>Loading the document…</p>
return <PdfApplication />
}To open another document, unload the current one with useResetPdf:
import { useResetPdf } from '@sdk-pdf/react'
function OpenAnotherButton() {
const resetPdf = useResetPdf()
// The load state returns to 'initial'.
return <button onClick={() => resetPdf()}>Open another document</button>
}What to know
- Bytes, not URLs.
loadPdftakes aUint8Array. Network options ofgetDocument(httpHeaders,withCredentials,range,disableRange,disableStream,disableAutoFetch) move to your ownfetch; range and streaming loading have no equivalent. - Buffer reuse needs the same care. pdf.js transfers a TypedArray to its worker and takes ownership of it.
loadPdfreads from the given buffer for the document's whole lifetime, and anything else touching that buffer can detach it. To reuse the bytes, pass a copy:loadPdf(data.slice()). - Passwords are a rejection, not a callback. Without a password, the empty password is tried, as the PDF specification prescribes; a missing or wrong password rejects with
InvalidPasswordError. There is noPasswordResponses: keep your own first-attempt-or-retry state. - No progress reporting.
loadState.statusis'initial','loading', or'ready'. - Reset invalidates ids. After
resetPdf(), page and block ids, widget snapshots, and document revisions from the old document are no longer valid.
Display the document
Before: pdf.js display layer
With the display layer alone, you render each page into a canvas you size yourself:
const pdf = await pdfjsLib.getDocument({ url }).promise
const page = await pdf.getPage(1)
const viewport = page.getViewport({ scale: 1.5 })
const outputScale = window.devicePixelRatio || 1
const canvas = document.getElementById('the-canvas')
const context = canvas.getContext('2d')
canvas.width = Math.floor(viewport.width * outputScale)
canvas.height = Math.floor(viewport.height * outputScale)
canvas.style.width = Math.floor(viewport.width) + 'px'
canvas.style.height = Math.floor(viewport.height) + 'px'
const transform = outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : null
page.render({ canvasContext: context, transform, viewport })Text selection and interactive annotations need the TextLayer and AnnotationLayer classes on top of the canvas.
Before: pdf.js viewer components
With the viewer components, PDFViewer manages the pages:
const container = document.getElementById('viewerContainer')
const eventBus = new pdfjsViewer.EventBus()
const pdfLinkService = new pdfjsViewer.PDFLinkService({ eventBus })
const pdfViewer = new pdfjsViewer.PDFViewer({
container,
eventBus,
linkService: pdfLinkService,
})
pdfLinkService.setViewer(pdfViewer)
eventBus.on('pagesinit', () => {
pdfViewer.currentScaleValue = 'page-width'
})
const pdfDocument = await pdfjsLib.getDocument({ url }).promise
pdfViewer.setDocument(pdfDocument)
pdfLinkService.setDocument(pdfDocument, null)After: @sdk-pdf/react
Viewport takes no props: PdfProviders configures it, the theme styles it, and hooks control it:
import { useViewportAutoFitScale, Viewport } from '@sdk-pdf/react'
function PdfApplication() {
// Replaces `currentScaleValue = 'page-width'` on 'pagesinit'.
useViewportAutoFitScale('width')
return (
// The viewport fills its parent, so give the parent an explicit size.
<div style={{ height: '100vh' }}>
<Viewport />
</div>
)
}What to know
- Render only when ready.
ViewportthrowsDocumentNotLoadedErrorwhen no document is loaded, so render it only when the load state is'ready'. Thepagesinithook point becomes the component rendered after'ready'. - No layers to stack. Text selection, link navigation, and form filling happen inside
Viewport, governed by the interaction mode. WherePDFViewertakestextLayerModeandannotationMode, text selection is thetextSelecttool, and widgets and links are levels of the content policy:widgets: 'interactive'lets users fill fields,links: 'interactive'keeps links clickable, and'hidden'doesn't render that kind of content at all. A level can also be a function of the block id, such aswidgets: (blockId) => (lockedIds.has(blockId) ? 'readonly' : 'interactive'). - Styling replaces pdf_viewer.css. The SDK renders unstyled: attach class names and inline styles through
theme.classNamesandtheme.styles, and override CSS variables such as--selection-color, as Theming and customization describes. The gap between the pages and the viewport edges istheme.viewportPadding. - One layout.
Viewportshows the pages in one scrollable area;ScrollMode,SpreadMode, andPDFSinglePageViewerhave no equivalent.
Pages and navigation
Before: pdf.js
With the display layer, navigation is your own page counter and render queue, because one canvas can't render two pages at once:
function onNextPage() {
if (pageNum >= pdfDoc.numPages) return
pageNum++
queueRenderPage(pageNum)
}With PDFViewer, the current page is a property, and changes arrive on the event bus:
// Go to page 3 (1-based).
pdfViewer.currentPageNumber = 3
// Go to the next page, taking scroll and spread modes into account.
pdfViewer.nextPage()
eventBus.on('pagechanging', ({ pageNumber }) => {
pageNumberInput.value = pageNumber
})After: @sdk-pdf/react
usePages returns the pages in document order, so a position is an index in that list. useScrollToPage scrolls to a page id, and useCurrentPageId re-renders with the page in the middle of the viewport:
import { useCurrentPageId, usePages, useScrollToPage } from '@sdk-pdf/react'
function PageNavigation() {
const pages = usePages()
const currentPageId = useCurrentPageId()
const scrollToPage = useScrollToPage()
// -1 while there is no current page yet.
const index = pages.findIndex((page) => page.id === currentPageId)
return (
<div role="toolbar">
<button disabled={index <= 0} onClick={() => scrollToPage(pages[index - 1].id)}>
Previous
</button>
<span>
{index === -1 ? '–' : index + 1} of {pages.length}
</span>
<button
disabled={index === -1 || index >= pages.length - 1}
onClick={() => scrollToPage(pages[index + 1].id)}
>
Next
</button>
</div>
)
}scrollToPage(pageId, options?) takes behavior ('auto' | 'smooth' | 'instant'), vertical ('top' | 'center' | 'bottom' | 'nearest'), and horizontal ('left' | 'center' | 'right' | 'nearest').
Converting between numbers and ids
| You have | You need | Use |
|---|---|---|
A 1-based page number n | A page id | usePages()[n - 1].id |
| A page id | Its position | getPageIndex(pageId) from usePageIndexGetter: a zero-based index or undefined |
| A page id, rendered | Its position | usePageIndexGetter paired with useRenderOnPageOrderChange |
| Only the count | usePageCount |
What to know
- Ids are session-scoped. Don't persist page ids or reuse them across reloads. Keep persisting page numbers (for example in a
#page=3deep link) and resolve them to ids once the document is ready. - Ids can go stale.
usePageIndexGetterreturnsundefinedfor an unknown id, andscrollToPagethrowsInvalidInputError. - Rotation edits the document.
pdfViewer.pagesRotationrotates the view only.setPageRotation(pageIdOrPageIds, rotation)fromuseSetPageRotationrotates the pages in the document as an undoable edit, with an absolute angle of0,90,180, or270. - Page operations edit the loaded document.
@sdk-pdf/reactchanges the pages of the loaded document:usePageCreate,usePageDelete,usePageMove,usePageDuplicate, anduseSetPageRotation. Deleting, moving, or rotating several pages in one call is a single undo step. The counterpart ofextractPages()isprepareDocument({ pageIds }), which saves only the listed pages. - Links. Internal links work inside
Viewportwhenlinksis'interactive'.PDFLinkService'sexternalLinkTarget, named destinations, and page labels have no equivalent.
Zoom and fit modes
Before: pdf.js
The display layer zooms by re-rendering with a different getViewport({ scale }). PDFViewer has a scale, preset fit values, and an event:
// or 'page-height', 'page-fit', 'page-actual', 'auto'
pdfViewer.currentScaleValue = 'page-width'
pdfViewer.currentScale = newScale
pdfViewer.increaseScale()
eventBus.on('scalechanging', ({ scale, presetValue }) => {
updateZoomLabel(scale, presetValue)
})After: @sdk-pdf/react
useViewportScale reads and sets the scale, useViewportFitScale calculates fit scales on demand, and useViewportAutoFitScale fits once per loaded document:
import { useEffect } from 'react'
import { useViewportFitScale, useViewportScale } from '@sdk-pdf/react'
function ZoomControls() {
const [scale, setScale] = useViewportScale()
const getFitScale = useViewportFitScale()
// Every scale change, whatever the source: controls, gestures, or an auto fit.
useEffect(() => saveZoomPreference(scale), [scale])
return (
<div role="toolbar">
<button onClick={() => setScale(scale / 1.25)}>−</button>
<span>{Math.round(scale * 100)}%</span>
<button onClick={() => setScale(scale * 1.25)}>+</button>
{/* Call the getter inside the handler: the result reflects the viewport size at
that moment. */}
<button onClick={() => setScale(getFitScale().fitWidthScale)}>Fit width</button>
<button onClick={() => setScale(getFitScale().fitHeightScale)}>Fit height</button>
</div>
)
}Mapping the presets
currentScaleValue | @sdk-pdf/react |
|---|---|
'page-width' | useViewportAutoFitScale('width') on load, or setScale(getFitScale().fitWidthScale), which fits the widest page into the viewport width. |
'page-height' | useViewportAutoFitScale('height'), or fitHeightScale, which fits the tallest page into the viewport height. |
'page-fit' | setScale(Math.min(fitWidthScale, fitHeightScale)) fits the pages in both directions. |
'page-actual' | setScale(96 / 72) shows pages at the same size. PDFViewer multiplies its scale by 96 / 72, while @sdk-pdf/react's scale 1 (its 100%) is one user-space unit per CSS pixel, the same unit as the display layer's getViewport({ scale: 1 }). A stored PDFViewer scale s becomes setScale(s * 96 / 72). |
'auto' | No equivalent. |
What to know
- Fits are one-time calculations.
useViewportAutoFitScaleapplies when a document loads and doesn't re-apply on viewport resizes or manual zooming;useViewportFitScalereflects the viewport size at the moment of the call. There is no fit mode that stays applied while the viewport resizes. - Gestures are built in. Pinch and wheel/trackpad zoom go through the same scale and are tuned with
zoomGesturesonPdfProviders(minScale0.5andmaxScale4by default). - Zoom around a point.
setScale(scale, pivot?)keeps the document still atpivot, a point{ x, y }given as fractions of the viewport area (0the top or left edge,1the bottom or right edge), the center by default. The third item ofuseViewportScale(),animateScale(scale, pivot?), animates the change and resolves when it ends. Both throwInvalidInputErrorunless the scale is a positive finite number. - The scale updates after a gesture. During a pinch or wheel zoom,
scalekeeps its value until the gesture ends;zoomGestureStartandzoomGestureEndfromuseViewportEventmark the gesture.
Thumbnails
Before: pdf.js
The pdf.js viewer app's PDFThumbnailViewer isn't one of the exported viewer components, so integrations render thumbnails themselves at a small scale:
const page = await pdfDocument.getPage(pageNumber)
const desiredWidth = 100
const viewport = page.getViewport({ scale: 1 })
const scale = desiredWidth / viewport.width
const scaledViewport = page.getViewport({ scale })
canvas.width = scaledViewport.width
canvas.height = scaledViewport.height
await page.render({ canvas, viewport: scaledViewport }).promiseAfter: @sdk-pdf/react
PageCanvas renders one page onto a canvas anywhere outside the main view; with usePages and useScrollToPage, it makes a navigation sidebar:
import { PageCanvas, usePages, useScrollToPage } from '@sdk-pdf/react'
function ThumbnailsSidebar() {
const pages = usePages()
const scrollToPage = useScrollToPage()
return (
<nav>
{pages.map((page, index) => (
<button key={page.id} onClick={() => scrollToPage(page.id)}>
{/* The render resolution follows the canvas CSS size: style .thumbnail to the
width you need. */}
<PageCanvas
pageId={page.id}
className="thumbnail"
role="img"
aria-label={`Thumbnail ${index + 1}`}
/>
</button>
))}
</nav>
)
}What to know
- Size with CSS, not scale math. The render resolution follows the canvas's displayed CSS size, or an explicit
scaleprop. - Lazy by default. An off-screen canvas does no work until it becomes visible. In a virtualized list, pass
isVisibleto skip the per-canvas intersection observer. - Other props:
renderOnResizefor window-relative sizes,hideAnnotations(form widgets included), andhideExtractableTextfor drawing your own text on top. - Thumbnails that show session edits are coming soon.
PageCanvasdraws the page as loaded from the file; the main view shows the edits. Rotation is applied and followed live. - Render only when ready.
PageCanvasthrowsDocumentNotLoadedErrorwhen no document is loaded.
Forms
Before: pdf.js
PDFViewer renders interactive form elements by default (annotationMode: AnnotationMode.ENABLE_FORMS). Field data comes from the document proxy, and values live in its annotationStorage, a key/value store typed as string keys and Object values:
// A Map of AcroForm field data, or null when the PDF has no fields.
const fieldObjects = await pdfDocument.getFieldObjects()
const storage = pdfDocument.annotationStorage
const value = storage.getValue(key, defaultValue)
storage.setValue(key, newValue)After: @sdk-pdf/react
Users fill fields in Viewport with nothing to build when the content policy allows interacting with widgets. For form UI outside the main view, useWidgets lists the widgets in reading order and useWidgetValue binds one widget's value like a useState backed by the document. Working with forms builds the full sidebar:
import {
CheckboxWidget,
TextWidget,
useWidgets,
useWidgetValue,
Widget,
} from '@sdk-pdf/react'
function FormSidebar() {
const { widgets, isLoaded } = useWidgets()
// The list is partial until every page containing widgets has been loaded.
if (!isLoaded) return <p>Loading the form…</p>
return (
<form>
{widgets.map((widget) => (
<FormField key={widget.id} widget={widget} />
))}
</form>
)
}
function FormField({ widget }: { widget: Widget }) {
switch (widget.widgetType) {
case 'text':
return <TextField widget={widget} />
case 'checkbox':
return <CheckboxField widget={widget} />
default:
return null
}
}
function TextField({ widget }: { widget: TextWidget }) {
const [value, setValue] = useWidgetValue(widget)
return (
<label>
{widget.fieldName}
<input value={value} onChange={(event) => setValue(event.currentTarget.value)} />
</label>
)
}
function CheckboxField({ widget }: { widget: CheckboxWidget }) {
const [isChecked, setIsChecked] = useWidgetValue(widget)
return (
<label>
<input
type="checkbox"
checked={isChecked}
onChange={(event) => setIsChecked(event.currentTarget.checked)}
/>
{widget.fieldName}
</label>
)
}From fields to widgets
| Concept | pdf.js | @sdk-pdf/react |
|---|---|---|
| Unit of work | A map of AcroForm fields | Widgets, the visible boxes users interact with; each carries a fieldName |
| Shared value | Fields | Every widget with the same field name shows and synchronizes the same value |
| Value shape | Storage objects | text and choice: string; checkbox and radio: boolean; multiChoice: selected option indices; pushButton and signature: no value |
| Completeness | — | useWidgets().isLoaded, or await getWidgets() from useWidgetsGetter, which loads every page with widgets first |
| Date fields | PDFScriptingManager runs document scripts | The SDK recognizes Acrobat date formats without running scripts and exposes them as the text widget's format (FormatType.DateTime); see FormatType |
What to know
- Values follow the document. Filling in the main view and undo/redo show up as they happen. With nothing to read, the value is the kind's empty value:
'',false, or[]. - Values are read and written per widget.
useWidgetValuebinds one widget per hook call; there is no setter by field name and no FDF or XFDF form-data export.prepareDocument()produces the filled PDF. - Field change events. The
widgetInputevent ofuseViewportEventreports a value the user changed in the main view, with the widget id; values set from code, undo, and redo emit nothing.useFocusedWidgetIdreturns the widget being filled, anduseDocumentRevisionchanges on any edit. - No scripting or XFA.
PDFScriptingManagerand theenableXfaloading option have no equivalent: date formats are recognized without running scripts, and fields driven by hand-written scripts read as plain text fields. - Form building is new.
useWidgetsCreate, theaddWidgettool,useSetWidgetProps, anduseBlockDeletecreate, restyle, and delete widgets. To lock a submitted form, callsetWidgetProps(ids, { isReadOnly: true }, { excludeFromHistory: true }).
Editing and interaction tools
Before: pdf.js
PDFViewer takes an annotationEditorMode option that enables creating and editing annotations, AnnotationEditorType.NONE by default. The setter switches the active editor and dispatches annotationeditormodechanged. The editor types are DISABLE, NONE, FREETEXT, HIGHLIGHT, STAMP, INK, POPUP, SIGNATURE, and COMMENT; passing DISABLE as the option turns the editor off, and the setter then throws The AnnotationEditor is not enabled.:
const pdfViewer = new pdfjsViewer.PDFViewer({
container,
eventBus,
annotationEditorMode: pdfjsLib.AnnotationEditorType.NONE,
})
// Later, from a toolbar button:
pdfViewer.annotationEditorMode = { mode: pdfjsLib.AnnotationEditorType.FREETEXT }After: @sdk-pdf/react
What users may do is the interaction mode: a content policy (a permission level per content kind) and an interaction tool (what a click, tap, or drag does). useSetInteractionMode switches both at once, and useInteractionTool reads the tool back. Building a PDF application lists the tools:
import {
CONTENT_POLICY_PRESET,
InteractionTool,
stageImage,
useInteractionTool,
useSetInteractionMode,
} from '@sdk-pdf/react'
const textSelectTool: InteractionTool = { type: 'textSelect' }
const selectTool: InteractionTool = { type: 'select' }
const addTextTool: InteractionTool = { type: 'addText' }
function EditingToolbar() {
const interactionTool = useInteractionTool()
const setInteractionMode = useSetInteractionMode()
async function placeImage(file: File) {
const image = await stageImage(file)
// The next click on a page places the image as a stamp annotation.
// Placing images is editing, so switch the policy with the tool.
setInteractionMode({ type: 'addImage', image }, CONTENT_POLICY_PRESET.PDF_EDITOR)
}
return (
<div role="toolbar">
<button
aria-pressed={interactionTool.type === 'textSelect'}
onClick={() =>
setInteractionMode(textSelectTool, CONTENT_POLICY_PRESET.PDF_VIEWER)
}
>
Read
</button>
<button
aria-pressed={interactionTool.type === 'select'}
onClick={() => setInteractionMode(selectTool, CONTENT_POLICY_PRESET.PDF_EDITOR)}
>
Edit
</button>
<button
aria-pressed={interactionTool.type === 'addText'}
onClick={() => setInteractionMode(addTextTool, CONTENT_POLICY_PRESET.PDF_EDITOR)}
>
Add text
</button>
<input
type="file"
accept="image/*"
onChange={(event) => {
const file = event.currentTarget.files?.[0]
if (file) placeImage(file).catch(showError)
}}
/>
</div>
)
}Mapping the editors
pdf.js AnnotationEditorType | @sdk-pdf/react |
|---|---|
NONE | { type: 'textSelect' } for native text selection, or { type: 'select' } to select, move, and resize blocks. |
FREETEXT | The nearest tool is { type: 'addText' }: it creates a text paragraph at the click position and focuses it for typing. It requires editable text, not editable annotations. |
STAMP | { type: 'addImage', image, width?, height? } places a staged image as a stamp annotation at the click position; stageImage(blob) validates and measures it first. The size is in user-space units: a single dimension keeps the aspect ratio, and with none the image keeps its natural size, fit to the page. |
INK | No drawing tool. useInkAnnotationCreate creates an ink annotation from strokes your code collects. |
HIGHLIGHT | No equivalent. |
SIGNATURE | No equivalent. Signature widgets exist as a form kind, and the SDK never signs. |
COMMENT, POPUP | No equivalent. |
What to know
- Editing existing text is new. Under
PDF_EDITOR, text is editable andViewporthandles typing.useSelectionTextStyledrives a style toolbar (fontSize,textColor,fontFamily,isBold,isItalic, and more), anduseFocusSelectionreturns the focus after a toolbar click. - Switch the policy with the tool. Pass
PDF_EDITORwith the editing tools, as above.textSelectneeds read-only text: with editable text, the combination is unsupported and reports a message throughlogMessages. - Selection commands.
useSelectiontells a paragraph in text editing ({ type: 'text', blockId }) from selected blocks ({ type: 'blocks', blockIds }).useDeleteSelection(a selected text range or selected blocks),useDuplicateSelection,useMoveSelection,useSelectBlocks, anduseSelectedBlocksread and act on the selection;useSelectionAnnotationPropseditsborderColorandborderWidthof selected shapes, lines, ink, and widgets. - Keyboard shortcuts are your code. Wire undo, redo, delete, and arrow keys with
useHotkeys. - Programmatic edits.
useProgrammaticEditorreads and rewrites paragraph text without UI, as Programmatic editing shows.
Undo, redo, and change tracking
Before: pdf.js
pdf.js keeps undo/redo inside its annotation editor. The editor listens for editingaction events on the event bus and reports hasSomethingToUndo and hasSomethingToRedo in editingstateschanged. AnnotationStorage calls onSetModified and onResetModified when its modified state changes, which the viewer uses to warn before leaving a page with edited forms:
undoButton.onclick = () => eventBus.dispatch('editingaction', { name: 'undo' })
redoButton.onclick = () => eventBus.dispatch('editingaction', { name: 'redo' })
eventBus.on('editingstateschanged', ({ details }) => {
undoButton.disabled = !details.hasSomethingToUndo
redoButton.disabled = !details.hasSomethingToRedo
})
pdfDocument.annotationStorage.onSetModified = () => setUnsavedChanges(true)
pdfDocument.annotationStorage.onResetModified = () => setUnsavedChanges(false)After: @sdk-pdf/react
useUndoRedo returns the commands and their availability; canUndo and canRedo re-render the component as the history changes, which replaces listening for editingstateschanged:
import { useMemo } from 'react'
import { useHotkeys, useUndoRedo } from '@sdk-pdf/react'
function HistoryControls() {
const { undo, redo, canUndo, canRedo } = useUndoRedo()
// The mapping must be memoized, or the listener re-installs on every render.
useHotkeys(useMemo(() => ({ 'Mod-z': undo, 'Shift-Mod-z': redo }), [undo, redo]))
return (
<div role="toolbar">
<button disabled={!canUndo} onClick={undo}>
Undo
</button>
<button disabled={!canRedo} onClick={redo}>
Redo
</button>
</div>
)
}onSetModified and onResetModified become document revisions: useDocumentRevision re-renders with a new revision on every edit, and undo and redo bring back the revision of that point in the history. Keep the revision you last saved and compare: a different revision means unsaved changes. A save records the revision it saved, so the unsaved-changes indicator lives with the save button in Saving and downloading.
What to know
- One history for the whole document, not only for annotations: deleting, moving, or rotating pages are undo steps, widget property changes are undo steps unless excluded, and form values follow undo and redo.
- Local history. Undo reverts your own changes, and edits made within 500 ms of each other merge into a single undo step, even across hook calls. To keep an operation a separate step, call
markUndoBoundary()fromuseUndoBoundarybefore it starts and after its edits finish. - Opting out. Changes applied with the
excludeFromHistoryoption ofuseSetWidgetPropsnever enter the history. - Events for user actions only.
useViewportEventreports what users do in the main view, such astextEditorInput,addToolApplied,blocksDragEnd, andwidgetInput. Page operations, undo, and redo that your code calls emit no events: track them where you call them, and watchuseDocumentRevisionfor any change.
Saving and downloading
Before: pdf.js
saveDocument() resolves with a Uint8Array holding the full saved document, and getData() resolves with the original file data. saveDocument() also resets the storage's modified state, which fires onResetModified, and warns you to use getData() when the annotation storage is empty. The viewer components export a DownloadManager for downloads:
const data = await pdfDocument.saveDocument()
await fetch('/api/documents', { method: 'PUT', body: data })After: @sdk-pdf/react
useDocumentSaving builds the final PDF from the loaded document and the recorded changes. Saving doesn't reset any modified state: record the saved revision yourself:
import { useRef, useState } from 'react'
import {
useDocumentRevision,
useDocumentRevisionGetter,
useDocumentSaving,
} from '@sdk-pdf/react'
function SaveControls() {
const revision = useDocumentRevision()
const getRevision = useDocumentRevisionGetter()
// The revision on the server: the loaded document's revision until the first save.
const [savedRevision, setSavedRevision] = useState(revision)
// One save at a time, so an overlapping save can't mark a failed upload as saved.
const isSavingRef = useRef(false)
const { prepareDocument, downloadDocument } = useDocumentSaving()
// Replaces onSetModified: undoing back to the saved state clears it too.
const hasUnsavedChanges = revision !== savedRevision
async function saveToServer() {
if (!hasUnsavedChanges || isSavingRef.current) return
isSavingRef.current = true
try {
// Edits made after the save starts may be missing from the saved bytes, so only
// this revision becomes saved.
const startRevision = getRevision()
// The buffer is transferred to fetch, so don't hand out the original.
const buffer = await prepareDocument({ returnOriginalIfUnchanged: false })
const response = await fetch('/api/documents', {
method: 'PUT',
body: buffer as BodyInit,
})
// A failed upload keeps the unsaved-changes indicator.
if (!response.ok) return
// Replaces onResetModified.
setSavedRevision(startRevision)
} finally {
isSavingRef.current = false
}
}
return (
<div role="toolbar">
<button onClick={() => void saveToServer()}>Save</button>
<button onClick={() => void downloadDocument()}>Download</button>
{hasUnsavedChanges && <span>Unsaved changes</span>}
</div>
)
}One component owns the saved revision, so the save and the indicator compare against the same value. Render it only once the document is ready, so its first revision is the loaded document's.
What to know
- returnOriginalIfUnchanged defaults to
true: with no changes, the original buffer passed toloadPdfcomes back with no copy, the closest match togetData(). Set it tofalsewhen you transfer or mutate the result, or the loaded document's buffer is detached and later reads throw. - Both functions throw DocumentNotLoadedError when no document is loaded.
- Save options. Besides
returnOriginalIfUnchanged,pageIdssaves only the listed pages, in the listed order, which replacesextractPages(). There is no flattening or download file name option.
Errors and logging
Before: pdf.js
Errors arrive as rejected promises. The verbosity loading option sets the console logging level with VerbosityLevel constants, and the exported exception classes tell invalid files from missing ones:
const loadingTask = pdfjsLib.getDocument({
url,
verbosity: pdfjsLib.VerbosityLevel.ERRORS,
})
try {
const pdfDocument = await loadingTask.promise
} catch (reason) {
if (reason instanceof pdfjsLib.InvalidPDFException) {
showError('Invalid PDF file')
} else if (reason instanceof pdfjsLib.ResponseException) {
showError(reason.missing ? 'Missing PDF file' : 'Unexpected server response')
}
}After: @sdk-pdf/react
The SDK reports problems through two channels. When an operation is impossible, it throws a typed error and changes nothing. When it hits a problem it can work around, such as a malformed document, it completes the operation and emits a log message through logMessages. Error handling and logging has the full rules:
import { InvalidPasswordError, logMessages, LogLevel, useLoadPdf } from '@sdk-pdf/react'
// Subscribing returns an unsubscribe function.
const unsubscribe = logMessages.on('message', (logMessage) => {
// The numeric levels order by severity, so a threshold is a single comparison.
if (logMessage.level >= LogLevel.Warning) {
reportToMonitoring(logMessage.message, logMessage.data)
}
})
function useLoadWithErrorHandling() {
const [, loadPdf] = useLoadPdf()
return async (data: Uint8Array) => {
try {
// Pass a copy and keep `data` for a password retry.
await loadPdf(data.slice())
} catch (error) {
if (error instanceof InvalidPasswordError) {
askForPassword()
} else {
// Narrow the other SDK error classes with instanceof, as for
// InvalidPasswordError.
showError(error)
}
}
}
}Error classes
Every SDK error extends GenericError and carries a static message and an optional data object:
| @sdk-pdf/react | Thrown when | Closest pdf.js situation |
|---|---|---|
InvalidPasswordError | The document is encrypted, and the password is missing or wrong. | onPassword with PasswordResponses |
DocumentFormatError | The PDF content is malformed beyond what the SDK can work around. | InvalidPDFException |
NotSupportedError | The document uses a PDF feature the SDK doesn't support yet. | — |
DocumentNotLoadedError | An operation requires a document, and none is loaded. | — |
InvalidInputError | The input passed to the API is invalid. | — |
UninitializedError | An object is used before its asynchronous initialization has completed. | — |
| — | — | ResponseException: your own fetch fails instead |
What to know
- Throw-versus-return rules. Functions that modify the document validate first and throw before touching anything; functions that only read return empty values instead of throwing; hooks never throw during render.
ViewportandPageCanvasare the exception: they throwDocumentNotLoadedErrorwhen rendered without a document. - Logs are opt-in. The SDK never prints to the browser console. Each message carries
level(LogLevel.Debug,Info,Warning,Error),reason(aLogReasonmember), a staticmessage,data, andstack. There is no verbosity setting: filter bylevelin your subscriber.
Cleanup and lifecycle
Before: pdf.js
pdf.js leaves resource management to you:
renderTask.cancel() // The render promise rejects when cancelled.
page.cleanup() // Frees the page's resources.
await pdfDocument.cleanup() // Never while rendering is in progress.
await loadingTask.destroy() // Aborts network requests and destroys the worker.After: @sdk-pdf/react
Most of this moves into the SDK and React's component lifecycle:
| Concern | @sdk-pdf/react |
|---|---|
| Cancelling renders | Nothing to call: PageCanvas renders lazily and does no work off-screen. |
| Event listeners | useViewportEvent subscriptions follow the component lifecycle. |
| Log subscriptions | logMessages.on() returns an unsubscribe function. |
| Keyboard listeners | useHotkeys installs window-level shortcuts; pass a falsy value instead of the mapping to disable them. |
| Closing a document | resetPdf() from useResetPdf unloads the document and invalidates its ids, snapshots, and revisions. |
| Buffers | Don't reuse a buffer passed to loadPdf; copy it first. |
In a component, a log subscription follows the same pattern as any other subscription:
import { useEffect } from 'react'
import { logMessages, LogLevel } from '@sdk-pdf/react'
function useReportSdkWarnings() {
useEffect(() => {
const unsubscribe = logMessages.on('message', (logMessage) => {
if (logMessage.level >= LogLevel.Warning)
reportToMonitoring(logMessage.message, logMessage.data)
})
return () => {
unsubscribe()
}
}, [])
}Text extraction and search
pdf.js reads text items with getTextContent() and searches with PDFFindController. In @sdk-pdf/react, getParagraphsByPage(pageId) from useProgrammaticEditor resolves with paragraph entries in content order: id, content, pageNumber, the user-space box bounds ({ x, y, width, height }), and the paragraph layout, such as textAlign and lineHeight:
import { useProgrammaticEditor } from '@sdk-pdf/react'
function useReadPageText() {
const { getParagraphsByPage } = useProgrammaticEditor()
return async (pageId: string) => {
const paragraphs = await getParagraphsByPage(pageId)
return paragraphs.map((paragraph) => paragraph.content).join('\n\n')
}
}useDocumentSearch replaces PDFFindController: set the query and the options, and the SDK finds the matches, highlights them on the pages, and scrolls to the active one:
import { useDocumentSearch } from '@sdk-pdf/react'
function SearchBar() {
const {
criteria,
matches,
activeMatchIndex,
status,
setSearchCriteria,
goToNextMatch,
goToPreviousMatch,
} = useDocumentSearch()
return (
<div role="search">
<input
value={criteria.query}
onChange={(event) =>
setSearchCriteria({ ...criteria, query: event.currentTarget.value })
}
/>
<label>
<input
type="checkbox"
checked={criteria.isCaseSensitive}
onChange={(event) =>
setSearchCriteria({
...criteria,
isCaseSensitive: event.currentTarget.checked,
})
}
/>
Match case
</label>
<span>
{status === 'searching'
? 'Searching…'
: `${activeMatchIndex + 1} of ${matches.length}`}
</span>
<button disabled={matches.length === 0} onClick={goToPreviousMatch}>
Previous
</button>
<button disabled={matches.length === 0} onClick={goToNextMatch}>
Next
</button>
</div>
)
}- Options.
isCaseSensitiveandisWholeWordreplacecaseSensitiveandentireWord; there is no diacritics option. - Matches. Each match carries
id,pageId,paragraphId, and the matchedtext, in document order;goToMatch(match.id)activates one from a results list. A changed query is searched once typing pauses, andstatusis'searching'until the whole document is searched. - No selection API. There is no API to read the user's text selection. To jump to a paragraph you located yourself, call
scrollToBlock(blockId)fromuseScrollToBlock.
Features without an equivalent
Plan around these before you migrate:
| pdf.js feature | In @sdk-pdf/react |
|---|---|
| Printing | No print API; PageCanvas can render pages for your own print layout, as loaded from the file, without session edits |
Outline, named destinations, page labels, PDFHistory | No equivalent |
View-only rotation (pagesRotation) | Rotation is a document edit (useSetPageRotation) |
| Scroll, spread, and single-page modes | One scrollable layout |
The 'auto' zoom preset, and fits that stay applied on resize | Fits are one-time calculations; see Zoom and fit modes |
| Load progress, range and streaming loading | No equivalent |
JavaScript execution (PDFScriptingManager), XFA | No equivalent |
| Ink drawing, highlight, signature, and comment annotation editors | No equivalent; ink annotations can be created from code with useInkAnnotationCreate |
| FDF or XFDF form data export, field-value events | No equivalent |
Localization (GenericL10n), page colors, document permissions | No equivalent |