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/reactis in beta, and its exports and APIs may change between releases. Pin the exact package version and review the changes when you update.
Migration checklist
- Read the overview: how the two integrations differ, and the API map for every MuPDF.js call you use.
- Install the package and replace the
mupdfimport and its worker withPdfProviders. - Load and reset documents with
loadPdfandresetPdf, including the password flow. - Replace the pixmap render loop and your page layers with
Viewport. - Move page navigation from indices to page ids.
- Move page insertion, deletion, and reordering to the page hooks.
- Map your zoom level and fit calculations to the viewport scale.
- Rebuild thumbnails with
PageCanvas. - Move text extraction and search.
- Move widget reading and filling to the widget hooks.
- Map annotation creation to interaction tools.
- Replace the journal and
hasUnsavedChanges(). - Replace
saveToBuffer()and downloads. - Convert stored positions using coordinates.
- Move error handling and logging and drop the
destroy()calls. - Plan for features without an equivalent.
- Remove
mupdf, the worker script and its message protocol, and the WebAssembly file you serve. Test encrypted, malformed, large, and form-heavy documents, and the save round-trip.
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, andpage.toPixmap(matrix, colorspace)renders it into aPixmapthat 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,
openDocumentreturns aPDFDocumentandloadPageaPDFPage, 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()andgetLinks(), 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 withdestroy(). - 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:
PdfProvidershosts the document and viewport state; every hook and component renders inside it.useLoadPdfloads a document from bytes and reports a load state.Viewportrenders the pages and handles scrolling, selection, text editing, form filling, link navigation, and zoom and drag gestures on its own.PageCanvasrenders 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.
| Concern | MuPDF.js | @sdk-pdf/react |
|---|---|---|
| Integration style | Synchronous objects, usually behind a Web Worker and your message protocol | A React provider, state hooks, and command hooks |
| Rendering | toPixmap() per page, drawn and laid out by your code | Viewport for the main view, PageCanvas for thumbnails and previews |
| Interaction | Your own text, link, and form layers | Built into Viewport, governed by the content policy |
| Page addressing | Zero-based page indices | Session-scoped page ids from usePages |
| Editing model | Annotations, widget values, the page tree, and low-level PDF objects | Page text, form widgets, images, and pages; no tools for markup annotations |
| Undo | An opt-in journal of named operations | Always on, one history for the whole document |
| Saving | saveToBuffer(options) | prepareDocument() and downloadDocument() |
| Memory | destroy() on every object | Nothing to release |
| File types | PDF, XPS, CBZ, EPUB, FictionBook 2, and images | |
| Diagnostics | Thrown exceptions, setLog() for warnings and errors | Typed errors thrown before any change; logMessages, never printed to console |
| Environment | Browsers, Node, and Bun; framework-agnostic | React in the browser |
What this means for your code:
- Delete the render pipeline. The worker and its message protocol, pixmap rendering,
ImageDataand PNG handling, lazy rendering with anIntersectionObserver, and the text and link layers move intoViewport. - Replace indices with ids. Every page reference in your state becomes a page id; convert at the edges (deep links, stored positions) with
usePagesandusePageIndexGetter. - 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.
- Rethink forms as widgets. Loops over
getWidgets()becomeuseWidgets, and values are read and written throughuseWidgetValue, which re-renders as the value changes. - Rethink editing. MuPDF.js creates annotations and writes page content through PDF objects;
@sdk-pdf/reactedits page text, widgets, image stamps, and pages through interaction tools and hooks, and creates ink annotations from code. - Drop memory management. There are no pages, pixmaps, or documents to destroy.
- Choose the policy. The default is
PDF_EDITOR; a reader needsPDF_VIEWERorPDF_PRINTER. - 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/react | Notes |
|---|---|---|
import * as mupdf from 'mupdf', the worker and its messages | PdfProviders | Nothing else to set up. |
Document.openDocument(data, 'application/pdf') | loadPdf(new Uint8Array(data)) from useLoadPdf | Asynchronous, with a load state. |
needsPassword(), authenticatePassword(password) | loadPdf(data, { password }), catch InvalidPasswordError | A rejection replaces the check. |
doc.destroy() before opening another document | resetPdf() from useResetPdf | Returns the load state to 'initial'. |
countPages() | usePageCount | 0 while no document is loaded. |
loadPage(index) | usePages()[index].id with usePages | Both zero-based. |
page.getBounds() | width and height of the usePages entry | 1/72 of an inch, rotation applied. |
page.toPixmap(), getPixels(), asPNG(), your canvases | Viewport | One component, no layers to assemble. |
Text and link layers from toStructuredText() and getLinks() | Viewport, with the textSelect tool and interactive links | See Display the document. |
link.isExternal(), doc.resolveLink(link) | Link navigation inside Viewport | When the policy's links level is 'interactive'. |
| Scrolling your page element into view | scrollToPage(pageId) from useScrollToPage | |
Tracking the visible page with an IntersectionObserver | useCurrentPageId | Re-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 usePageCreate | Inserts after a page, or appends. |
deletePage(index) | deletePages(pageIdOrIds) from usePageDelete | |
rearrangePages(order) | moveBefore or moveAfter from usePageMove | Moves pages, no full order. |
A page listed twice in rearrangePages | duplicatePage(sourcePageId) from usePageDuplicate | |
graftPage(to, srcDoc, srcPage), setPageBox() | No equivalent | No page import or cropping. |
A new document with graftPage() to extract or split pages | prepareDocument({ pageIds }) from useDocumentSaving | Saves 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 useViewportScale | Scale 1 is one user-space unit per CSS pixel. |
A fit-width zoom computed from getBounds() | useViewportFitScale, useViewportAutoFitScale | A one-time fit, not a persistent mode. |
| Small pixmaps or PNGs as thumbnails | PageCanvas | Lazy; shows the page as loaded. Showing session edits is coming soon. |
toStructuredText().asText() | getParagraphsByPage(pageId) from useProgrammaticEditor | Paragraph text and bounds. |
asJSON(), walk(), structured text options | No equivalent | No lines, characters, or fonts. |
page.search(needle), StructuredText.search() | useDocumentSearch | The SDK highlights the matches; no hit quads. |
StructuredText.highlight(), copy() | The textSelect tool in Viewport | No API reads the selected text. |
page.getWidgets() | useWidgets, useWidgetsGetter | Every widget of the document, in reading order. |
getValue(), setTextValue(), setChoiceValue(), toggle() | useWidgetValue | One widget per hook call. |
getName(), getFieldType() | fieldName and widgetType of the widget snapshot | See Forms. |
page.update() and rendering the page again | Nothing to call | The view follows the document. |
enableJS() | No equivalent | Acrobat 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 stageImage | Places a stamp annotation. |
createAnnotation('Stamp') from code | useStampAnnotationCreate | At a user-space position, with a staged image. |
createAnnotation('Ink'), setInkList() | useInkAnnotationCreate | Strokes your code collects; there is no drawing tool. |
createAnnotation() for markup, shapes, notes, and attachments | No equivalent | Existing shapes and lines display. |
getAnnotations() | No equivalent | useSelectedBlocks returns what the user selected. |
Redaction annotations, applyRedactions() | No equivalent | |
enableJournal(), undo(), redo(), canUndo(), canRedo() | useUndoRedo | Always on. |
beginOperation(), endOperation() | markUndoBoundary() from useUndoBoundary | Keeps an operation a separate step. |
getJournal() | No equivalent | Steps are recorded without names. |
hasUnsavedChanges() | useDocumentRevision, useDocumentRevisionGetter | Compare revisions to tell unsaved changes. |
saveToBuffer(options).asUint8Array() | prepareDocument() from useDocumentSaving | Resolves with a Uint8Array. |
Write options (incremental, garbage, compress, encrypt) | No equivalent | The options are returnOriginalIfUnchanged and pageIds. |
A Blob and an object URL for downloads | downloadDocument() from useDocumentSaving | |
bake() | No equivalent | No flattening. |
setLog({ error, warning }) | logMessages, LogLevel | The SDK never prints to the console. |
| Exceptions relayed from the worker | Errors thrown or rejected at the call | See Errors and logging. |
destroy(), emptyStore(), worker.terminate() | Nothing to call | See Cleanup and lifecycle. |
getMetaData(), setMetaData(), hasPermission(), layers, embedded files | No equivalent | |
PDFPage.getTransform() | No equivalent | See Coordinates. |
page.createLink() | No equivalent | |
PDFObject, getTrailer(), addStream() | No equivalent | No 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/reactSet up the project as Getting started describes: import the stylesheet, render the SDK on the client only, alias canvas in the bundler, and turn off React Strict Mode.
Then wrap the part of your app that shows documents in PdfProviders. Its props are optional, 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.
PdfProvidersdefaults to theselecttool andCONTENT_POLICY_PRESET.PDF_EDITOR, which makes text, annotations, links, and widgets editable. For a reader that fills forms, start withPDF_VIEWER(text and annotations read-only, links and widgets interactive) and thetextSelecttool, as above. Values that change at runtime go throughuseMemo, 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/reactis 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;
useLoadPdfloads PDF documents, with no MIME type or extension to pass. - A Uint8Array you don't reuse. Wrap an
ArrayBufferwithnew Uint8Array(data).loadPdfreads from the given buffer for the document's whole lifetime, and anything else touching that buffer, such as anotherloadPdfcall 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 callloadPdfagain 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;loadPdfreturns a promise, andloadState.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.
ViewportthrowsDocumentNotLoadedErrorwhen 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 thetextSelecttool, and widgets and links are levels of the content policy (widgets: 'interactive'lets users fill fields,links: 'interactive'keeps links clickable, and'hidden'doesn't render that kind of content). A level can also be a function of the block id, such aswidgets: (blockId) => (lockedIds.has(blockId) ? 'readonly' : 'interactive'). The HTML layers you built fromasJSON()andgetLinks()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.classNamesandtheme.styles, and set the gap between the pages and the viewport edges withtheme.viewportPadding, as Theming and customization describes. - Scrolling is built in.
Viewportshows 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 have | You need | Use |
|---|---|---|
| A zero-based page index | A page id | usePages()[index].id |
| A page id | Its index | getPageIndex(pageId) from usePageIndexGetter: a zero-based index or undefined |
| A page id, rendered | Its index | usePageIndexGetter paired with useRenderOnPageOrderChange |
| Only the count | usePageCount, 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.
usePageIndexGetterreturnsundefinedfor an unknown id, andscrollToPagethrowsInvalidInputError. - Page sizes. The
widthandheightof ausePagesentry replacegetBounds(): 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
Viewportwhenlinksis'interactive'; there is nogetLinks()orresolveLink()to call, and creating links has no equivalent. - Outline and labels.
loadOutline()andpage.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
| Operation | MuPDF.js | @sdk-pdf/react |
|---|---|---|
| Insert | insertPage(at, addPage(...)) at an index, -1 for the end | createPage(width, height, insertAfterPageId?), after a page or appended; no index |
| Delete | deletePage(index), one page per call | deletePages(pageIdOrIds), several pages in one call |
| Reorder | rearrangePages(order) with the full new order | moveBefore(ids, beforePageId?) (start when omitted) or moveAfter(ids, afterPageId?) (end when omitted) |
| Duplicate | A page listed twice in rearrangePages | duplicatePage(sourcePageId, insertAfterPageId?), asynchronous, inserted after the source by default |
| Rotate | No method for existing pages; the rotate argument of addPage for a new page | setPageRotation(pageIdOrIds, rotation) for any page, an absolute angle on a quarter turn |
| Extract | graftPage() into a new document | prepareDocument({ pageIds }) saves only the listed pages, in the listed order |
| Copy or crop | graftPage(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, letusePagesre-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, passpageIdstoprepareDocument().
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
zDPI becomessetScale(z / 72), and there is nodevicePixelRatioin the calculation. - Fits are one-time calculations.
useViewportAutoFitScaleapplies when a document loads and doesn't re-apply on viewport resizes or manual zooming;useViewportFitScalereflects the viewport size at the moment of the call.fitWidthScalefits the widest page into the viewport width andfitHeightScalethe tallest page into its height, both accounting forviewportPadding. - Gestures are built in. Pinch and wheel zoom go through the same scale and are tuned with
zoomGesturesonPdfProviders. ItsminScale(0.5by default) andmaxScale(4) bound the gestures; to give them the limits of your buttons, passminScale: 48 / 72andmaxScale: 384 / 72in a configuration defined outside the component or memoized. - Zoom around a point.
setScale(scale, pivot?)keeps the document still atpivot, a point{ x, y }given as fractions of the viewport area (0the top or left edge,1the bottom or right edge), the center by default. The third item ofuseViewportScale(),animateScale(scale, pivot?), animates the change and resolves when it ends. Both throwInvalidInputErrorunless the scale is a positive finite number. - The scale updates after a gesture. During a pinch or wheel zoom,
scalekeeps its value until the gesture ends;zoomGestureStartandzoomGestureEndfromuseViewportEventmark the gesture.
Thumbnails
Before: 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
scaleprop, and the pixels stay sharp; there is no scale to compute fromgetBounds()and nodevicePixelRatiofactor. - Lazy by default. An off-screen canvas does no work until it becomes visible. In a virtualized list, pass
isVisibleto skip the per-canvas intersection observer. - Other props:
renderOnResizefor window-relative sizes,hideAnnotations(form widgets included) where you passedfalseas theshowExtrasargument oftoPixmap(), andhideExtractableTextfor drawing your own text on top. - Thumbnails that show session edits are coming soon.
PageCanvasdraws the page as loaded from the file; the main view shows the edits. Rotation is applied and followed live. - No image files.
PageCanvasdraws onto a canvas element; there is no API that returns a page as PNG or JPEG bytes, asasPNG()andasJPEG()do. - Render only when ready.
PageCanvasthrowsDocumentNotLoadedErrorwhen no document is loaded.
Text extraction and search
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 paragraphboundsin user space. There are no lines, character quads, fonts, or image blocks, nowalk(), and no structured text options such aspreserve-whitespaceordehyphenate. - Matches, not quads. Each match carries
id,pageId,paragraphId, and the matchedtext, in document order, and the SDK draws the highlights: there are no hit quads to position or turn intoHighlightannotations.goToMatch(match.id)activates a match from a results list, andisWholeWordmatches whole words only. A changed query is searched once typing pauses, andstatusis'searching'until the whole document is searched. - Selection is native. The
highlight()andcopy()helpers behind a custom text layer give way to thetextSelecttool, 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 widgetType | Value |
|---|---|---|
text | text | string |
combobox, listbox | choice for a single selection (isCombo tells a dropdown from a list box), or multiChoice | string, or selected option indices |
checkbox | checkbox | boolean: whether the widget is checked |
radiobutton | radio | boolean: whether the widget is checked |
button | pushButton | No value |
signature | signature | No 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.
useWidgetValuebinds one widget per hook call; there is no setter by field name. The field name is the snapshot'sfieldName, and every widget with the same field name shows and synchronizes the same value. - The whole document, not one page.
useWidgetslists every widget of the document;await getWidgets()fromuseWidgetsGetterloads every page with widgets first and suits one-time reads, such as collecting values to submit. - Read-only fields.
isReadOnly()becomes theisReadOnlywidget property. To lock a submitted form, callsetWidgetProps(ids, { isReadOnly: true }, { excludeFromHistory: true })fromuseSetWidgetProps. - No document JavaScript.
enableJS()has no equivalent. The SDK recognizes Acrobat date formats without running scripts and exposes them as the text widget'sformat(seeFormatType); fields driven by hand-written scripts read as plain text fields. - Form building.
useWidgetsCreate, theaddWidgettool,useSetWidgetProps, anduseBlockDeletecreate, 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 |
|---|---|
FreeText | The 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, Squiggly | No equivalent. |
Ink | No drawing tool. useInkAnnotationCreate creates an ink annotation from strokes your code collects, like setInkList(). |
Line, Square, Circle, Polygon, PolyLine | No tool creates them. Existing shapes and lines display, and their border color and width can be changed. |
Text notes, FileAttachment | No equivalent. |
Redaction annotations and applyRedactions() | No equivalent. |
What to know
- Switch the policy with the tool. Pass
PDF_EDITORwith the editing tools, as above.textSelectneeds read-only text: with editable text, the combination is unsupported and reports a message throughlogMessages. - Editing page text is built in. Under
PDF_EDITOR, users edit existing text in place, andViewporthandles typing.useSelectionTextStyledrives a style toolbar (fontSize,textColor,fontFamily,isBold,isItalic, and more), anduseFocusSelectionreturns the focus after a toolbar click. - Restyling instead of setters.
useSelectionAnnotationPropschangesborderColorandborderWidthof selected shapes, lines, ink, and widgets, where you calledsetColor()andsetBorderWidth(). Contents and quad points have no counterpart; opacity carries over only as the alpha channel ofborderColor. - Selection commands.
useSelectiontells a paragraph in text editing ({ type: 'text', blockId }) from selected blocks ({ type: 'blocks', blockIds }).useDeleteSelection(a selected text range or selected blocks),useDuplicateSelection,useMoveSelection,useSelectBlocks, anduseSelectedBlocksread and act on the selection. - 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 calledbeginOperation()to keep an operation apart, callmarkUndoBoundary()fromuseUndoBoundarybefore it starts and after its edits finish; it doesn't guarantee a single step for edits spread over time or pages. The named steps ofgetJournal()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
excludeFromHistoryoption ofuseSetWidgetPropsnever enter the history. - Events for user actions only.
useViewportEventreports what users do in the main view, such astextEditorInput,addToolApplied,blocksDragEnd, andwidgetInput. Page operations, undo, and redo that your code calls emit no events: track them where you call them, and watchuseDocumentRevisionfor any change.
Saving and downloading
Before: 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 aUint8Array; there is noasUint8Array()to call and no buffer to destroy.downloadDocument()builds the same bytes and saves them on the user's device, which replaces theBloband link code. - returnOriginalIfUnchanged defaults to
true: with no changes, the original buffer passed toloadPdfcomes back with no copy. Set it tofalsewhen you transfer or mutate the result, or the loaded document's buffer is detached and later reads throw. - Page subsets.
pageIdssaves 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:
| Topic | MuPDF.js | @sdk-pdf/react |
|---|---|---|
| Units | Points, 1/72 of an inch | User-space units, 1/72 of an inch |
| Origin | The top-left corner of the page, y pointing down | The bottom-left corner of the page, such as for widget positions |
| Page reference | Zero-based page indices | Session-scoped page ids; the index is the position in usePages() |
| Page size | getBounds(), with the page rotation applied | width and height of the usePages() entries, with rotation applied |
| Conversion | PDFPage.getTransform() to PDF user space | No 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/react | Thrown when | Closest MuPDF.js situation |
|---|---|---|
InvalidPasswordError | The document is encrypted, and the password is missing or wrong. | needsPassword(), and authenticatePassword() returning 0 |
DocumentFormatError | The PDF content is malformed beyond what the SDK can work around. | — |
NotSupportedError | The document uses a PDF feature the SDK doesn't support yet. | — |
DocumentNotLoadedError | An operation requires a document, and none is loaded. | — |
InvalidInputError | The input passed to the API is invalid. | A TypeError for an invalid argument |
UninitializedError | An 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.
ViewportandPageCanvasare the exception: they throwDocumentNotLoadedErrorwhen 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, eachlogMessagesentry carrieslevel(LogLevel.Debug,Info,Warning,Error),reason(aLogReasonmember), a staticmessage,data, andstack. There is no verbosity setting: filter bylevelin 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 text | Nothing to destroy: there is no page, pixmap, structured text, or buffer object to release. |
| Rendering off-screen pages | Nothing to manage: PageCanvas renders lazily and does no work off-screen. |
| The worker | No worker of your own to start or terminate. |
| Closing a document | resetPdf() from useResetPdf unloads the document and invalidates its ids, snapshots, and revisions. |
| Event listeners | useViewportEvent subscriptions follow the component lifecycle. |
| Log subscriptions | logMessages.on() returns an unsubscribe function: call it in the effect cleanup, as in Errors and logging. |
| Keyboard listeners | useHotkeys installs window-level shortcuts; pass a falsy value instead of the mapping to disable them. |
| Buffers | Don't reuse a buffer passed to loadPdf; copy it first. |
What to know
- No release calls. The API has no counterpart to
destroy(),emptyStore(), orshrinkStore(). - Reset before you reuse. Where you destroyed a document before opening the next one, call
resetPdf()and thenloadPdf, and drop the ids and snapshots you kept from the old document.
Features without an equivalent
Plan around these before you migrate:
| MuPDF.js feature | In @sdk-pdf/react |
|---|---|
Structured text: lines, characters, fonts, image blocks, walk(), HTML and JSON output | Paragraph 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 hand | No 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, encryption | No equivalent |
Outline, page labels, link creation (createLink()) | No equivalent |
| Metadata, permissions, layers, and embedded files | No 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 layout | PDF only |
| Running in Node or Bun, server-side jobs | @sdk-pdf/react runs in the browser |