Migration

Migrating from pdf-lib

This guide moves the browser side of a pdf-lib integration to @sdk-pdf/react. pdf-lib loads, creates, modifies, and saves PDF bytes in any JavaScript runtime, and has no rendering or UI: apps show its output in an <iframe> or another viewer. @sdk-pdf/react is a React library for the browser that shows the document, lets users edit it, and builds the final PDF when you save. The guide covers the parts users see and change; pdf-lib code that runs without a browser, such as document generation on a server, has no equivalent and can stay where it runs. Work through the checklist below; each step links to the section that covers it.

Beta: @sdk-pdf/react is in beta, and its exports and APIs may change between releases. Pin the exact package version and review the changes when you update.

Migration checklist

Overview

How the two integrations differ

pdf-lib creates and modifies PDF files in any JavaScript runtime (browsers, Node, Deno, and React Native) and has no rendering or UI. An integration built on it works on bytes, step by step:

  • PDFDocument.load() parses a Uint8Array, an ArrayBuffer, or a base64 string into an in-memory document; PDFDocument.create() starts an empty one.
  • Pages are PDFPage objects at zero-based indices: getPage(index), insertPage(index), removePage(index).
  • New content is drawn onto pages with drawText, drawImage, and vector drawing calls. Text already on a page can't be read or edited; only form field values can.
  • Forms are reached through getForm(), with fields looked up by fully qualified name.
  • save() serializes the document to a Uint8Array. To show the result, apps hand the bytes to an <iframe> or a viewer library, and save again after every change.
  • Encrypted documents aren't supported: load() throws EncryptedPDFError.

@sdk-pdf/react owns the document, and you own the UI:

  • PdfProviders hosts the document and the configuration; every hook and component renders inside it.
  • useLoadPdf loads a document from bytes, encrypted documents included.
  • Viewport shows the pages and handles selection, text editing, form filling, and zoom on its own; changes appear as they happen.
  • Pages, paragraphs, and widgets are addressed by session-scoped ids.
  • Edits don't rewrite the PDF file. Each change is an operation in a CRDT, which powers undo/redo; useDocumentSaving builds the final PDF when you need the bytes.
Concernpdf-lib@sdk-pdf/react
RuntimeAny JavaScript runtime: browsers, Node, Deno, React NativeThe browser, in a React app
StylePDFDocument objects and promisesA React provider, state hooks, and command hooks
DisplayNone: saved bytes go to an <iframe> or a viewer libraryViewport for the main view, PageCanvas for thumbnails
Page addressingZero-based indicesSession-scoped page ids from usePages
Page textDrawn onto pages; existing text can't be read or editedParagraphs that users edit in place and code reads and rewrites
FormsFields by fully qualified nameWidgets with a fieldName
Change historyNoneUndo/redo and document revisions
Encrypted filesNot supported (EncryptedPDFError)Opened with the password
New documentsPDFDocument.create()Load an existing PDF
Outputsave(), saveAsBase64()prepareDocument(), downloadDocument()
DiagnosticsThrown errorsTyped errors thrown before any change; logMessages

What this means for your code:

  1. Split by runtime. Code that runs in Node, Deno, or React Native, such as generating or merging documents on a server, has no equivalent and stays where it is. The browser code that shows documents to users and changes them moves to @sdk-pdf/react.
  2. Drop the save-to-preview loop. Calling save(), building a Blob URL, and reloading an <iframe> after each change become one Viewport that shows edits live.
  3. Replace indices with ids. Every page reference in your state becomes a page id; convert at the edges (deep links, stored page numbers) with usePages.
  4. Let users do the editing. Hard-coded drawText and drawImage calls often become user actions: the addText and addImage tools, typing in existing paragraphs, and filling fields in place. Code that still writes text uses the programmatic editor, and code that still places images creates stamp annotations.
  5. Rethink forms as widgets. Field lookups by name become widget lists, and values are read and written through useWidgetValue in components.
  6. Choose the content policy. It decides what users may change: everything by default (PDF_EDITOR), or form fields only (PDF_VIEWER).
  7. Move into React. Async functions that drive a PDFDocument become hooks and components under PdfProviders.

pdf-lib has no viewer, so zoom, thumbnails, and undo have no pdf-lib counterpart and no section in this guide. @sdk-pdf/react gives your users all three: zoom gestures and useViewportScale, thumbnails with PageCanvas, and undo and redo with useUndoRedo. Building a PDF application shows each part. There is no cleanup and lifecycle section either: pdf-lib has nothing to destroy, and in @sdk-pdf/react, resetPdf() from useResetPdf closes a document.

API map

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

pdf-lib@sdk-pdf/reactNotes
npm install pdf-lib, @pdf-lib/fontkit, registerFontkit()The package and PdfProvidersNo font setup.
PDFDocument.load(bytes)loadPdf(data) from useLoadPdfloadPdf takes a Uint8Array.
PDFDocument.load() on an encrypted file, EncryptedPDFError, ignoreEncryptionloadPdf(data, { password }), catch InvalidPasswordErrorEncrypted documents open with the password.
PDFDocument.create()No equivalentLoad a PDF file, such as a blank template.
Loading another documentresetPdf() from useResetPdf, then loadPdf
save() into a Blob URL or saveAsBase64({ dataUri: true }) for an <iframe>ViewportShows changes live, with no save.
pdfDoc.getPageCount()usePageCount0 while no document is loaded.
pdfDoc.getPages(), getPage(index)usePages, usePages()[index].idEntries carry id, width, height, and rotation.
page.getSize(), page.getRotation()width, height, and rotation of a usePages entrySizes have the rotation applied.
addPage([width, height]), addPage(PageSizes.A4)createPage(width, height) from usePageCreateAppends; the size is required.
insertPage(index, size)createPage(width, height, insertAfterPageId)Inserts after a page id.
removePage(index)deletePages(pageIdOrIds) from usePageDelete
page.setRotation(degrees(angle))setPageRotation(pageIdOrIds, angle) from useSetPageRotationA plain number of degrees.
—moveBefore, moveAfter from usePageMove
—duplicatePage(sourcePageId, insertAfterPageId?) from usePageDuplicateCopies a page within the loaded document.
copyPages(srcDoc, indices) into a new document, to splitprepareDocument({ pageIds }) from useDocumentSavingSaves only the listed pages, in the listed order.
copyPages(srcDoc, indices) from another document, to mergeNo equivalentSee Merge and split documents.
embedPdf(), embedPage(), page.drawPage()No equivalent
setSize(), setMediaBox(), setCropBox(), scale(), scaleContent()No equivalent
form.getFields()useWidgets, useWidgetsGetterWidgets in reading order, not fields.
getTextField(name).setText(), getText()useWidgetValue on a text widgetOne widget per hook call.
getCheckBox(name).check(), uncheck(), isChecked()useWidgetValue on a checkbox widgetA boolean.
getRadioGroup(name).select(option), getDropdown(), getOptionList()useWidgetValue on radio, choice, and multiChoice widgetsSee Forms.
createTextField(name) and the other create methods, addToPage(), addOptionToPage()createWidgets(props) from useWidgetsCreateOne widget per props object.
field.enableReadOnly()setWidgetProps(ids, { isReadOnly: true }, { excludeFromHistory: true }) from useSetWidgetProps
setMaxLength(), enableMultiline(), enableCombing(), setAlignment(), setFontSize()maxLength, isMultiline, isMonospaced, horizontalAlign, fontSize widget props
form.removeField(field)deleteBlocks(widgetIds) from useBlockDeleteThe stored field value stays.
form.flatten()No equivalentLock widgets with isReadOnly.
setImage() on text fields and buttonsNo equivalent
page.drawText(text, { x, y })createParagraph(pageId, x, y) from useParagraphCreate, then insertText(text)An editable paragraph.
drawText options maxWidth, lineHeightsetParagraphProps([paragraphId], { width, lineHeight }) from useSetParagraphPropsAlso sets alignment, indent, and rotation.
—getParagraphsByPage(pageId) from useProgrammaticEditorReads the text already on a page.
embedFont(), widthOfTextAtSize(), heightAtSize()No equivalentFonts come from useSelectionTextStyle.
embedPng(), embedJpg(), page.drawImage()stageImage, then useStampAnnotationCreateA stamp annotation; or the user places it with the addImage tool.
drawRectangle(), drawLine(), drawCircle(), drawEllipse(), drawSvgPath(), pushOperators()No equivalent
pdfDoc.save()prepareDocument() from useDocumentSavingResolves with a Uint8Array.
A Blob and a temporary download linkdownloadDocument()No file name option.
saveAsBase64(), SaveOptionsNo equivalentThe save options are returnOriginalIfUnchanged and pageIds.
setTitle(), getTitle(), and the other metadata methodsNo equivalent
catalog.getOrCreateViewerPreferences(), attach(), addJavaScript()No equivalent
EncryptedPDFError and other thrown errorsInvalidPasswordError and the other SDK error classes, logMessagesSee Errors and logging.

Install and set up

Before: pdf-lib

pdf-lib installs as pdf-lib. Custom fonts need a second package, @pdf-lib/fontkit, registered on each document before a font is embedded:

npm install --save pdf-lib @pdf-lib/fontkit
import { PDFDocument } from 'pdf-lib'
import fontkit from '@pdf-lib/fontkit'

const pdfDoc = await PDFDocument.create()
pdfDoc.registerFontkit(fontkit)

Without a bundler, the UMD build exposes the same API on a global PDFLib.

After: @sdk-pdf/react

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. The configuration decides what users may do: an editor starts with everything editable, and a form-filling app with read-only text and fillable forms. Keep its identity stable: define it outside the component and read the prop once:

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

const selectTool: InteractionTool = { type: 'select' }
const textSelectTool: InteractionTool = { type: 'textSelect' }

const editorConfig = {
  initialInteractionTool: selectTool,
  initialContentPolicy: CONTENT_POLICY_PRESET.PDF_EDITOR,
}
const formFillerConfig = {
  initialInteractionTool: textSelectTool,
  initialContentPolicy: CONTENT_POLICY_PRESET.PDF_VIEWER,
}

export function App({ canEditContent }: { canEditContent: boolean }) {
  // Read the prop once: the initial values apply when the viewport first mounts, and a
  // prop that changes identity re-renders the PDF application. Switch later with
  // useSetInteractionMode.
  const [config] = useState(() => (canEditContent ? editorConfig : formFillerConfig))

  return (
    <PdfProviders {...config}>
      <DocumentLoader />
    </PdfProviders>
  )
}

What to know

  • Browser and React only. The SDK renders React components in the browser. Node, Deno, and React Native code that uses pdf-lib has no counterpart here.
  • No font setup. There is no fontkit to register and no font file to embed. The fonts users can pick are the fontFamily.options of useSelectionTextStyle: regular families, always available, and extracted ones, taken from the document.
  • Pick the content policy. PdfProviders defaults to the select tool and CONTENT_POLICY_PRESET.PDF_EDITOR, where text, annotations, links, and widgets are all editable. PDF_VIEWER keeps text and annotations read-only and lets users fill forms; PDF_PRINTER makes everything read-only, with links still clickable. The initial values apply when the viewport first mounts; switch later with useSetInteractionMode.

Load and reset a document

Before: pdf-lib

PDFDocument.load() takes a Uint8Array, an ArrayBuffer, or a base64 string or data URI. pdf-lib can't decrypt documents: an encrypted file makes load() throw EncryptedPDFError:

import { EncryptedPDFError, PDFDocument } from 'pdf-lib'

async function openFile(file) {
  try {
    return await PDFDocument.load(await file.arrayBuffer())
  } catch (error) {
    if (error instanceof EncryptedPDFError) {
      showError('Encrypted documents are not supported')
      return null
    }
    throw error
  }
}

The ignoreEncryption: true load option opens an encrypted file without decrypting it, so later changes may fail or behave unexpectedly. To work on another document, you load another PDFDocument.

After: @sdk-pdf/react

loadPdf(pdfFileContent: Uint8Array, options?: { password?: string }): Promise<void> from useLoadPdf loads the document and drives the load state. An encrypted file opens once the user enters its password:

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

function DocumentLoader() {
  const [loadState, loadPdf] = useLoadPdf()
  const bytesRef = useRef<Uint8Array | null>(null)
  const [isPasswordNeeded, setIsPasswordNeeded] = useState(false)

  async function open(password?: string) {
    if (!bytesRef.current) return

    try {
      // The document reads from the buffer it receives for its whole lifetime,
      // so keep the original for a password retry and pass a copy.
      await loadPdf(bytesRef.current.slice(), { password })
      setIsPasswordNeeded(false)
    } catch (error) {
      if (error instanceof InvalidPasswordError) {
        // Where pdf-lib stops at EncryptedPDFError, ask for the password and open again.
        setIsPasswordNeeded(true)
      } else {
        showError(error)
      }
    }
  }

  async function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
    const file = event.currentTarget.files?.[0]
    if (!file) return

    // loadPdf takes a Uint8Array: wrap the ArrayBuffer.
    bytesRef.current = new Uint8Array(await file.arrayBuffer())
    await open()
  }

  if (isPasswordNeeded)
    return <PasswordPrompt onSubmit={(password) => void open(password)} />
  if (loadState.status === 'initial')
    return <input type="file" accept=".pdf" onChange={handleFileChange} />
  if (loadState.status === 'loading') return <p>Loading the document…</p>

  return <PdfApplication />
}

function CloseButton() {
  const resetPdf = useResetPdf()

  // The load state returns to 'initial', and the file input shows again.
  return <button onClick={() => resetPdf()}>Close</button>
}

What to know

  • Bytes as a Uint8Array. Wrap an ArrayBuffer in new Uint8Array(buffer), and decode base64 strings and data URIs to bytes before calling loadPdf. A URL becomes a fetch of your own.
  • Encrypted files open. Without a password, the empty password is tried, as the PDF specification prescribes; a missing or wrong password rejects with InvalidPasswordError. ignoreEncryption has no counterpart: an encrypted document opens with its password or not at all.
  • One load option. password is the only option of loadPdf; parseSpeed, updateMetadata, throwOnInvalidObject, and capNumbers have no equivalent.
  • Keep a copy for retries. Anything else touching the buffer passed to loadPdf, such as another loadPdf call, can detach it, and every later document read throws. To reuse the bytes, pass data.slice().
  • No empty documents. PDFDocument.create() has no equivalent: load an existing PDF, such as a blank template your app ships.
  • Reset invalidates ids. After resetPdf() from useResetPdf, page and block ids, widget snapshots, and document revisions from the old document are no longer valid.

Display the document

Before: pdf-lib

pdf-lib can't render pages. To show a document, apps save it and hand the bytes to an <iframe>, as a Blob URL or as a data URI from saveAsBase64({ dataUri: true }), and repeat that after every change:

const pdfBytes = await pdfDoc.save()
const blob = new Blob([pdfBytes], { type: 'application/pdf' })
document.getElementById('preview').src = URL.createObjectURL(blob)

Apps that need more control render the saved bytes with a separate library, such as pdf.js.

After: @sdk-pdf/react

Viewport renders the loaded document and every change made to it, by users or by your code, with nothing to save first. It takes no props: PdfProviders configures it and hooks control it:

import { useViewportAutoFitScale, Viewport } from '@sdk-pdf/react'

function PdfApplication() {
  // Opens at the page width, once per loaded document.
  useViewportAutoFitScale('width')

  return (
    <div style={{ display: 'grid', gridTemplateRows: 'auto 1fr', height: '100vh' }}>
      {/* Your controls: the page, form, and save actions from this guide. */}
      <Toolbar />
      {/* The viewport fills its parent, so the parent needs an explicit size. */}
      <div style={{ minHeight: 0 }}>
        <Viewport />
      </div>
    </div>
  )
}

What to know

  • No save-and-reload loop. The Blob URL, the data URI, and the <iframe> go away. prepareDocument() is needed only when you want the file, as in Saving and downloading.
  • Render only when ready. Viewport throws DocumentNotLoadedError when no document is loaded, so render it when the load state is 'ready'.
  • Interaction is built in. Scrolling, zoom gestures, selection, typing, and form filling work inside Viewport, limited by the content policy. Toolbars, page navigation with useScrollToPage, and zoom controls with useViewportScale are your components.
  • Styling is yours. The SDK renders unstyled; class names, inline styles, and CSS variables go through the theme configuration, as Theming and customization describes.

Pages

Before: pdf-lib

pdf-lib addresses pages by zero-based index and takes sizes as [width, height] pairs, often from PageSizes:

import { degrees, PageSizes } from 'pdf-lib'

const pageCount = pdfDoc.getPageCount()
const { width, height } = pdfDoc.getPage(0).getSize()

// Append an A4 page, and insert a Letter page as the second page.
pdfDoc.addPage(PageSizes.A4)
pdfDoc.insertPage(1, PageSizes.Letter)

// Remove the last page.
pdfDoc.removePage(pdfDoc.getPageCount() - 1)

// Turn the first page by another quarter turn.
const firstPage = pdfDoc.getPage(0)
firstPage.setRotation(degrees(firstPage.getRotation().angle + 90))

After: @sdk-pdf/react

usePages returns the pages in document order, so an index is a position in that list. The page commands take page ids: usePageCreate, usePageDelete, usePageMove, and useSetPageRotation:

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

// PageSizes.A4 and PageSizes.Letter, in user-space units.
const A4 = { width: 595.28, height: 841.89 }
const LETTER = { width: 612, height: 792 }

function PageActions() {
  const pages = usePages()
  const createPage = usePageCreate()
  const deletePages = usePageDelete()
  const { moveBefore } = usePageMove()
  const setPageRotation = useSetPageRotation()

  if (pages.length === 0) return null
  const firstPage = pages[0]
  const lastPage = pages[pages.length - 1]

  function insertAtStart() {
    // insertPage(0, PageSizes.Letter): create the page, then move it to the start.
    const pageId = createPage(LETTER.width, LETTER.height)
    moveBefore(pageId)
  }

  return (
    <div role="toolbar">
      {/* addPage(PageSizes.A4): without a target page, the new page is appended. */}
      <button onClick={() => createPage(A4.width, A4.height)}>Add page</button>
      {/* insertPage(1, PageSizes.Letter): the new page goes after the first one. */}
      <button onClick={() => createPage(LETTER.width, LETTER.height, firstPage.id)}>
        Insert as page 2
      </button>
      <button onClick={insertAtStart}>Insert at start</button>
      <button disabled={pages.length <= 1} onClick={() => deletePages(lastPage.id)}>
        Delete last page
      </button>
      {/* Rotation is absolute: add to the current angle yourself. */}
      <button onClick={() => setPageRotation(firstPage.id, firstPage.rotation + 90)}>
        Rotate first page
      </button>
    </div>
  )
}

From indices to ids

pdf-lib@sdk-pdf/react
pdfDoc.getPage(index)usePages()[index]: an entry with id, width, height, and rotation
pdfDoc.getPageCount()usePageCount, which re-renders only when the count changes
The index of a page you holdgetPageIndex(pageId) from usePageIndexGetter: a zero-based index, or undefined for an unknown id
page.getSize(), getWidth(), getHeight()width and height of the entry, with the rotation applied
page.getRotation().anglerotation of the entry: 0, 90, 180, or 270

What to know

  • Ids, not indices. An id keeps pointing at the same page while pages are added, removed, and moved by the user or your code, where an index would shift to another page. Ids are session-scoped: store page numbers, and resolve them to ids once the document is ready.
  • Sizes are required. createPage(width, height, insertAfterPageId?) always takes the size in user-space units, one unit being 1/72 of an inch; the PageSizes pairs carry over as numbers. pdf-lib's getSize() reads the MediaBox, while usePages sizes have the rotation applied.
  • Positions are page ids. A new page goes after insertAfterPageId, or at the end without it. For the first position, create the page and call moveBefore(pageId), which moves pages to the start when no target page is given.
  • Rotation is a number. setPageRotation(pageIdOrPageIds, rotation) takes degrees instead of degrees(...). It sets an absolute angle; -90 and 450 are normalized, and an angle off a quarter turn throws InvalidInputError.
  • Undo. Deleting, moving, or rotating several pages in one call is a single undo step, and edits made within 500 ms of each other merge into one step, even across hook calls. To keep an operation a separate step, call markUndoBoundary() from useUndoBoundary before it starts and after its edits finish. An undo can restore a deleted page with its content.
  • New operations. moveBefore and moveAfter reorder pages, and duplicatePage from usePageDuplicate copies one. Page boxes and resizing (setSize, setMediaBox, setCropBox, scale, scaleContent) have no equivalent.

Merge and split documents

Before: pdf-lib

pdf-lib merges documents by loading each source and copying its pages into the target with copyPages, followed by addPage or insertPage. It splits a document by copying some of its pages into a document from PDFDocument.create(), and draws pages of other documents onto a page with embedPdf, embedPage, and drawPage:

import { PDFDocument } from 'pdf-lib'

// Merge: copy every page of each source into a new document.
const mergedPdf = await PDFDocument.create()
for (const sourceBytes of [firstPdfBytes, secondPdfBytes]) {
  const sourcePdf = await PDFDocument.load(sourceBytes)
  const copiedPages = await mergedPdf.copyPages(sourcePdf, sourcePdf.getPageIndices())
  for (const page of copiedPages) mergedPdf.addPage(page)
}

// Split: copy the first two pages into a document of their own.
const firstPartPdf = await PDFDocument.create()
const [firstPage, secondPage] = await firstPartPdf.copyPages(mergedPdf, [0, 1])
firstPartPdf.addPage(firstPage)
firstPartPdf.addPage(secondPage)

const mergedBytes = await mergedPdf.save()
const firstPartBytes = await firstPartPdf.save()

After: @sdk-pdf/react

Splitting becomes a save option: prepareDocument({ pageIds }) from useDocumentSaving builds a PDF of the listed pages only, in the listed order, with the session's edits:

import { useDocumentSaving, usePages } from '@sdk-pdf/react'

// Replaces copying pages into a new document: the first two pages become a file of their
// own.
function useSaveFirstPart() {
  const pages = usePages()
  const { prepareDocument } = useDocumentSaving()

  return () => prepareDocument({ pageIds: pages.slice(0, 2).map((page) => page.id) })
}

Merging has no equivalent: the page hooks work on the loaded document, and there is no API to import pages from another file. Keep merging where it runs today, on your server or in the browser with pdf-lib, and pass the resulting bytes to loadPdf. Inside the loaded document:

  • duplicatePage(sourcePageId, insertAfterPageId?) from usePageDuplicate copies a page with all its content and resolves with the new page's id;
  • moveBefore and moveAfter from usePageMove reorder pages, several at once;
  • deletePages from usePageDelete removes them.

Each page goes into a saved file once: to repeat a page, duplicate it first. Saving some of the pages doesn't change the loaded document.

Forms

Before: pdf-lib

getForm() returns the document's form. Fields are looked up by fully qualified name and typed by getter; a missing name, or a field of another type, throws:

const form = pdfDoc.getForm()

form.getTextField('customer.name').setText('Jane Doe')
form.getCheckBox('terms.accepted').check()
form.getRadioGroup('delivery').select('Express')

const customerName = form.getTextField('customer.name').getText()

// A new radio group: each addOptionToPage call adds one widget.
const page = pdfDoc.getPage(0)
const contactField = form.createRadioGroup('contact')
contactField.addOptionToPage('Email', page, { x: 72, y: 540, width: 16, height: 16 })
contactField.addOptionToPage('Phone', page, { x: 72, y: 510, width: 16, height: 16 })

// Lock every field before the document goes out.
for (const field of form.getFields()) field.enableReadOnly()

const pdfBytes = await pdfDoc.save()

After: @sdk-pdf/react

Users fill fields in Viewport when the content policy lets them interact with widgets. For values your code sets or reads, the unit is the widget: useWidgets lists the widgets in reading order, each with its fieldName, and useWidgetValue binds one widget's value like a useState backed by the document. Working with forms covers the model:

import { useEffect, useRef } from 'react'
import {
  TextWidget,
  useWidgets,
  useWidgetsCreate,
  useWidgetValue,
  Widget,
} from '@sdk-pdf/react'

function isTextWidget(widget: Widget): widget is TextWidget {
  return widget.widgetType === 'text'
}

// Replaces form.getTextField(name).setText(text) for each entry of `values`.
function PrefillTextFields({ values }: { values: Record<string, string> }) {
  const { widgets, isLoaded } = useWidgets()

  // Wait for the complete list: it's partial until every page with widgets has loaded.
  if (!isLoaded) return null

  return (
    <>
      {widgets
        .filter(isTextWidget)
        .filter((widget) => widget.fieldName in values)
        .map((widget) => (
          <PrefillTextField
            key={widget.id}
            widget={widget}
            text={values[widget.fieldName]}
          />
        ))}
    </>
  )
}

function PrefillTextField({ widget, text }: { widget: TextWidget; text: string }) {
  const [, setValue] = useWidgetValue(widget)
  const isPrefilled = useRef(false)

  useEffect(() => {
    // Write once, so what the user types afterwards stays.
    if (isPrefilled.current) return
    isPrefilled.current = true
    setValue(text)
  }, [setValue, text])

  return null
}

// Replaces createRadioGroup(name) and one addOptionToPage call per option.
function useAddContactField() {
  const createWidgets = useWidgetsCreate()

  return (pageId: string) => {
    // Radio widgets that share a field name form one group.
    const option = {
      pageId,
      widgetType: 'radio',
      x: 72,
      width: 16,
      height: 16,
      fieldName: 'contact',
    } as const

    return createWidgets([
      { ...option, y: 540, checkedValue: 'Email' },
      { ...option, y: 510, checkedValue: 'Phone' },
    ])
  }
}

Render PrefillTextFields once inside PdfApplication, with your values keyed by field name.

Mapping the field API

pdf-lib@sdk-pdf/react
PDFTextField: setText(), getText()A text widget; its value is a string
PDFCheckBox: check(), uncheck(), isChecked()A checkbox widget; its value is a boolean
PDFRadioGroup: select(option), getSelected()Several radio widgets sharing a field name, each with a checkedValue; each widget's value is a boolean, whether it's checked
PDFDropdown: select(), addOptions(), enableEditing()A choice widget with isCombo; its value is a string. Options are the options prop, and allowsCustomValue accepts typed text
PDFOptionList, with or without enableMultiselect()A choice widget without isCombo, a list box; with multiple selection, a multiChoice widget, whose value is the selected option indices
PDFButton: addToPage(text, page)A pushButton widget with a caption; it has no value
PDFRadioGroup: enableOffToggling(); mutual exclusion (isMutuallyExclusive() and its setters)canToggleToOff: true; isInUnison, the inverse of mutual exclusion: options sharing a checkedValue check together
PDFTextField: setMaxLength(), enableMultiline(), enableCombing()The maxLength, isMultiline, and isMonospaced props
setAlignment(), setFontSize()The horizontalAlign and fontSize props; a fontSize of 0 sizes the text automatically
addToPage() and addOptionToPage() options, such as backgroundColorcreateWidgets(props): pageId, widgetType, x, y, width, and height are required; fillColor, borderColor, and the other widget props are optional

What to know

  • Lookups find nothing instead of throwing. Where getTextField(name) throws for a missing name or another field type, filtering useWidgets() by fieldName and widgetType returns no widget. For a one-time read inside a handler, await getWidgets() from useWidgetsGetter loads every page with widgets first.
  • Values are read and written per widget. useWidgetValue binds one widget per hook call, inside a component, so a fill by name becomes a component per widget, like PrefillTextField above. Its value follows the document: filling in the main view and undo/redo show up as they happen.
  • Field names are shared, not unique. createTextField(name) throws when the name exists. createWidgets from useWidgetsCreate with an existing fieldName adds a widget to that field, and every widget with the same name shows and synchronizes the same value; without a fieldName, a unique one is generated.
  • Boxes are required. addToPage options are optional, while createWidgets needs the widget box: user-space units with the origin at the bottom-left corner of the page. Check placement on a test document when you port coordinates.
  • Locking, not flattening. enableReadOnly() becomes setWidgetProps(widgetIds, { isReadOnly: true }, { excludeFromHistory: true }) from useSetWidgetProps, where excludeFromHistory keeps an undo from unlocking the form. The change applies at once, and the returned promise resolves when the widgets show it. form.flatten(), which turns fields into page content, has no equivalent.
  • Removing fields. form.removeField(field) becomes deleteBlocks(widgetIds) from useBlockDelete with the ids of every widget of that field. The stored field value stays.
  • No images in fields. setImage on text fields and buttons has no equivalent.

Draw text and images

Before: pdf-lib

pdf-lib embeds a font or an image into the document, then draws it at page coordinates:

import { rgb, StandardFonts } from 'pdf-lib'

const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica)
const pngImage = await pdfDoc.embedPng(pngImageBytes)
const page = pdfDoc.getPage(0)

page.drawText('Approved for release', {
  x: 72,
  y: 720,
  size: 14,
  font: helveticaFont,
  color: rgb(0.8, 0.1, 0.1),
  maxWidth: 300,
})

const { width, height } = pngImage.scale(0.5)
page.drawImage(pngImage, { x: 72, y: 600, width, height })

The drawn text becomes part of the page. pdf-lib has no API to read or edit text on a page afterwards, its own included.

After: @sdk-pdf/react

Text from code becomes a paragraph: useParagraphCreate creates an empty one, useSetParagraphProps sets its wrap width and layout, and the editor mirror from useProgrammaticEditor types into it. An image staged with stageImage goes onto the page as a stamp annotation, from code with useStampAnnotationCreate or by the user with the addImage tool:

import {
  CONTENT_POLICY_PRESET,
  stageImage,
  useParagraphCreate,
  useProgrammaticEditor,
  useSetInteractionMode,
  useSetParagraphProps,
  useStampAnnotationCreate,
} from '@sdk-pdf/react'

// Replaces drawText: the result is a paragraph users can edit later.
function useAddApprovalNote() {
  const createParagraph = useParagraphCreate()
  const setParagraphProps = useSetParagraphProps()
  const { getParagraphEditor } = useProgrammaticEditor()

  return async (pageId: string) => {
    const paragraphId = await createParagraph(pageId, 72, 720)
    // Like maxWidth: the text wraps at 300 units.
    setParagraphProps([paragraphId], { width: 300 })

    // undefined when the paragraph no longer exists.
    const editor = getParagraphEditor(paragraphId)
    editor?.selectAll()
    editor?.insertText('Approved for release')
    return paragraphId
  }
}

// Replaces embedPng and drawImage at fixed coordinates.
function useAddLogo() {
  const createStampAnnotation = useStampAnnotationCreate()

  return async (pageId: string, logo: Blob) => {
    const image = await stageImage(logo)
    // x and y are the bottom-left corner; with only a width, the height follows the
    // aspect ratio.
    return createStampAnnotation({ pageId, image, x: 72, y: 600, width: 200 })
  }
}

// Or let the user choose the spot: the next click on a page places the image.
function ImagePicker() {
  const setInteractionMode = useSetInteractionMode()

  async function placeImage(file: File) {
    const image = await stageImage(file)
    // Placing images is editing, so switch the policy with the tool.
    setInteractionMode(
      { type: 'addImage', image, width: 200 },
      CONTENT_POLICY_PRESET.PDF_EDITOR
    )
  }

  return (
    <input
      type="file"
      accept="image/*"
      onChange={(event) => {
        const file = event.currentTarget.files?.[0]
        if (file) placeImage(file).catch(showError)
      }}
    />
  )
}

What to know

  • Text stays editable. A paragraph is text, not drawing: users edit it in Viewport under a policy with editable text, and code reads and rewrites any paragraph, those already in the document included, with getParagraphsByPage(pageId) and getParagraphEditor(paragraphId), as Programmatic editing shows.
  • Users can add text too. The { type: 'addText' } tool creates a paragraph where the user clicks and focuses it for typing; it requires editable text.
  • No font or color options. insertText(text) takes the text only, and there are no font, size, or color options. Users style text with a selection toolbar built on useSelectionTextStyle. The paragraph-wide lineHeight, textAlign, textIndent, and rotation through matrix are props of setParagraphProps. Embedding fonts and measuring text (embedFont, widthOfTextAtSize) have no equivalent.
  • Check placement. createParagraph(pageId, x, y) takes the paragraph position in user-space units, and setParagraphProps([paragraphId], { width }) sets the width the text wraps at; the height follows the text. Like drawText, a paragraph's x and y are the start of its first baseline. Check the result on a test page when you port drawText coordinates.
  • Images are stamp annotations. drawImage paints into the page content; @sdk-pdf/react places the image as a stamp annotation that users can select, move, and resize. stageImage(blob) accepts any image the browser can decode, not only PNG and JPEG, and throws InvalidInputError when the blob isn't a loadable image or has no intrinsic size; SVGs are rasterized when the PDF is saved. The size goes on the placement, in user-space units: a single dimension keeps the aspect ratio, and without width and height, the natural size is used, fit down to the page. Like drawImage, x and y are the bottom-left corner.
  • No vector drawing. drawRectangle, drawLine, drawCircle, drawEllipse, drawSquare, drawSvgPath, and pushOperators have no equivalent. For freehand strokes, such as a handwritten mark collected in your UI, useInkAnnotationCreate creates an ink annotation from the points.
  • One document. Programmatic edits go through the same document as user edits. setEditorAwarenessProperties gives your code a presence cursor for real-time collaboration, which is coming soon.

Saving and downloading

Before: pdf-lib

save() serializes the document, with all changes, into a Uint8Array; saveAsBase64() returns a base64 string or a data URI. Saving also refreshes the appearance of form fields when the form was accessed through getForm():

const pdfBytes = await pdfDoc.save()
await fetch('/api/documents', { method: 'PUT', body: pdfBytes })

For a download, apps wrap the bytes in a Blob and click a temporary link. pdf-lib keeps no change history, so apps that warn about unsaved changes track edits themselves.

After: @sdk-pdf/react

useDocumentSaving builds the final PDF from the loaded document and the recorded changes. useDocumentRevision tells whether anything changed since the last save: keep the revision you saved, and compare:

import { useRef, useState } from 'react'
import {
  useDocumentRevision,
  useDocumentRevisionGetter,
  useDocumentSaving,
} from '@sdk-pdf/react'

function SaveControls() {
  const revision = useDocumentRevision()
  const getRevision = useDocumentRevisionGetter()
  // One component owns the saved revision: 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()

  // Every edit changes the revision; undoing back to the saved state clears the
  // indicator.
  const hasUnsavedChanges = revision !== savedRevision

  async function save() {
    if (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()
      // Like pdfDoc.save(): the bytes go to fetch, so never hand out the loaded
      // document's own buffer.
      const pdfBytes = await prepareDocument({ returnOriginalIfUnchanged: false })
      const response = await fetch('/api/documents', {
        method: 'PUT',
        body: pdfBytes 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 save()}>Save</button>
      {/* Replaces the Blob and the temporary link. */}
      <button onClick={() => void downloadDocument()}>Download</button>
      {hasUnsavedChanges && <span>Unsaved changes</span>}
    </div>
  )
}

What to know

  • returnOriginalIfUnchanged defaults to true: with no changes, prepareDocument() returns the buffer passed to loadPdf without a copy. Set it to false when you transfer or mutate the bytes, or the loaded document's buffer is detached and later reads throw.
  • Two options. Besides returnOriginalIfUnchanged, pageIds saves only the listed pages, as in Merge and split documents. useObjectStreams, addDefaultPage, objectsPerTick, updateFieldAppearances, base64 output, and a download file name have no equivalent; encode the bytes yourself where you need base64.
  • Change tracking is new. The revision changes on every edit by the user or your code, and undo and redo bring back the revision of that point in the history, so equal revisions mean no unsaved changes. Render SaveControls once the document is ready, so its first revision is the loaded document's. To read the revision in a handler without re-rendering, use useDocumentRevisionGetter.
  • Both functions throw DocumentNotLoadedError when no document is loaded.

Errors and logging

Before: pdf-lib

pdf-lib reports problems by throwing. load() throws EncryptedPDFError for encrypted files, the PDFForm getters throw when a field is missing or has another type, and text a font can't encode, such as Ω in the standard Helvetica, throws an encoding error:

import { EncryptedPDFError, PDFDocument } from 'pdf-lib'

async function fillAndSave(existingPdfBytes, customerName) {
  try {
    const pdfDoc = await PDFDocument.load(existingPdfBytes)
    pdfDoc.getForm().getTextField('customer.name').setText(customerName)
    // An encoding error for field text surfaces on save.
    return await pdfDoc.save()
  } catch (error) {
    if (error instanceof EncryptedPDFError)
      showError('Encrypted documents are not supported')
    else showError(error)
    return null
  }
}

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 useSdkLogReporting() {
  useEffect(() => {
    // The SDK never prints to the console: subscribe, and unsubscribe on unmount.
    const unsubscribe = logMessages.on('message', (logMessage) => {
      if (logMessage.level >= LogLevel.Warning)
        reportToMonitoring(logMessage.message, logMessage.data)
    })
    return () => {
      unsubscribe()
    }
  }, [])
}

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

  return async (data: Uint8Array, password?: string) => {
    try {
      // Pass a copy and keep `data` for a password retry.
      await loadPdf(data.slice(), { password })
    } catch (error) {
      if (error instanceof InvalidPasswordError) askForPassword()
      // Narrow the other SDK error classes with instanceof in the same way.
      else showError(error)
    }
  }
}

What to know

  • Error classes. InvalidPasswordError (the password is missing or wrong), DocumentFormatError (the PDF is malformed beyond what the SDK can work around), NotSupportedError (the document uses a PDF feature the SDK doesn't support yet), DocumentNotLoadedError, InvalidInputError, and UninitializedError. All extend GenericError and carry a static message and an optional data object.
  • Nothing half-applied. Functions that change the document validate their input and throw before touching anything; functions that only read return empty values instead of throwing; hooks never throw during render.
  • Logs are opt-in. Each message carries level (LogLevel.Debug, Info, Warning, Error), reason, a static message, data, and stack. There is no verbosity setting: filter by level in your subscriber.

Features without an equivalent

Plan around these before you migrate. Jobs that run outside the browser, such as generating, merging, or flattening documents on a server, can keep running where they are and hand their output to loadPdf:

pdf-lib featureIn @sdk-pdf/react
Running in Node, Deno, or React NativeNo equivalent; the SDK runs in React in the browser
Creating a document from scratch (PDFDocument.create())Load an existing PDF, such as a blank template
Copying pages from other documents and merging (copyPages)No equivalent; usePageDuplicate copies pages within the loaded document
Drawing pages of other documents (embedPdf, embedPage, drawPage)No equivalent
Page boxes and resizing (setMediaBox, setCropBox, setSize, scale, scaleContent)No equivalent
Vector drawing (drawRectangle, drawLine, drawCircle, drawEllipse, drawSvgPath, pushOperators)No equivalent; SVG images placed as stamps are rasterized on save
Embedding fonts and measuring text (embedFont, @pdf-lib/fontkit, widthOfTextAtSize)No equivalent; fonts come from the fontFamily options of useSelectionTextStyle
Text style options on drawn text (font, size, color)Users style paragraphs through the selection
Flattening forms (form.flatten())No equivalent; lock widgets with isReadOnly
Images in fields (setImage)No equivalent
Document metadata (setTitle, getAuthor, and the other metadata methods)No equivalent
Viewer preferences, attachments (attach), document JavaScript (addJavaScript)No equivalent
Save options (useObjectStreams, addDefaultPage, objectsPerTick) and saveAsBase64()No equivalent; encode the bytes yourself

On this page