Migration

Migrating from MuPDF.js

This guide moves a browser application built on MuPDF.js to @sdk-pdf/react. MuPDF.js renders and edits documents but has no viewer: the application renders pages into pixmaps, lays them out, builds its own text, link, and form layers, and turns pointer events into annotations. @sdk-pdf/react ships the page view and its interaction, so most of that code goes away and the rest moves to hooks. The guide covers MuPDF.js running in the browser, on the main thread or in a Web Worker. If you also run MuPDF.js on a server, the server keeps fetching and storing the PDF bytes, and server-side jobs such as rendering image files or redaction are listed in Features without an equivalent. 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

MuPDF.js is the MuPDF C library compiled to WebAssembly, with an object-oriented JavaScript API on top. It renders and edits documents and leaves every piece of UI to you:

  • It installs as mupdf, an ES module that ships its WebAssembly binary and TypeScript definitions, and runs in browsers, Node, and Bun.
  • Calls are synchronous: Document.openDocument() opens the bytes, loadPage(index) loads a page by zero-based index, and page.toPixmap(matrix, colorspace) renders it into a Pixmap that you draw on a canvas or encode as PNG. Rendering blocks the thread it runs on, so viewers run MuPDF.js in a Web Worker and post the images to the page.
  • For a PDF file, openDocument returns a PDFDocument and loadPage a PDFPage, which add widgets, annotations, the page tree, the undo journal, and saving.
  • The viewer is your code: page layout and scrolling, zoom by rendering again at another matrix, text selection and links as HTML layers built from toStructuredText() and getLinks(), inputs over the form widgets, and the pointer handlers that create annotations.
  • Edits change the loaded document in place, saveToBuffer() writes the PDF, and objects hold WebAssembly memory that you release with destroy().
  • Coordinates are points with the origin at the top-left corner 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 scrolling, selection, text editing, form filling, link navigation, and zoom and drag gestures on its own. PageCanvas renders single pages anywhere else, such as thumbnails.
  • Toolbars and sidebars are your React components: state hooks re-render them on change, and command hooks change the document.
  • Pages are addressed by id, not by index: 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.
ConcernMuPDF.js@sdk-pdf/react
Integration styleSynchronous objects, usually behind a Web Worker and your message protocolA React provider, state hooks, and command hooks
RenderingtoPixmap() per page, drawn and laid out by your codeViewport for the main view, PageCanvas for thumbnails and previews
InteractionYour own text, link, and form layersBuilt into Viewport, governed by the content policy
Page addressingZero-based page indicesSession-scoped page ids from usePages
Editing modelAnnotations, widget values, the page tree, and low-level PDF objectsPage text, form widgets, images, and pages; no tools for markup annotations
UndoAn opt-in journal of named operationsAlways on, one history for the whole document
SavingsaveToBuffer(options)prepareDocument() and downloadDocument()
Memorydestroy() on every objectNothing to release
File typesPDF, XPS, CBZ, EPUB, FictionBook 2, and imagesPDF
DiagnosticsThrown exceptions, setLog() for warnings and errorsTyped errors thrown before any change; logMessages, never printed to console
EnvironmentBrowsers, Node, and Bun; framework-agnosticReact in the browser

What this means for your code:

  1. Delete the render pipeline. The worker and its message protocol, pixmap rendering, ImageData and PNG handling, lazy rendering with an IntersectionObserver, and the text and link layers move into Viewport.
  2. Replace indices with ids. Every page reference in your state becomes a page id; convert at the edges (deep links, stored positions) with usePages and usePageIndexGetter.
  3. Replace rendering again with a scale. A zoom level that you passed into a render matrix becomes the viewport scale, and the viewport zooms on pinch and wheel gestures too.
  4. Rethink forms as widgets. Loops over getWidgets() become useWidgets, and values are read and written through useWidgetValue, which re-renders as the value changes.
  5. Rethink editing. MuPDF.js creates annotations and writes page content through PDF objects; @sdk-pdf/react edits page text, widgets, image stamps, and pages through interaction tools and hooks, and creates ink annotations from code.
  6. Drop memory management. There are no pages, pixmaps, or documents to destroy.
  7. Choose the policy. The default is PDF_EDITOR; a reader needs PDF_VIEWER or PDF_PRINTER.
  8. Move into React. Code that drives MuPDF.js from outside React needs a React boundary.
API map

Rows marked No equivalent are covered in Features without an equivalent.

MuPDF.js@sdk-pdf/reactNotes
import * as mupdf from 'mupdf', the worker and its messagesPdfProvidersNothing else to set up.
Document.openDocument(data, 'application/pdf')loadPdf(new Uint8Array(data)) from useLoadPdfAsynchronous, with a load state.
needsPassword(), authenticatePassword(password)loadPdf(data, { password }), catch InvalidPasswordErrorA rejection replaces the check.
doc.destroy() before opening another documentresetPdf() from useResetPdfReturns the load state to 'initial'.
countPages()usePageCount0 while no document is loaded.
loadPage(index)usePages()[index].id with usePagesBoth zero-based.
page.getBounds()width and height of the usePages entry1/72 of an inch, rotation applied.
page.toPixmap(), getPixels(), asPNG(), your canvasesViewportOne component, no layers to assemble.
Text and link layers from toStructuredText() and getLinks()Viewport, with the textSelect tool and interactive linksSee Display the document.
link.isExternal(), doc.resolveLink(link)Link navigation inside ViewportWhen the policy's links level is 'interactive'.
Scrolling your page element into viewscrollToPage(pageId) from useScrollToPage
Tracking the visible page with an IntersectionObserveruseCurrentPageIdRe-renders with the page in the middle of the viewport.
loadOutline(), page.getLabel()No equivalent
insertPage(at, addPage(mediabox, rotate, resources, contents))createPage(width, height, insertAfterPageId?) from usePageCreateInserts after a page, or appends.
deletePage(index)deletePages(pageIdOrIds) from usePageDelete
rearrangePages(order)moveBefore or moveAfter from usePageMoveMoves pages, no full order.
A page listed twice in rearrangePagesduplicatePage(sourcePageId) from usePageDuplicate
graftPage(to, srcDoc, srcPage), setPageBox()No equivalentNo page import or cropping.
A new document with graftPage() to extract or split pagesprepareDocument({ pageIds }) from useDocumentSavingSaves only the listed pages, in the listed order.
A zoom level in the render matrix, Matrix.scale(dpi / 72, dpi / 72)setScale(dpi / 72) from useViewportScaleScale 1 is one user-space unit per CSS pixel.
A fit-width zoom computed from getBounds()useViewportFitScale, useViewportAutoFitScaleA one-time fit, not a persistent mode.
Small pixmaps or PNGs as thumbnailsPageCanvasLazy; shows the page as loaded. Showing session edits is coming soon.
toStructuredText().asText()getParagraphsByPage(pageId) from useProgrammaticEditorParagraph text and bounds.
asJSON(), walk(), structured text optionsNo equivalentNo lines, characters, or fonts.
page.search(needle), StructuredText.search()useDocumentSearchThe SDK highlights the matches; no hit quads.
StructuredText.highlight(), copy()The textSelect tool in ViewportNo API reads the selected text.
page.getWidgets()useWidgets, useWidgetsGetterEvery widget of the document, in reading order.
getValue(), setTextValue(), setChoiceValue(), toggle()useWidgetValueOne widget per hook call.
getName(), getFieldType()fieldName and widgetType of the widget snapshotSee Forms.
page.update() and rendering the page againNothing to callThe view follows the document.
enableJS()No equivalentAcrobat date formats are recognized without running scripts.
createAnnotation('FreeText'){ type: 'addText' }The nearest tool; it creates a text paragraph.
Image content written through PDFObject, Stamp annotations{ type: 'addImage', image } with stageImagePlaces a stamp annotation.
createAnnotation('Stamp') from codeuseStampAnnotationCreateAt a user-space position, with a staged image.
createAnnotation('Ink'), setInkList()useInkAnnotationCreateStrokes your code collects; there is no drawing tool.
createAnnotation() for markup, shapes, notes, and attachmentsNo equivalentExisting shapes and lines display.
getAnnotations()No equivalentuseSelectedBlocks returns what the user selected.
Redaction annotations, applyRedactions()No equivalent
enableJournal(), undo(), redo(), canUndo(), canRedo()useUndoRedoAlways on.
beginOperation(), endOperation()markUndoBoundary() from useUndoBoundaryKeeps an operation a separate step.
getJournal()No equivalentSteps are recorded without names.
hasUnsavedChanges()useDocumentRevision, useDocumentRevisionGetterCompare revisions to tell unsaved changes.
saveToBuffer(options).asUint8Array()prepareDocument() from useDocumentSavingResolves with a Uint8Array.
Write options (incremental, garbage, compress, encrypt)No equivalentThe options are returnOriginalIfUnchanged and pageIds.
A Blob and an object URL for downloadsdownloadDocument() from useDocumentSaving
bake()No equivalentNo flattening.
setLog({ error, warning })logMessages, LogLevelThe SDK never prints to the console.
Exceptions relayed from the workerErrors thrown or rejected at the callSee Errors and logging.
destroy(), emptyStore(), worker.terminate()Nothing to callSee Cleanup and lifecycle.
getMetaData(), setMetaData(), hasPermission(), layers, embedded filesNo equivalent
PDFPage.getTransform()No equivalentSee Coordinates.
page.createLink()No equivalent
PDFObject, getTrailer(), addStream()No equivalentNo low-level PDF objects or content streams.

Install and set up

Before: MuPDF.js

MuPDF.js installs as mupdf and loads only as an ES module. Importing it loads the WebAssembly binary that ships in the package, which you serve with your app. Viewers import it in a module worker, so rendering doesn't block the page:

npm install mupdf
// mupdf.worker.js: MuPDF.js runs off the main thread.
import * as mupdf from 'mupdf'

let doc = null
// main.js
const worker = new Worker(new URL('./mupdf.worker.js', import.meta.url), {
  type: 'module',
})

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, and the PDF application re-renders whenever a prop changes identity, so define static configuration outside the component:

import {
  CONTENT_POLICY_PRESET,
  Config,
  InteractionTool,
  PdfProviders,
} from '@sdk-pdf/react'
import '@sdk-pdf/react/style.css'

// Defined outside the component, so their identity never changes.
const theme: Config['theme'] = { viewportPadding: 16 }
const initialInteractionTool: InteractionTool = { type: 'textSelect' }

export function App() {
  return (
    <PdfProviders
      theme={theme}
      initialInteractionTool={initialInteractionTool}
      initialContentPolicy={CONTENT_POLICY_PRESET.PDF_VIEWER}
    >
      <DocumentLoader url="/files/document.pdf" />
    </PdfProviders>
  )
}

What to know

  • Pick the content policy. In a MuPDF.js viewer, users can do whatever your layers let them do. PdfProviders defaults to the select tool and CONTENT_POLICY_PRESET.PDF_EDITOR, which makes text, annotations, links, and widgets editable. For a reader that fills forms, start with PDF_VIEWER (text and annotations read-only, links and widgets interactive) and the textSelect tool, as above. Values that change at runtime go through useMemo, as Getting started shows.
  • No worker or WebAssembly file. There is no worker script or message protocol to write and no WebAssembly file to point at.
  • React in the browser. @sdk-pdf/react is a React library for the browser; code that drives MuPDF.js from outside React moves into components and hooks.

Load and reset a document

Before: MuPDF.js

This snippet and the others that mix MuPDF.js calls with page code call doc directly, as on the main thread; behind a worker, those calls go through your message protocol.

Document.openDocument() takes the file bytes and a MIME type or file extension, 'application/pdf' by default. It opens an encrypted document without a password: needsPassword() reports that one is required, and authenticatePassword() returns a bit field that is 0 when the password is wrong:

async function openDocument(url) {
  const response = await fetch(url)
  const data = await response.arrayBuffer()
  const doc = mupdf.Document.openDocument(data, 'application/pdf')

  if (doc.needsPassword()) {
    let password = await askForPassword({ isRetry: false })
    while (doc.authenticatePassword(password) === 0) {
      password = await askForPassword({ isRetry: true })
    }
  }

  return doc
}

To open another document, destroy the current one first:

doc.destroy()

After: @sdk-pdf/react

useLoadPdf returns the load state and loadPdf(pdfFileContent: Uint8Array, options?: { password?: string }): Promise<void>. The password goes into the load call, and a wrong one rejects:

import { useEffect, useRef, useState } from 'react'
import { InvalidPasswordError, useLoadPdf, useResetPdf } from '@sdk-pdf/react'

function DocumentLoader({ url }: { url: string }) {
  const [loadState, loadPdf] = useLoadPdf()
  const resetPdf = useResetPdf()
  const bytesRef = useRef<Uint8Array | null>(null)
  const bytesUrlRef = useRef(url)
  const [isPasswordRetry, setIsPasswordRetry] = useState<boolean>()

  // A new url is a new document: drop the cached bytes and unload the old document.
  useEffect(() => {
    if (bytesUrlRef.current === url) return
    bytesUrlRef.current = url
    bytesRef.current = null
    setIsPasswordRetry(undefined)
    resetPdf()
  }, [url, resetPdf])

  async function open(password?: string) {
    try {
      if (!bytesRef.current) {
        const response = await fetch(url)
        if (!response.ok) throw new Error(`Cannot fetch the document: ${response.status}`)
        bytesRef.current = new Uint8Array(await response.arrayBuffer())
      }

      // 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 })
      setIsPasswordRetry(undefined)
    } catch (error) {
      if (error instanceof InvalidPasswordError) {
        // Replaces needsPassword() and authenticatePassword(): ask, then call
        // open(password).
        setIsPasswordRetry(password !== undefined)
      } else {
        showError(error)
      }
    }
  }

  if (isPasswordRetry !== undefined)
    return <PasswordPrompt isRetry={isPasswordRetry} 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 CloseDocumentButton() {
  const resetPdf = useResetPdf()

  // Replaces doc.destroy(): the load state returns to 'initial'.
  return <button onClick={() => resetPdf()}>Close</button>
}

What to know

  • PDF only. MuPDF.js also opens XPS, CBZ, EPUB, FictionBook 2, and image files, and lays out reflowable documents; useLoadPdf loads PDF documents, with no MIME type or extension to pass.
  • A Uint8Array you don't reuse. Wrap an ArrayBuffer with new Uint8Array(data). loadPdf reads from the given buffer for the document's whole lifetime, and anything else touching that buffer, such as another loadPdf call or a transfer to a worker, can detach it. To reuse the bytes, pass a copy: loadPdf(data.slice()).
  • Passwords are a rejection, not a check. Without a password, the empty password is tried, as the PDF specification prescribes; a missing or wrong password rejects with InvalidPasswordError, and you call loadPdf again with { password }. The promise resolves with nothing, so there is no counterpart to the bit field that tells a user password from an owner password.
  • Asynchronous loading. openDocument() returns an open document; loadPdf returns a promise, and loadState.status ('initial', 'loading', or 'ready') decides what to render.
  • 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: MuPDF.js

A MuPDF.js viewer renders each page into a pixmap and draws it on a canvas of its own. The zoom is a resolution in DPI: a point is 1/72 of an inch, so the render matrix scales by dpi / 72. In the worker:

function drawPage(pageIndex, dpi) {
  const page = doc.loadPage(pageIndex)
  const matrix = mupdf.Matrix.scale(dpi / 72, dpi / 72)
  // With alpha, the pixels are RGBA, the layout ImageData expects, and the page draws
  // on a transparent background, so give the canvases a white CSS background.
  const pixmap = page.toPixmap(matrix, mupdf.ColorSpace.DeviceRGB, true)
  const imageData = new ImageData(
    pixmap.getPixels().slice(),
    pixmap.getWidth(),
    pixmap.getHeight()
  )

  pixmap.destroy()
  page.destroy()
  return imageData
}

On the page, one canvas per page, sized in CSS from getBounds() (width * zoom / 72 pixels) and drawn when it scrolls into view:

let zoom = 96

const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (entry.isIntersecting) drawCanvas(entry.target)
  }
})

async function drawCanvas(canvas) {
  const pageIndex = Number(canvas.dataset.pageIndex)
  // callWorker posts a message to the worker and resolves with its reply.
  const imageData = await callWorker('drawPage', pageIndex, zoom * devicePixelRatio)
  canvas.width = imageData.width
  canvas.height = imageData.height
  canvas.getContext('2d').putImageData(imageData, 0, 0)
}

Text selection, link hit areas, and search hits are HTML layers you position over each canvas from toStructuredText().asJSON(), getLinks(), and search().

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() {
  // Opens each document fitted to the viewport width; Zoom and fit modes maps fixed DPI
  // levels.
  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'.
  • No layers to build. Text selection, link navigation, and form filling happen inside Viewport, governed by the interaction mode: 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). A level can also be a function of the block id, such as widgets: (blockId) => (lockedIds.has(blockId) ? 'readonly' : 'interactive'). The HTML layers you built from asJSON() and getLinks() go away.
  • No pixels to move. There is no pixmap, ImageData, PNG, or object URL, and nothing to transfer between threads.
  • Styling replaces your page CSS. The SDK renders unstyled: attach class names and inline styles through theme.classNames and theme.styles, and set the gap between the pages and the viewport edges with theme.viewportPadding, as Theming and customization describes.
  • Scrolling is built in. Viewport shows the pages in one scrollable area, with no page containers of your own.

Pages and navigation

Before: MuPDF.js

Pages are zero-based indices. Navigation scrolls your own page elements, and an internal link resolves to a page index:

const pageCount = doc.countPages()

function goToPage(pageIndex) {
  pageElements[pageIndex].scrollIntoView()
}

function followLink(link) {
  if (link.isExternal()) {
    window.open(link.getURI(), '_blank')
  } else {
    goToPage(doc.resolveLink(link))
  }
}

After: @sdk-pdf/react

usePages returns the pages in document order, so an index in that list is the same zero-based index you passed to loadPage(). 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 scrollToPage = useScrollToPage()
  // Replaces tracking the visible page with your own observer.
  const currentPageId = useCurrentPageId()

  // While there is no current page yet, treat the first page as the current one.
  const currentIndex = Math.max(
    0,
    pages.findIndex((page) => page.id === currentPageId)
  )

  function goToPage(pageIndex: number) {
    const page = pages[pageIndex]
    if (page) scrollToPage(page.id, { behavior: 'smooth' })
  }

  return (
    <div role="toolbar">
      <button disabled={currentIndex === 0} onClick={() => goToPage(currentIndex - 1)}>
        Previous
      </button>
      <span>
        Page {currentIndex + 1} of {pages.length}
      </span>
      <button
        disabled={currentIndex >= pages.length - 1}
        onClick={() => goToPage(currentIndex + 1)}
      >
        Next
      </button>
    </div>
  )
}

scrollToPage(pageId, options?) takes behavior ('auto' | 'smooth' | 'instant'), vertical ('top' | 'center' | 'bottom' | 'nearest'), and horizontal ('left' | 'center' | 'right' | 'nearest').

Converting between indices and ids

You haveYou needUse
A zero-based page indexA page idusePages()[index].id
A page idIts indexgetPageIndex(pageId) from usePageIndexGetter: a zero-based index or undefined
A page id, renderedIts indexusePageIndexGetter paired with useRenderOnPageOrderChange
Only the countusePageCount, the counterpart of countPages()

What to know

  • Ids are session-scoped. Don't persist page ids or reuse them across reloads. Keep persisting page indices or numbers, 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.
  • Page sizes. The width and height of a usePages entry replace getBounds(): user-space units of 1/72 of an inch, the same unit as MuPDF.js points, with the page rotation applied.
  • Links. Links work inside Viewport when links is 'interactive'; there is no getLinks() or resolveLink() to call, and creating links has no equivalent.
  • Outline and labels. loadOutline() and page.getLabel() have no equivalent.

Page manipulation

Before: MuPDF.js

A PDFDocument changes its page tree by index. addPage() creates a page object, and insertPage() adds it to the tree:

// Append a blank A4 page: -1 inserts at the end.
doc.insertPage(-1, doc.addPage([0, 0, 595, 842], 0, null, ''))

// Delete the first page.
doc.deletePage(0)

// The full new order: unlisted pages are removed, and a page listed twice is duplicated.
doc.rearrangePages([2, 0, 1, 1])

After: @sdk-pdf/react

Each page operation is a hook that returns a command taking page ids: usePageCreate, usePageDelete, usePageMove, usePageDuplicate, and useSetPageRotation:

import {
  usePageCreate,
  usePageDelete,
  usePageDuplicate,
  usePageMove,
  usePages,
  useSetPageRotation,
} from '@sdk-pdf/react'

function PageActions({ pageId }: { pageId: string }) {
  const pages = usePages()
  const createPage = usePageCreate()
  const deletePages = usePageDelete()
  const duplicatePage = usePageDuplicate()
  const { moveAfter } = usePageMove()
  const setPageRotation = useSetPageRotation()

  const page = pages.find((page) => page.id === pageId)
  if (!page) return null

  return (
    <div role="toolbar">
      {/* Like addPage([0, 0, 595, 842]): A4 in user-space units, inserted after this
         page. */}
      <button onClick={() => createPage(595, 842, pageId)}>
        Insert blank page after
      </button>
      <button disabled={pages.length <= 1} onClick={() => deletePages(pageId)}>
        Delete
      </button>
      <button onClick={() => void duplicatePage(pageId)}>Duplicate</button>
      {/* Without a target page, moveAfter moves the page to the end of the document. */}
      <button onClick={() => moveAfter(pageId)}>Move to end</button>
      {/* Rotation is absolute: add to the current angle yourself. */}
      <button onClick={() => setPageRotation(pageId, page.rotation + 90)}>
        Rotate clockwise
      </button>
    </div>
  )
}

What to know

OperationMuPDF.js@sdk-pdf/react
InsertinsertPage(at, addPage(...)) at an index, -1 for the endcreatePage(width, height, insertAfterPageId?), after a page or appended; no index
DeletedeletePage(index), one page per calldeletePages(pageIdOrIds), several pages in one call
ReorderrearrangePages(order) with the full new ordermoveBefore(ids, beforePageId?) (start when omitted) or moveAfter(ids, afterPageId?) (end when omitted)
DuplicateA page listed twice in rearrangePagesduplicatePage(sourcePageId, insertAfterPageId?), asynchronous, inserted after the source by default
RotateNo method for existing pages; the rotate argument of addPage for a new pagesetPageRotation(pageIdOrIds, rotation) for any page, an absolute angle on a quarter turn
ExtractgraftPage() into a new documentprepareDocument({ pageIds }) saves only the listed pages, in the listed order
Copy or cropgraftPage(to, srcDoc, srcPage), setPageBox('CropBox', rect)No equivalent
  • Undo. Deleting, moving, or rotating several pages in one call is a single undo step, and an undo can restore a deleted page; separate edits within 500 ms merge into one step.
  • No events to re-read. Instead of re-reading countPages() after a change, let usePages re-render your component. Page operations emit no events: track your own calls where you make them.
  • No merging. Copying pages from another document with graftPage() has no equivalent: page operations edit the loaded document. To write a subset of the pages as a new file, pass pageIds to prepareDocument().

Zoom and fit modes

Before: MuPDF.js

The zoom lives in your code as a DPI value. Changing it resizes the canvases and renders every visible page again at the new matrix, and a fit is your own calculation from the page bounds and the container width:

// In the worker.
function getPageBounds(pageIndex) {
  const page = doc.loadPage(pageIndex)
  const bounds = page.getBounds()
  page.destroy()
  return bounds
}
// On the page.
let zoom = 96

function zoomTo(newZoom) {
  zoom = Math.min(Math.max(newZoom, 48), 384)
  for (const canvas of visibleCanvases) {
    resizeCanvas(canvas)
    drawCanvas(canvas)
  }
}

async function fitWidth() {
  const [x0, , x1] = await callWorker('getPageBounds', 0)
  // The CSS width of a page is its width in points * zoom / 72.
  zoomTo((viewerElement.clientWidth / (x1 - x0)) * 72)
}

After: @sdk-pdf/react

useViewportScale reads and sets the scale, useViewportFitScale calculates fit scales on demand, and useViewportAutoFitScale fits once per loaded document. A DPI zoom maps to the scale as dpi / 72:

import { useEffect } from 'react'
import { useViewportFitScale, useViewportScale } from '@sdk-pdf/react'

function ZoomControls() {
  const [scale, setScale] = useViewportScale()
  const getFitScale = useViewportFitScale()

  // The zoom your render loop used, in DPI: scale 1 is 72 DPI.
  const dpi = scale * 72

  // Every scale change, whatever the source: these controls, gestures, or an auto fit.
  useEffect(() => saveZoomPreference(scale * 72), [scale])

  function zoomTo(newDpi: number) {
    // Your buttons keep the limits your render loop had.
    setScale(Math.min(Math.max(newDpi, 48), 384) / 72)
  }

  function fitPage() {
    const { fitWidthScale, fitHeightScale } = getFitScale()
    setScale(Math.min(fitWidthScale, fitHeightScale))
  }

  return (
    <div role="toolbar">
      <button onClick={() => zoomTo(dpi - 12)}>−</button>
      <span>{Math.round(dpi)} DPI</span>
      <button onClick={() => zoomTo(dpi + 12)}>+</button>
      <button onClick={() => zoomTo(96)}>96 DPI</button>
      <button onClick={() => setScale(getFitScale().fitWidthScale)}>Fit width</button>
      <button onClick={fitPage}>Fit page</button>
    </div>
  )
}

What to know

  • Scale 1 is one user-space unit per CSS pixel, the page size a MuPDF.js viewer shows at a zoom of 72 DPI. A stored zoom of z DPI becomes setScale(z / 72), and there is no devicePixelRatio in the calculation.
  • 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. fitWidthScale fits the widest page into the viewport width and fitHeightScale the tallest page into its height, both accounting for viewportPadding.
  • Gestures are built in. Pinch and wheel zoom go through the same scale and are tuned with zoomGestures on PdfProviders. Its minScale (0.5 by default) and maxScale (4) bound the gestures; to give them the limits of your buttons, pass minScale: 48 / 72 and maxScale: 384 / 72 in a configuration defined outside the component or memoized.
  • 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: MuPDF.js

Thumbnails are small renders: the matrix scales the page to the thumbnail width, and the pixmap comes back as PNG bytes for an image element:

// In the worker.
function drawThumbnail(pageIndex, width) {
  const page = doc.loadPage(pageIndex)
  const [x0, , x1] = page.getBounds()
  const scale = width / (x1 - x0)
  const pixmap = page.toPixmap(
    mupdf.Matrix.scale(scale, scale),
    mupdf.ColorSpace.DeviceRGB,
    false
  )
  const png = pixmap.asPNG()

  pixmap.destroy()
  page.destroy()
  return png
}
// On the page.
const png = await callWorker('drawThumbnail', pageIndex, 120 * devicePixelRatio)
image.src = URL.createObjectURL(new Blob([png], { type: 'image/png' }))

After: @sdk-pdf/react

PageCanvas renders one page onto a canvas anywhere outside the main view. With usePages, useScrollToPage, and useCurrentPageId, it makes a sidebar that marks the current page:

import { PageCanvas, useCurrentPageId, usePages, useScrollToPage } from '@sdk-pdf/react'

function ThumbnailsSidebar() {
  const pages = usePages()
  const scrollToPage = useScrollToPage()
  const currentPageId = useCurrentPageId()

  return (
    <nav>
      {pages.map((page, index) => (
        <button
          key={page.id}
          aria-current={page.id === currentPageId ? 'page' : undefined}
          onClick={() => scrollToPage(page.id)}
        >
          {/* The render resolution follows the canvas CSS size: style .thumbnail to the
             width you rendered at. */}
          <PageCanvas
            pageId={page.id}
            className="thumbnail"
            role="img"
            aria-label={`Page ${index + 1}`}
          />
        </button>
      ))}
    </nav>
  )
}

What to know

  • Size with CSS, not a matrix. The render resolution follows the canvas's displayed CSS size, or an explicit scale prop, and the pixels stay sharp; there is no scale to compute from getBounds() and no devicePixelRatio factor.
  • 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) where you passed false as the showExtras argument of toPixmap(), 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.
  • No image files. PageCanvas draws onto a canvas element; there is no API that returns a page as PNG or JPEG bytes, as asPNG() and asJPEG() do.
  • Render only when ready. PageCanvas throws DocumentNotLoadedError when no document is loaded.

Before: MuPDF.js

toStructuredText() analyzes a page into blocks, lines, and characters, which come out as plain text, as JSON with bounding boxes and fonts, or through a walker. search() returns each hit as a list of quads to highlight:

const page = doc.loadPage(pageIndex)
const stext = page.toStructuredText('preserve-whitespace')

const text = stext.asText()
const { blocks } = JSON.parse(stext.asJSON())
const hits = page.search('total due')

stext.destroy()
page.destroy()

After: @sdk-pdf/react

useProgrammaticEditor reads the text as paragraphs: getParagraphsByPage(pageId) resolves with paragraph entries in content order, and getAllParagraphIds() with getPlainTextParagraphs() reads the whole document. Each entry carries 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 useDocumentText() {
  const { getParagraphsByPage, getAllParagraphIds, getPlainTextParagraphs } =
    useProgrammaticEditor()

  // Replaces toStructuredText().asText() for one page.
  async function readPage(pageId: string) {
    const paragraphs = await getParagraphsByPage(pageId)
    return paragraphs.map((paragraph) => paragraph.content).join('\n\n')
  }

  // Loads every page in turn, which can take a long time for a large document.
  async function readDocument() {
    const paragraphs = await getPlainTextParagraphs(await getAllParagraphIds())
    return paragraphs.map((paragraph) => paragraph.content).join('\n\n')
  }

  return { readPage, readDocument }
}

useDocumentSearch replaces page.search(): set the query and the options, and the SDK finds the matches across the document, 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>
  )
}

What to know

  • Paragraphs, not blocks, lines, and characters. An entry carries the paragraph text, the 1-based pageNumber, and the paragraph bounds in user space. There are no lines, character quads, fonts, or image blocks, no walk(), and no structured text options such as preserve-whitespace or dehyphenate.
  • Matches, not quads. Each match carries id, pageId, paragraphId, and the matched text, in document order, and the SDK draws the highlights: there are no hit quads to position or turn into Highlight annotations. goToMatch(match.id) activates a match from a results list, and isWholeWord matches whole words only. A changed query is searched once typing pauses, and status is 'searching' until the whole document is searched.
  • Selection is native. The highlight() and copy() helpers behind a custom text layer give way to the textSelect tool, which uses the browser's own text selection for reading and copying. No API reads the selected text.
  • Ids are session-scoped, like page ids, and a paragraph can be deleted after you read it.

Forms

Before: MuPDF.js

MuPDF.js has no form UI. Each PDFPage lists its widgets, a widget reports its field name and type, and setters change the value; after a change, page.update() regenerates the appearances and tells you whether to render the page again. Users type into inputs you position over getBounds(), and the same setters write their values back:

// Fill a page's fields from saved values, keyed by field name.
const page = doc.loadPage(pageIndex)

for (const widget of page.getWidgets()) {
  const value = savedValues[widget.getName()]
  if (value === undefined || widget.isReadOnly()) continue

  if (widget.getFieldType() === 'text') widget.setTextValue(value)
  else if (widget.getFieldType() === 'combobox') widget.setChoiceValue(value)
}

if (page.update()) redrawPage(pageIndex)

Checkboxes and radio buttons change with toggle().

After: @sdk-pdf/react

Users fill fields in Viewport with nothing to build when the content policy allows interacting with forms. For form UI outside the main view, useWidgets lists the widgets of the whole document 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 }) {
  // widgetType replaces getFieldType().
  switch (widget.widgetType) {
    case 'text':
      return <TextField widget={widget} />
    case 'checkbox':
      return <CheckboxField widget={widget} />
    default:
      return null
  }
}

function TextField({ widget }: { widget: TextWidget }) {
  // Replaces getValue() and setTextValue(); there is no page.update() to call.
  const [value, setValue] = useWidgetValue(widget)

  return (
    <label>
      {widget.fieldName}
      <input value={value} onChange={(event) => setValue(event.currentTarget.value)} />
    </label>
  )
}

function CheckboxField({ widget }: { widget: CheckboxWidget }) {
  // A boolean instead of toggle().
  const [isChecked, setIsChecked] = useWidgetValue(widget)

  return (
    <label>
      <input
        type="checkbox"
        checked={isChecked}
        onChange={(event) => setIsChecked(event.currentTarget.checked)}
      />
      {widget.fieldName}
    </label>
  )
}

From field types to widget kinds

MuPDF.js getFieldType()@sdk-pdf/react widgetTypeValue
texttextstring
combobox, listboxchoice for a single selection (isCombo tells a dropdown from a list box), or multiChoicestring, or selected option indices
checkboxcheckboxboolean: whether the widget is checked
radiobuttonradioboolean: whether the widget is checked
buttonpushButtonNo value
signaturesignatureNo value; isCryptoSigned is read-only

What to know

  • Values follow the document. Filling in the main view and undo/redo show up as they happen, with no page.update() and no page to render again. 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. The field name is the snapshot's fieldName, and every widget with the same field name shows and synchronizes the same value.
  • The whole document, not one page. useWidgets lists every widget of the document; await getWidgets() from useWidgetsGetter loads every page with widgets first and suits one-time reads, such as collecting values to submit.
  • Read-only fields. isReadOnly() becomes the isReadOnly widget property. To lock a submitted form, call setWidgetProps(ids, { isReadOnly: true }, { excludeFromHistory: true }) from useSetWidgetProps.
  • No document JavaScript. enableJS() has no equivalent. The SDK recognizes Acrobat date formats without running scripts and exposes them as the text widget's format (see FormatType); fields driven by hand-written scripts read as plain text fields.
  • Form building. useWidgetsCreate, the addWidget tool, useSetWidgetProps, and useBlockDelete create, restyle, and delete widgets. The SDK never signs.

Editing and interaction tools

Before: MuPDF.js

MuPDF.js edits a page through annotations: createAnnotation(type) on a PDFPage, setters such as setRect(), setContents(), setColor(), and setQuadPoints(), and page.update() to regenerate the appearances. Deciding where an annotation goes is your pointer handler's job, which converts the click from CSS pixels to page points:

// A FreeText box where the user clicked.
function addNote(pageIndex, event) {
  const page = doc.loadPage(pageIndex)
  const [x0, y0] = page.getBounds()
  const x = x0 + (event.offsetX * 72) / zoom
  const y = y0 + (event.offsetY * 72) / zoom

  const annotation = page.createAnnotation('FreeText')
  annotation.setRect([x, y, x + 200, y + 50])
  annotation.setContents('Note')
  page.update()
  redrawPage(pageIndex)
}

// A highlight over the first search hit.
function highlightFirst(page, needle) {
  const [hit] = page.search(needle)
  if (!hit) return

  const highlight = page.createAnnotation('Highlight')
  highlight.setQuadPoints(hit)
  highlight.setColor([1, 1, 0])
  page.update()
}

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. The tool places content at the click position, so the pointer handler and its coordinate conversion go away:

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>
      {/* The nearest counterpart of a FreeText box: a paragraph at the click
         position. */}
      <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>
  )
}

Where your code wrote text into a page without user interaction, useParagraphCreate creates a paragraph and the paragraph editor of useProgrammaticEditor types into it:

import { useParagraphCreate, useProgrammaticEditor } from '@sdk-pdf/react'

function useAddTextAt() {
  const createParagraph = useParagraphCreate()
  const { getParagraphEditor } = useProgrammaticEditor()

  return async (pageId: string, x: number, y: number, text: string) => {
    // x and y are in user-space units.
    const paragraphId = await createParagraph(pageId, x, y)
    const editor = getParagraphEditor(paragraphId)
    if (!editor) return

    editor.selectAll()
    editor.insertText(text)
  }
}

Mapping the annotation types

MuPDF.js@sdk-pdf/react
FreeTextThe nearest tool is { type: 'addText' }: it creates a text paragraph, not an annotation, at the click position and focuses it for typing.
Stamp, image content written through PDFObject{ 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. From code, useStampAnnotationCreate places it at a position you give.
Highlight, Underline, StrikeOut, SquigglyNo equivalent.
InkNo drawing tool. useInkAnnotationCreate creates an ink annotation from strokes your code collects, like setInkList().
Line, Square, Circle, Polygon, PolyLineNo tool creates them. Existing shapes and lines display, and their border color and width can be changed.
Text notes, FileAttachmentNo equivalent.
Redaction annotations and applyRedactions()No equivalent.

What to know

  • Switch the policy with the tool. Pass PDF_EDITOR with the editing tools, as above. textSelect needs read-only text: with editable text, the combination is unsupported and reports a message through logMessages.
  • Editing page text is built in. Under PDF_EDITOR, users edit existing text in place, and Viewport handles typing. useSelectionTextStyle drives a style toolbar (fontSize, textColor, fontFamily, isBold, isItalic, and more), and useFocusSelection returns the focus after a toolbar click.
  • Restyling instead of setters. useSelectionAnnotationProps changes borderColor and borderWidth of selected shapes, lines, ink, and widgets, where you called setColor() and setBorderWidth(). Contents and quad points have no counterpart; opacity carries over only as the alpha channel of borderColor.
  • Selection commands. useSelection tells 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, and useSelectedBlocks read and act on the selection.
  • Keyboard shortcuts are your code. Wire undo, redo, delete, and arrow keys with useHotkeys.

Undo, redo, and change tracking

Before: MuPDF.js

The journal is off until you enable it. Once it's on, annotation and widget edits are recorded as named undo steps, and beginOperation() and endOperation() group several edits into one named step. After an undo or a redo, the pages render again:

doc.enableJournal()

function createNote(page, rect, text) {
  // One named undo step for the three edits.
  doc.beginOperation('Add note')
  try {
    const annotation = page.createAnnotation('FreeText')
    annotation.setRect(rect)
    annotation.setContents(text)
    doc.endOperation()
  } catch (error) {
    doc.abandonOperation()
    throw error
  }
  updateHistoryControls()
}

undoButton.onclick = () => {
  doc.undo()
  redrawVisiblePages()
  updateHistoryControls()
}

function updateHistoryControls() {
  undoButton.disabled = !doc.canUndo()
  redoButton.disabled = !doc.canRedo()
  unsavedIndicator.hidden = !doc.hasUnsavedChanges()
}

After: @sdk-pdf/react

useUndoRedo returns the commands and their availability; canUndo and canRedo re-render the component as the history changes, which replaces calling updateHistoryControls() after every edit:

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>
  )
}

hasUnsavedChanges() becomes a comparison of 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

  • Always on, nothing to name. Your edits enter the history without enableJournal(). Commands that act on several pages or widgets in one call are one step, and edits made within 500 ms of each other merge into a single step, even across hook calls. Where you called beginOperation() to keep an operation apart, call markUndoBoundary() from useUndoBoundary before it starts and after its edits finish; it doesn't guarantee a single step for edits spread over time or pages. The named steps of getJournal() have no equivalent.
  • One history for the whole document. 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.
  • 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: MuPDF.js

saveToBuffer(options) writes the document into a MuPDF Buffer, with write options such as incremental, garbage, and compress; asUint8Array() turns it into bytes for an upload or a download:

// Upload, appending the changes to the original file.
const buffer = doc.saveToBuffer('incremental')
await fetch('/api/documents', { method: 'PUT', body: buffer.asUint8Array() })
buffer.destroy()

// Download.
const output = doc.saveToBuffer('garbage,compress')
const blob = new Blob([output.asUint8Array()], { type: 'application/pdf' })
output.destroy()
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = 'document.pdf'
link.click()

After: @sdk-pdf/react

useDocumentSaving builds the final PDF from the loaded document and the recorded changes. Saving doesn't reset any modified state, so the component that saves also records the saved revision:

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 hasUnsavedChanges(): 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 bytes = await prepareDocument({ returnOriginalIfUnchanged: false })
      const response = await fetch('/api/documents', {
        method: 'PUT',
        body: bytes as BodyInit,
      })
      // A failed upload keeps the unsaved-changes indicator.
      if (!response.ok) return

      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

  • Bytes, not a Buffer. prepareDocument() resolves with a Uint8Array; there is no asUint8Array() to call and no buffer to destroy. downloadDocument() builds the same bytes and saves them on the user's device, which replaces the Blob and link code.
  • returnOriginalIfUnchanged defaults to true: with no changes, the original buffer passed to loadPdf comes back with no copy. Set it to false when you transfer or mutate the result, or the loaded document's buffer is detached and later reads throw.
  • Page subsets. pageIds saves only the listed pages, in the listed order, where you grafted pages into a new document to extract or split them.
  • No write options. Incremental saves, garbage collection, compression, encryption, and a download file name have no equivalent.
  • No flattening. bake() has no equivalent.
  • Both functions throw DocumentNotLoadedError when no document is loaded.

Coordinates

MuPDF.js and @sdk-pdf/react measure in the same unit but from different corners:

TopicMuPDF.js@sdk-pdf/react
UnitsPoints, 1/72 of an inchUser-space units, 1/72 of an inch
OriginThe top-left corner of the page, y pointing downThe bottom-left corner of the page, such as for widget positions
Page referenceZero-based page indicesSession-scoped page ids; the index is the position in usePages()
Page sizegetBounds(), with the page rotation appliedwidth and height of the usePages() entries, with rotation applied
ConversionPDFPage.getTransform() to PDF user spaceNo conversion helpers

On an unrotated page, flip the y axis of rectangles you stored from MuPDF.js, such as annotation or widget positions, against the page height before you pass them to useWidgetsCreate or setWidgetProps. Check the placement on a test document before converting stored positions in bulk.

Errors and logging

Before: MuPDF.js

MuPDF.js calls throw when they fail, such as a TypeError for a color with the wrong number of components. In a worker, the dispatcher catches the error and posts it to the page, and setLog() installs a logger for MuPDF's warnings and errors:

mupdf.setLog({
  error(message) {
    reportToMonitoring('error', message)
  },
  warning(message) {
    reportToMonitoring('warning', message)
  },
})

onmessage = (event) => {
  const [method, id, args] = event.data
  try {
    postMessage(['RESULT', id, methods[method](...args)])
  } catch (error) {
    postMessage([
      'ERROR',
      id,
      { name: error.name, message: error.message, stack: error.stack },
    ])
  }
}

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 { useEffect } from 'react'
import { InvalidPasswordError, logMessages, LogLevel, useLoadPdf } from '@sdk-pdf/react'

function useSdkDiagnostics() {
  // Replaces setLog(): subscribe, and unsubscribe on unmount.
  useEffect(() => {
    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)
    })
    return () => {
      unsubscribe()
    }
  }, [])
}

function useOpenDocument() {
  const [, loadPdf] = useLoadPdf()

  return async (data: Uint8Array) => {
    try {
      // Pass a copy and keep `data` for a password retry.
      await loadPdf(data.slice())
    } catch (error) {
      // The error arrives at the call, with no worker to relay it.
      if (error instanceof InvalidPasswordError) askForPassword()
      else showError(error)
    }
  }
}

Error classes

Every SDK error extends GenericError and carries a static message and an optional data object:

@sdk-pdf/reactThrown whenClosest MuPDF.js situation
InvalidPasswordErrorThe document is encrypted, and the password is missing or wrong.needsPassword(), and authenticatePassword() returning 0
DocumentFormatErrorThe PDF content is malformed beyond what the SDK can work around.—
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.A TypeError for an invalid argument
UninitializedErrorAn object is used before its asynchronous initialization has completed.—

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.
  • Repairs become log messages. MuPDF.js repairs a broken file when it opens it and reports that through wasRepaired(). In @sdk-pdf/react, problems the SDK works around arrive as log messages instead.
  • Structured logs, opt-in. The SDK never prints to the browser console. Where setLog() receives a message string per warning or error, each logMessages entry 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: MuPDF.js

MuPDF.js objects hold WebAssembly memory, so you destroy every document, page, pixmap, structured text, and buffer when you're done with it, empty the cache when you need the memory back, and stop the worker when the viewer goes away:

// In the worker.
pixmap.destroy()
stext.destroy()
page.destroy()
doc.destroy()
mupdf.emptyStore()
// On the page.
worker.terminate()

After: @sdk-pdf/react

Most of this goes away, and the rest follows React's component lifecycle:

Concern@sdk-pdf/react
Pages, pixmaps, and textNothing to destroy: there is no page, pixmap, structured text, or buffer object to release.
Rendering off-screen pagesNothing to manage: PageCanvas renders lazily and does no work off-screen.
The workerNo worker of your own to start or terminate.
Closing a documentresetPdf() from useResetPdf unloads the document and invalidates its ids, snapshots, and revisions.
Event listenersuseViewportEvent subscriptions follow the component lifecycle.
Log subscriptionslogMessages.on() returns an unsubscribe function: call it in the effect cleanup, as in Errors and logging.
Keyboard listenersuseHotkeys installs window-level shortcuts; pass a falsy value instead of the mapping to disable them.
BuffersDon't reuse a buffer passed to loadPdf; copy it first.

What to know

  • No release calls. The API has no counterpart to destroy(), emptyStore(), or shrinkStore().
  • Reset before you reuse. Where you destroyed a document before opening the next one, call resetPdf() and then loadPdf, and drop the ids and snapshots you kept from the old document.

Features without an equivalent

Plan around these before you migrate:

MuPDF.js featureIn @sdk-pdf/react
Structured text: lines, characters, fonts, image blocks, walk(), HTML and JSON outputParagraph text and bounds from useProgrammaticEditor
Reading the selected text (highlight(), copy())No equivalent
Rendering pages to image files (toPixmap(), asPNG(), asJPEG())PageCanvas draws onto a canvas, as loaded from the file, without session edits; no image export
Creating markup, shape, note, and file attachment annotations, and drawing ink by handNo equivalent; existing shapes, lines, and ink display, and their border color and width can be changed; useInkAnnotationCreate creates ink from code
Listing a page's annotations (getAnnotations())No equivalent
Redaction (applyRedactions())No equivalent
Flattening (bake())No equivalent
Write options: incremental saves, garbage collection, compression, encryptionNo equivalent
Outline, page labels, link creation (createLink())No equivalent
Metadata, permissions, layers, and embedded filesNo equivalent
Copying pages from another document (graftPage()), cropping (setPageBox())No equivalent
Document JavaScript (enableJS())No equivalent; Acrobat date formats are recognized without running scripts
Named undo steps (beginOperation(), getJournal())One automatic history
Low-level PDF objects and content streams (PDFObject, getTrailer(), addStream())No equivalent
Coordinate conversion (getTransform())No equivalent; see Coordinates
XPS, CBZ, EPUB, FictionBook 2, and image files; reflowable layoutPDF only
Running in Node or Bun, server-side jobs@sdk-pdf/react runs in the browser

On this page