Migration

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/react is 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

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.workerSrc at the worker file and keep it on exactly the same version as the library;
  • getDocument() returns a PDFDocumentLoadingTask whose promise resolves with a PDFDocumentProxy;
  • you get pages by 1-based number (getPage(pageNumber)), compute a PageViewport, and either render each page into a canvas yourself or hand the document to PDFViewer, 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, and EventBus events;
  • 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:

  • PdfProviders hosts the document and viewport state; every hook and component renders inside it.
  • useLoadPdf loads a document from bytes and reports a load state.
  • Viewport renders 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.
Concernpdf.js@sdk-pdf/react
Integration styleImperative objects, promises, callbacks, EventBusA React provider, state hooks, and command hooks
RenderingYour render loop (display layer) or PDFViewerViewport for the main view, PageCanvas for thumbnails and previews
Page addressing1-based page numbersSession-scoped page ids from usePages
Document stateObjects you hold: PDFDocumentProxy, annotationStorageOwned by the SDK, read through hooks
Default capabilityReading and form filling; the annotation editor idle (NONE)Everything editable (PDF_EDITOR) unless you configure a read-only policy
Editing modelAnnotation editors (FreeText, Highlight, Stamp, Ink, and others)Page text, form widgets, image stamps, and pages; ink from code; no highlights or comments
SavingsaveDocument()prepareDocument() and downloadDocument()
DiagnosticsRejected promises, verbosity console loggingTyped errors thrown before any change; logMessages, never printed to the console
FrameworkFramework-agnosticReact

What this means for your code:

  1. Delete the render loop. Page rendering, render queues (such as pageRendering and pageNumPending flags), HiDPI canvas sizing, and text and annotation layers move into Viewport.
  2. Replace numbers with ids. Every page reference in your state becomes a page id; convert at the edges (deep links, analytics) with usePages and usePageIndexGetter.
  3. Replace event-bus listeners with hooks. State you mirrored from events (current page, page count, scale, undo availability) comes from state hooks, so pagechanging and scalechanging become useCurrentPageId and useViewportScale. useViewportEvent reports only user interactions, such as drags and zoom gestures.
  4. Rethink forms as widgets. Field maps become widget lists; values are read and written through useWidgetValue.
  5. Rethink editing. pdf.js adds annotations on top of the page; @sdk-pdf/react edits page text, widgets, images, and pages, and restyles the borders of selected shapes, lines, ink, and widgets.
  6. Choose the policy. The default is PDF_EDITOR; a reader needs PDF_VIEWER or PDF_PRINTER.
  7. 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/reactNotes
GlobalWorkerOptions.workerSrc, cMapUrl, standardFontDataUrl, wasmUrlNothing to configureThe install is the package and PdfProviders.
getDocument({ url })fetch the bytes, then loadPdf(data) from useLoadPdfloadPdf takes a Uint8Array.
getDocument({ data })loadPdf(data)Both take over the buffer; pass a copy to reuse it.
getDocument({ password }), loadingTask.onPassword, PasswordResponsesloadPdf(data, { password }), catch InvalidPasswordErrorA rejection replaces the callback.
loadingTask.onProgressNo equivalentThe load state is 'initial', 'loading', or 'ready'.
loadingTask.promiseThe loadPdf promise and loadState.status === 'ready'
loadingTask.destroy() to close a documentresetPdf() from useResetPdfReturns the load state to 'initial'.
pdfDocument.numPagesusePageCount0 while no document is loaded.
pdfDocument.getPage(pageNumber)usePages()[pageNumber - 1].id with usePagesEach page has id, width, height, and rotation.
page.getViewport(), page.render(), TextLayer, AnnotationLayerViewportOne component, no layers to assemble.
new PDFViewer({ container, eventBus, linkService }), setDocument()PdfProviders plus Viewport, rendered when the load state is 'ready'
pdf_viewer.cssThe theme configuration, see Theming and customizationThe SDK renders unstyled.
pdfViewer.currentPageNumber = n, nextPage(), previousPage(), PDFLinkService.goToPage()scrollToPage(pageId) from useScrollToPage
eventBus.on('pagechanging')useCurrentPageIdRe-renders with the id of the page in the middle of the viewport.
pdfViewer.currentScale, increaseScale(), decreaseScale()useViewportScaleScale 1 is one user-space unit per CSS pixel.
pdfViewer.currentScaleValue = 'page-width' or 'page-height'useViewportAutoFitScale, useViewportFitScaleA 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 useEffectzoomGestureEnd from useViewportEvent reports pinch and wheel zoom.
pdfViewer.pagesRotation (view only)No equivalentuseSetPageRotation edits the document.
Thumbnails rendered with a small getViewport({ scale })PageCanvasLazy; shows the page as loaded. Showing session edits is coming soon.
AnnotationMode.ENABLE_FORMSContent policy widgets: 'interactive' (CONTENT_POLICY_PRESET.PDF_VIEWER)
pdfDocument.getFieldObjects()useWidgets, useWidgetsGetterWidgets in reading order, not a map of fields.
annotationStorage.getValue(), setValue()useWidgetValueOne widget per hook call.
PDFScriptingManager, enableXfaNo equivalentAcrobat date formats are recognized without running scripts.
pdfViewer.annotationEditorMode = { mode }useSetInteractionModeSee Editing and interaction tools.
AnnotationEditorType.FREETEXT{ type: 'addText' }The nearest tool; it creates a text paragraph.
AnnotationEditorType.STAMP{ type: 'addImage', image } with stageImagePlaces a stamp annotation.
AnnotationEditorType.INKuseInkAnnotationCreateCreates ink from strokes your code collects; there is no drawing tool.
AnnotationEditorType.HIGHLIGHT, SIGNATURE, COMMENTNo equivalent
eventBus.dispatch('editingaction', { name: 'undo' }), 'editingstateschanged'useUndoRedo
annotationStorage.onSetModified, onResetModifieduseDocumentRevision, useDocumentRevisionGetterCompare revisions to tell whether there are unsaved changes.
pdfDocument.saveDocument()prepareDocument() from useDocumentSavingResolves with a Uint8Array.
pdfDocument.getData()prepareDocument() with the default returnOriginalIfUnchanged: trueReturns the original buffer only while there are no changes.
DownloadManagerdownloadDocument() from useDocumentSaving
InvalidPDFExceptionDocumentFormatErrorSee Errors and logging.
ResponseExceptionErrors from your own fetchloadPdf takes bytes; fetching is yours.
verbosity, VerbosityLevellogMessages, LogLevelThe SDK never prints to the console.
RenderTask.cancel(), PDFPageProxy.cleanup(), PDFDocumentProxy.cleanup(), PDFWorker.destroy()Nothing to callSee Cleanup and lifecycle.
page.getTextContent()getParagraphsByPage(pageId) from useProgrammaticEditorParagraph text and bounds, not text items.
PDFFindControlleruseDocumentSearchHighlights 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 = true

After: @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/react

Set 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's annotationMode defaults to AnnotationMode.ENABLE_FORMS, and annotationEditorMode to AnnotationEditorType.NONE. PdfProviders defaults to the select tool and CONTENT_POLICY_PRESET.PDF_EDITOR, which makes text, annotations, links, and widgets editable. To keep a reader's behavior, start with PDF_VIEWER (text and annotations read-only, links and widgets interactive) and the textSelect tool, 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.promise

To 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. loadPdf takes a Uint8Array. Network options of getDocument (httpHeaders, withCredentials, range, disableRange, disableStream, disableAutoFetch) move to your own fetch; 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. loadPdf reads 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 no PasswordResponses: keep your own first-attempt-or-retry state.
  • No progress reporting. loadState.status is '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. Viewport throws DocumentNotLoadedError when no document is loaded, so render it only when the load state is 'ready'. The pagesinit hook 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. Where PDFViewer takes textLayerMode and annotationMode, text selection is the textSelect tool, 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 as widgets: (blockId) => (lockedIds.has(blockId) ? 'readonly' : 'interactive').
  • Styling replaces pdf_viewer.css. The SDK renders unstyled: attach class names and inline styles through theme.classNames and theme.styles, and override CSS variables such as --selection-color, as Theming and customization describes. The gap between the pages and the viewport edges is theme.viewportPadding.
  • One layout. Viewport shows the pages in one scrollable area; ScrollMode, SpreadMode, and PDFSinglePageViewer have 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 haveYou needUse
A 1-based page number nA page idusePages()[n - 1].id
A page idIts positiongetPageIndex(pageId) from usePageIndexGetter: a zero-based index or undefined
A page id, renderedIts positionusePageIndexGetter paired with useRenderOnPageOrderChange
Only the countusePageCount

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=3 deep link) and resolve them to ids once the document is ready.
  • Ids can go stale. usePageIndexGetter returns undefined for an unknown id, and scrollToPage throws InvalidInputError.
  • Rotation edits the document. pdfViewer.pagesRotation rotates the view only. setPageRotation(pageIdOrPageIds, rotation) from useSetPageRotation rotates the pages in the document as an undoable edit, with an absolute angle of 0, 90, 180, or 270.
  • Page operations edit the loaded document. @sdk-pdf/react changes the pages of the loaded document: usePageCreate, usePageDelete, usePageMove, usePageDuplicate, and useSetPageRotation. Deleting, moving, or rotating several pages in one call is a single undo step. The counterpart of extractPages() is prepareDocument({ pageIds }), which saves only the listed pages.
  • Links. Internal links work inside Viewport when links is 'interactive'. PDFLinkService's externalLinkTarget, 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. useViewportAutoFitScale applies when a document loads and doesn't re-apply on viewport resizes or manual zooming; useViewportFitScale reflects 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 zoomGestures on PdfProviders (minScale 0.5 and maxScale 4 by default).
  • Zoom around a point. setScale(scale, pivot?) keeps the document still at pivot, a point { x, y } given as fractions of the viewport area (0 the top or left edge, 1 the bottom or right edge), the center by default. The third item of useViewportScale(), animateScale(scale, pivot?), animates the change and resolves when it ends. Both throw InvalidInputError unless the scale is a positive finite number.
  • The scale updates after a gesture. During a pinch or wheel zoom, scale keeps its value until the gesture ends; zoomGestureStart and zoomGestureEnd from useViewportEvent mark 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 }).promise

After: @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 scale prop.
  • Lazy by default. An off-screen canvas does no work until it becomes visible. In a virtualized list, pass isVisible to skip the per-canvas intersection observer.
  • Other props: renderOnResize for window-relative sizes, hideAnnotations (form widgets included), and hideExtractableText for drawing your own text on top.
  • Thumbnails that show session edits are coming soon. PageCanvas draws the page as loaded from the file; the main view shows the edits. Rotation is applied and followed live.
  • Render only when ready. PageCanvas throws DocumentNotLoadedError when 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

Conceptpdf.js@sdk-pdf/react
Unit of workA map of AcroForm fieldsWidgets, the visible boxes users interact with; each carries a fieldName
Shared valueFieldsEvery widget with the same field name shows and synchronizes the same value
Value shapeStorage objectstext 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 fieldsPDFScriptingManager runs document scriptsThe 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. useWidgetValue binds 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 widgetInput event of useViewportEvent reports a value the user changed in the main view, with the widget id; values set from code, undo, and redo emit nothing. useFocusedWidgetId returns the widget being filled, and useDocumentRevision changes on any edit.
  • No scripting or XFA. PDFScriptingManager and the enableXfa loading 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, the addWidget tool, useSetWidgetProps, and useBlockDelete create, restyle, and delete widgets. To lock a submitted form, call setWidgetProps(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.
FREETEXTThe 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.
INKNo drawing tool. useInkAnnotationCreate creates an ink annotation from strokes your code collects.
HIGHLIGHTNo equivalent.
SIGNATURENo equivalent. Signature widgets exist as a form kind, and the SDK never signs.
COMMENT, POPUPNo equivalent.

What to know

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() from useUndoBoundary before it starts and after its edits finish.
  • Opting out. Changes applied with the excludeFromHistory option of useSetWidgetProps never enter the history.
  • Events for user actions only. useViewportEvent reports what users do in the main view, such as textEditorInput, addToolApplied, blocksDragEnd, and widgetInput. Page operations, undo, and redo that your code calls emit no events: track them where you call them, and watch useDocumentRevision for 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 to loadPdf comes back with no copy, the closest match to getData(). Set it to false when 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, pageIds saves only the listed pages, in the listed order, which replaces extractPages(). 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/reactThrown whenClosest pdf.js situation
InvalidPasswordErrorThe document is encrypted, and the password is missing or wrong.onPassword with PasswordResponses
DocumentFormatErrorThe PDF content is malformed beyond what the SDK can work around.InvalidPDFException
NotSupportedErrorThe document uses a PDF feature the SDK doesn't support yet.—
DocumentNotLoadedErrorAn operation requires a document, and none is loaded.—
InvalidInputErrorThe input passed to the API is invalid.—
UninitializedErrorAn 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. Viewport and PageCanvas are the exception: they throw DocumentNotLoadedError when 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 (a LogReason member), a static message, data, and stack. There is no verbosity setting: filter by level in 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 rendersNothing to call: PageCanvas renders lazily and does no work off-screen.
Event listenersuseViewportEvent subscriptions follow the component lifecycle.
Log subscriptionslogMessages.on() returns an unsubscribe function.
Keyboard listenersuseHotkeys installs window-level shortcuts; pass a falsy value instead of the mapping to disable them.
Closing a documentresetPdf() from useResetPdf unloads the document and invalidates its ids, snapshots, and revisions.
BuffersDon'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()
    }
  }, [])
}

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. isCaseSensitive and isWholeWord replace caseSensitive and entireWord; there is no diacritics option.
  • Matches. Each match carries id, pageId, paragraphId, and the matched text, in document order; goToMatch(match.id) activates one from a results list. A changed query is searched once typing pauses, and status is '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) from useScrollToBlock.

Features without an equivalent

Plan around these before you migrate:

pdf.js featureIn @sdk-pdf/react
PrintingNo print API; PageCanvas can render pages for your own print layout, as loaded from the file, without session edits
Outline, named destinations, page labels, PDFHistoryNo equivalent
View-only rotation (pagesRotation)Rotation is a document edit (useSetPageRotation)
Scroll, spread, and single-page modesOne scrollable layout
The 'auto' zoom preset, and fits that stay applied on resizeFits are one-time calculations; see Zoom and fit modes
Load progress, range and streaming loadingNo equivalent
JavaScript execution (PDFScriptingManager), XFANo equivalent
Ink drawing, highlight, signature, and comment annotation editorsNo equivalent; ink annotations can be created from code with useInkAnnotationCreate
FDF or XFDF form data export, field-value eventsNo equivalent
Localization (GenericL10n), page colors, document permissionsNo equivalent

On this page