Migration

Migrating from PDFium

This guide moves the viewer and editor of a PDFium integration to @sdk-pdf/react. PDFium is the open-source PDF library, with a C API, that Chromium uses. It has no UI, so an integration links it into native code, such as a desktop app or a server, and builds the viewer on top: it renders pages into bitmaps, forwards input to a form-fill environment, extracts text, edits page objects, and saves through a write callback. @sdk-pdf/react runs in the browser and ships the viewer and editor as React components and hooks. The guide covers moving the part users see and edit into a React app; your server or native code keeps fetching and storing the bytes, and batch jobs that never show a page can stay on PDFium. 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

PDFium is a library with a C API and no UI:

  • You build it from source with Chromium's tooling (gclient, GN, and Ninja), choose features such as V8 JavaScript and XFA forms with GN arguments, and link it into your app. The API is the set of C headers in public/: fpdfview.h, fpdf_formfill.h, fpdf_text.h, fpdf_edit.h, fpdf_save.h, fpdf_ppo.h, and more.
  • FPDF_InitLibraryWithConfig sets up process-wide state before any other call, and FPDF_DestroyLibrary releases it.
  • Everything is an opaque handle you open and close yourself: FPDF_DOCUMENT, FPDF_PAGE, FPDF_BITMAP, FPDF_TEXTPAGE, and FPDF_FORMHANDLE.
  • There is no viewer. You render each page into a BGRx or BGRA bitmap and draw it, lay out and scroll the pages, convert pointer positions with FPDF_DeviceToPage, and forward input to the form-fill environment, which calls back into your app to repaint (FFI_Invalidate), run timers, and set the cursor.
  • Pages are 0-based indices, positions are page coordinates in points with the origin at the bottom-left corner, and text strings (FPDF_WIDESTRING) are UTF-16LE.
  • No call is thread-safe: an app makes one PDFium call at a time.
  • Edits change page objects in memory; FPDFPage_GenerateContent writes them into the page, and FPDF_SaveAsCopy streams the file through your write callback.

@sdk-pdf/react runs in the browser; the SDK owns the document, and you own the UI:

  • Viewport renders the pages and handles scrolling, zooming, selection, text editing, form filling, and link navigation on its own. PageCanvas renders single pages for thumbnails and previews.
  • PdfProviders hosts the document and viewport state. Toolbars and panels are your React components, driven by hooks that re-render them on change and hooks that return commands.
  • 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 it.
  • Pages and blocks are addressed by session-scoped ids, and positions are user-space units with the origin at the bottom-left corner of the page.
ConcernPDFium@sdk-pdf/react
Where it runsNative code: a desktop app or a serverThe browser, in your React app
SetupBuild from source, link, FPDF_InitLibraryWithConfigThe package from a private registry and PdfProviders
UINone: you draw bitmaps and forward inputViewport and PageCanvas; you build toolbars and panels
ResourcesHandles you open and close in orderOwned by the SDK; resetPdf() unloads the document
Pages0-based indicesSession-scoped page ids
FormsA form-fill environment, your callbacks, and your input forwardingBuilt into Viewport; widgets and useWidgetValue for your own form UI
EditingPage objects (FPDFPageObj_*) and FPDFPage_GenerateContentInteraction tools, paragraph and widget hooks, and page hooks
UndoThe focused form field (FORM_Undo)The whole document (useUndoRedo)
SavingFPDF_SaveAsCopy with a write callbackprepareDocument() resolves with the bytes
ErrorsNULL or FALSE returns; FPDF_GetLastError after a failed loadTyped errors thrown before any change; logMessages

What this means for your code:

  1. Split the work. The viewer moves to the browser. Your server sends the PDF bytes and stores the bytes that prepareDocument() returns; batch jobs such as rendering previews, merging, flattening, or indexing text can keep running on PDFium there.
  2. Delete the render loop and input plumbing. Bitmap allocation, FPDF_RenderPageBitmap followed by FPDF_FFLDraw, page layout and scrolling, zooming by re-rendering, FPDF_DeviceToPage, and the FPDF_FORMFILLINFO callbacks move into Viewport.
  3. Delete handle bookkeeping. There is no library to initialize, and no page, text page, bitmap, or form handle to close.
  4. Replace indices with ids. Every page index in your state becomes a page id; convert at the edges with usePages and usePageIndexGetter.
  5. Rethink editing. Page objects become paragraphs, widgets, and images, edited through interaction tools and hooks; FPDFPage_GenerateContent and FPDF_SaveAsCopy become prepareDocument().
  6. Choose the policy. A PDFium viewer allows exactly what you implemented; PdfProviders defaults to PDF_EDITOR, so a reader needs PDF_VIEWER or PDF_PRINTER.

PDFium has no zoom state or fit modes: zoom is the size you render at, so Display the document covers it.

API map

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

PDFium@sdk-pdf/reactNotes
FPDF_InitLibraryWithConfig, FPDF_DestroyLibrary<PdfProviders>Nothing to initialize or destroy.
FPDF_LoadMemDocument(data, size, password)loadPdf(data, { password }) from useLoadPdfBoth read from the buffer while the document is open.
FPDF_LoadDocument(path, password)fetch the bytes, then loadPdf(data)loadPdf takes a Uint8Array.
FPDF_LoadCustomDocument, FPDFAvail_* (linearized loading)No equivalentFetch the whole file.
FPDF_GetLastError() returning FPDF_ERR_PASSWORDInvalidPasswordErrorSee Errors and logging.
FPDF_ERR_FORMATDocumentFormatError
FPDF_CloseDocumentresetPdf() from useResetPdfReturns the load state to 'initial'.
FPDF_GetPageCountusePageCount0 while no document is loaded.
FPDF_LoadPage(doc, index), FPDF_ClosePageusePages()[index].idNo page handles.
FPDF_GetPageWidth, FPDF_GetPageHeightwidth and height of the usePages entriesBoth with rotation applied.
FPDFBitmap_Create, FPDF_RenderPageBitmap, FPDF_FFLDrawViewportNo bitmaps to allocate or draw.
FPDF_RenderPageBitmap_Start, FPDF_RenderPage_ContinueNothing to callViewport renders on its own.
The render size you compute to zoomuseViewportScaleScale 1 is one user-space unit per CSS pixel.
A fit scale you compute from the page and window sizesuseViewportFitScale, useViewportAutoFitScaleOne-time fits.
The rotate argument of FPDF_RenderPageBitmap, FPDF_GRAYSCALE, FPDF_COLORSCHEMENo equivalentuseSetPageRotation edits the document.
Thumbnails rendered as small bitmapsPageCanvasRenders lazily, sized with CSS.
FPDFPage_GetThumbnailAsBitmapNo equivalentPageCanvas renders the page instead.
FPDF_DeviceToPage, FPDF_PageToDeviceNo equivalentSee Coordinates.
The current page index your viewer tracksuseCurrentPageIdThe page in the middle of the viewport.
Scrolling your viewer to a page indexscrollToPage(pageId) from useScrollToPage
FPDFLink_GetLinkAtPoint, FPDFDest_GetDestPageIndex, FFI_DoGoToActionNothing to callInternal links work in Viewport when links is 'interactive'.
FPDFBookmark_*, FPDF_GetPageLabelNo equivalent
FPDFPage_New(doc, index, width, height)createPage(width, height, insertAfterPageId?) from usePageCreateInserts after a page id.
FPDFPage_Delete(doc, index)deletePages(pageIdOrIds) from usePageDelete
FPDF_MovePagesmoveBefore or moveAfter from usePageMove
FPDFPage_SetRotation(page, quarterTurns)setPageRotation(pageIdOrIds, degrees) from useSetPageRotationBoth set an absolute rotation.
—duplicatePage(sourcePageId, insertAfterPageId?) from usePageDuplicate
FPDF_ImportPages into a new document, to extract pagesprepareDocument({ pageIds })Saves only the listed pages, in the listed order.
FPDF_ImportPages from another document, FPDF_ImportNPagesToOneNo equivalent
FPDFDOC_InitFormFillEnvironment, FORM_OnAfterLoadPage, FORM_OnLButtonDown, FORM_OnCharViewport under a content policy with interactive widgetsSee Forms.
FFI_Invalidate, FFI_SetCursor, FFI_SetTimerNothing to implement
FFI_OnChangewidgetInput from useViewportEventReports a value the user changed in the main view.
FPDFAnnot_GetFormFieldName, FPDFAnnot_GetFormFieldValueuseWidgets, useWidgetValueWidgets, not annotations.
FPDF_SetFormFieldHighlightColorThe --widget-overlay-background CSS variableSee Theming and customization.
—createWidgets(props) from useWidgetsCreate, the addWidget toolFPDFPage_CreateAnnot doesn't create widgets.
FORM_DoDocumentJSAction, FPDF_LoadXFANo equivalent
FPDFText_LoadPage, FPDFText_GetTextgetParagraphsByPage(pageId) from useProgrammaticEditorParagraphs, not characters.
FPDFText_GetRectThe paragraph's boundsBounds per paragraph, not per text segment.
FPDFText_GetCharBox, FPDFText_GetCharIndexAtPosNo equivalent
FPDFText_FindStart, FPDFText_FindNextuseDocumentSearchHighlights the matches and scrolls to the active one.
FPDFPageObj_NewTextObj, FPDFText_SetText, FPDFPage_InsertObjectuseParagraphCreate and getParagraphEditor, or the addText toolSee Editing and interaction tools.
FPDFPageObj_NewImageObj, FPDFImageObj_LoadJpegFileInlineThe addImage tool or useStampAnnotationCreate, with stageImagePlaces a stamp annotation.
FPDFPageObj_TransformThe select tool, useMoveSelection, useSetParagraphProps
FPDFPage_RemoveObjectuseBlockDelete, useDeleteSelectionBlocks, not page objects.
FPDFPage_CreateAnnot with FPDF_ANNOT_INK, FPDFAnnot_AddInkStrokeuseInkAnnotationCreateInk from strokes your code collects.
FPDFPageObj_CreateNewPath, FPDFPage_CreateAnnot for other subtypesNo equivalent
FPDFPage_GenerateContentNothing to callprepareDocument() applies the edits.
FORM_Undo, FORM_Redo, FORM_CanUndo, FORM_CanRedouseUndoRedoThe whole document, not the focused field.
An unsaved-changes flag set in FFI_OnChangeuseDocumentRevision, useDocumentRevisionGetterCompare revisions to tell whether there are unsaved changes.
FPDF_SaveAsCopy with FPDF_FILEWRITEprepareDocument() from useDocumentSavingResolves with a Uint8Array.
FPDF_INCREMENTAL, FPDF_REMOVE_SECURITY, FPDF_SaveWithVersionNo equivalent
FPDFPage_FlattenNo equivalent
FSDK_SetUnSpObjProcessHandlerlogMessagesProblems the SDK works around arrive as log messages.
FPDFDOC_ExitFormFillEnvironment, FPDFText_ClosePage, FPDFBitmap_DestroyNothing to callSee Cleanup and lifecycle.

Install and set up

Before: PDFium

PDFium builds from source: gclient fetches the code, gn args configures a build directory, and Ninja builds it. The GN arguments choose the features:

# gn args out/Release
is_debug = false
is_component_build = false
pdf_enable_v8 = false # No JavaScript support.
pdf_enable_xfa = false # No XFA forms; XFA also requires JavaScript.
pdf_is_complete_lib = true # One static library to link.

The app initializes the library once per process, before any other call:

#include <string.h>

#include "public/fpdfview.h"

int main(void) {
  FPDF_LIBRARY_CONFIG config;
  memset(&config, 0, sizeof(config));
  config.version = 2;
  // Zeroed fields: the default font paths, and no V8 isolate of your own.
  FPDF_InitLibraryWithConfig(&config);

  // ... load, render, edit, and save documents ...

  FPDF_DestroyLibrary();
  return 0;
}

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.

Mount PdfProviders where your app shows documents. Its props must keep their identity between renders, so define the tools outside the component and memoize the configuration:

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

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

export function App({ documentUrl, canEdit }: { documentUrl: string; canEdit: boolean }) {
  // The PDF application re-renders whenever a PdfProviders prop changes identity.
  const config = useMemo(
    () =>
      canEdit
        ? {
            initialInteractionTool: selectTool,
            initialContentPolicy: CONTENT_POLICY_PRESET.PDF_EDITOR,
          }
        : {
            initialInteractionTool: textSelectTool,
            initialContentPolicy: CONTENT_POLICY_PRESET.PDF_VIEWER,
          },
    [canEdit]
  )

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

What to know

  • Nothing to build or link. The GN arguments, the static library, the include paths, and the FPDF_LIBRARY_CONFIG fields go away: the install is the registry scope and the package.
  • No library lifetime. FPDF_InitLibraryWithConfig and FPDF_DestroyLibrary have no counterpart. PdfProviders hosts the state, and every SDK hook and component renders inside it.
  • Pick the content policy deliberately. A PDFium viewer can do only what you implemented. PdfProviders defaults to the select tool and CONTENT_POLICY_PRESET.PDF_EDITOR, where text, annotations, links, and widgets are all editable. A reader starts with PDF_VIEWER (text and annotations read-only, links and widgets interactive) and the textSelect tool, as above; the initial policy applies when the viewport first mounts.
  • No JavaScript or XFA switch. Where a PDFium build chooses pdf_enable_v8 and pdf_enable_xfa, document JavaScript and XFA forms have no equivalent; see Forms.

Load and reset a document

Before: PDFium

FPDF_LoadDocument opens a file path (UTF-8), FPDF_LoadMemDocument takes a buffer that must stay valid while the document is open, and FPDF_LoadCustomDocument reads blocks through the m_GetBlock callback of an FPDF_FILEACCESS. Pass NULL or an empty string when there is no password. A NULL result means the load failed, and FPDF_GetLastError tells why:

FPDF_DOCUMENT load_document(const void* data, int size, FPDF_BYTESTRING password) {
  // `data` must stay valid until FPDF_CloseDocument().
  FPDF_DOCUMENT doc = FPDF_LoadMemDocument(data, size, password);
  if (!doc) {
    switch (FPDF_GetLastError()) {
      case FPDF_ERR_PASSWORD:
        // Password required or incorrect: ask for it, then load again.
        break;
      case FPDF_ERR_FORMAT:
        // Not a PDF, or corrupted.
        break;
      case FPDF_ERR_SECURITY:
        // Unsupported security scheme.
        break;
    }
    return NULL;
  }
  return doc;
}

// When the document is no longer needed; after this, the buffer can be freed.
FPDF_CloseDocument(doc);

After: @sdk-pdf/react

loadPdf from useLoadPdf takes the file bytes as a Uint8Array, so the bytes your server used to pass to FPDF_LoadMemDocument now come from a fetch:

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 lastUrlRef = useRef(url)
  const [isPasswordNeeded, setIsPasswordNeeded] = useState(false)

  // A new url: unload the old document and drop its bytes, so the next open() fetches the
  // new file.
  useEffect(() => {
    if (lastUrlRef.current === url) return
    lastUrlRef.current = url
    bytesRef.current = null
    setIsPasswordNeeded(false)
    resetPdf()
  }, [url, resetPdf])

  async function open(password?: string) {
    try {
      if (!bytesRef.current) {
        const response = await fetch(url)
        // Where PDFium reports FPDF_ERR_FILE, check the response yourself.
        if (!response.ok) throw new Error(`Loading failed with status ${response.status}`)
        bytesRef.current = new Uint8Array(await response.arrayBuffer())
      }

      // The document reads from the buffer it receives: keep the original for a retry.
      await loadPdf(bytesRef.current.slice(), { password })
      setIsPasswordNeeded(false)
    } catch (error) {
      // The counterpart of FPDF_ERR_PASSWORD: ask for the password, then call
      // open(password).
      if (error instanceof InvalidPasswordError) setIsPasswordNeeded(true)
      else showError(error)
    }
  }

  if (isPasswordNeeded) return <PasswordPrompt onSubmit={open} />
  if (loadState.status === 'initial')
    return <button onClick={() => void open()}>Open</button>
  if (loadState.status === 'loading') return <p>Loading the document…</p>

  return <PdfApplication />
}

useResetPdf replaces FPDF_CloseDocument:

import { useResetPdf } from '@sdk-pdf/react'

function CloseButton() {
  const resetPdf = useResetPdf()

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

What to know

  • Bytes, not paths or readers. loadPdf takes the whole file: block readers (FPDF_LoadCustomDocument with an FPDF_FILEACCESS) and linearized loading (FPDFAvail_*) have no equivalent, and a file path becomes a request to your server, which returns the bytes it used to open with PDFium.
  • The buffer rule is stricter. FPDF_LoadMemDocument needs the buffer while the document is open. loadPdf reads from the buffer for the document's whole lifetime, and anything else touching the buffer can detach it, such as another loadPdf call. To reuse the bytes, pass a copy: loadPdf(data.slice(), { password }).
  • Passwords are a rejection. Without a password, the empty password is tried, as the PDF specification prescribes; a missing or wrong password rejects with InvalidPasswordError. Catch it, ask the user, and call loadPdf again.
  • A load state instead of a handle. loadState.status is 'initial', 'loading', or 'ready'; render the PDF UI only when it's 'ready'. There is no load progress.
  • Closing invalidates ids. After resetPdf(), page and block ids, widget snapshots, and document revisions from the old document are no longer valid. For an open-another-document action, call resetPdf() and then loadPdf.

Display the document

Before: PDFium

The viewer renders each visible page into a bitmap at the current zoom, draws the form widgets on top, and hands the pixels to its UI toolkit:

// One page at `scale`: at 1.0, one bitmap pixel per point.
FPDF_PAGE page = FPDF_LoadPage(doc, page_index);
FORM_OnAfterLoadPage(page, form);

int width = (int)(FPDF_GetPageWidth(page) * scale);
int height = (int)(FPDF_GetPageHeight(page) * scale);
FPDF_BITMAP bitmap = FPDFBitmap_Create(width, height, 0);
FPDFBitmap_FillRect(bitmap, 0, 0, width, height, 0xFFFFFFFF);

// The page content and non-interactive annotations, then the form widgets.
FPDF_RenderPageBitmap(bitmap, page, 0, 0, width, height, 0, FPDF_ANNOT);
FPDF_FFLDraw(form, bitmap, page, 0, 0, width, height, 0, FPDF_ANNOT);

// BGRx pixels with `stride` bytes per row, for your toolkit to draw.
blit_bgrx(FPDFBitmap_GetBuffer(bitmap), width, height, FPDFBitmap_GetStride(bitmap));
FPDFBitmap_Destroy(bitmap);

// The page stays loaded while it's visible; FORM_OnBeforeClosePage() and
// FPDF_ClosePage() release it.

Around this, the viewer lays out and scrolls the pages, re-renders an area when FFI_Invalidate reports a change, and zooms by rendering again at a new scale, such as window_width / FPDF_GetPageWidth(page) to fit the width. Heavy pages can render in steps with FPDF_RenderPageBitmap_Start and FPDF_RenderPage_Continue.

After: @sdk-pdf/react

Viewport renders the pages, scrolls, and zooms; your components render the rest:

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

function PdfApplication() {
  // Each document opens fitted to the viewport width.
  useViewportAutoFitScale('width')

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

The zoom you computed for each render becomes the viewport scale. useViewportScale reads and sets it, and useViewportFitScale calculates the fits on demand:

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

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

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

  function fitPage() {
    // The smaller fit shows whole pages in both directions.
    const { fitWidthScale, fitHeightScale } = getFitScale()
    setScale(Math.min(fitWidthScale, fitHeightScale))
  }

  return (
    <div role="toolbar">
      <button onClick={() => setScale(scale / 1.25)}>Zoom out</button>
      <span>{Math.round(scale * 100)}%</span>
      <button onClick={() => setScale(scale * 1.25)}>Zoom in</button>
      <button onClick={() => setScale(getFitScale().fitWidthScale)}>Fit width</button>
      <button onClick={fitPage}>Fit page</button>
    </div>
  )
}

What to know

  • Nothing to render or repaint. Bitmap sizing, FPDF_RenderPageBitmap and FPDF_FFLDraw, repainting on FFI_Invalidate, rendering in steps, and the page layout move into Viewport. Selection, text editing, form filling, link navigation, and the zoom and drag gestures work inside it, limited by the content policy.
  • Render only when ready. Viewport throws DocumentNotLoadedError when no document is loaded. It takes no props: PdfProviders configures it, the theme styles it, and hooks control it.
  • Scale 1 is 100%: one user-space unit, the same 1/72 inch as a PDFium point, per CSS pixel. setScale takes a positive finite number and throws InvalidInputError otherwise.
  • Zoom around a point. setScale(scale, pivot?) keeps the document still at pivot, a point { x, y } given as fractions of the viewport area (0 the top or left edge, 1 the bottom or right edge), the center by default. The third item of useViewportScale(), animateScale(scale, pivot?), animates the change and resolves when it ends.
  • The scale updates after a gesture. During a pinch or wheel zoom, scale keeps its value until the gesture ends; zoomGestureStart and zoomGestureEnd from useViewportEvent mark the gesture.
  • Fits are one-time calculations. fitWidthScale fits the widest page into the viewport width and fitHeightScale the tallest page into its height, both accounting for viewportPadding, at the moment of the call. useViewportAutoFitScale fits once per loaded document and doesn't re-apply on resizes or manual zooming.
  • Gestures are built in. Pinch and wheel zoom go through the same scale and are tuned with zoomGestures on PdfProviders (minScale 0.5 and maxScale 4 by default).
  • Styling is CSS you own. theme.classNames and theme.styles target the SDK's elements, theme.viewportPadding sets the gap around the pages, and CSS variables tune the selection and widget visuals, as Theming and customization describes.
  • No render options. Grayscale, LCD text, print rendering, color schemes, and the view-only rotate argument have no equivalent; rotating a page is a document edit with useSetPageRotation. Viewport shows the pages in one scrollable area, so custom layouts such as two-up spreads have no equivalent either.

Pages and navigation

Before: PDFium

Pages are 0-based indices from 0 to FPDF_GetPageCount(doc) - 1. The viewer keeps the current index, and internal links are a hit test on the page:

// Your viewer keeps the current page index and scrolls to it.
static void go_to_page(Viewer* viewer, int page_index) {
  if (page_index < 0 || page_index >= FPDF_GetPageCount(viewer->doc)) {
    return;
  }
  viewer->current_page = page_index;
  scroll_to_page(viewer, page_index);
}

// A click at a point in page coordinates, from FPDF_DeviceToPage().
static void on_click(Viewer* viewer, FPDF_PAGE page, double page_x, double page_y) {
  FPDF_LINK link = FPDFLink_GetLinkAtPoint(page, page_x, page_y);
  if (!link) {
    return;
  }
  // NULL when the link carries an action instead: see FPDFLink_GetAction().
  FPDF_DEST dest = FPDFLink_GetDest(viewer->doc, link);
  if (dest) {
    go_to_page(viewer, FPDFDest_GetDestPageIndex(viewer->doc, dest));
  }
}

After: @sdk-pdf/react

usePages returns the pages in document order, so a PDFium page index is a position in that list. useScrollToPage scrolls to a page id, and useCurrentPageId re-renders with the page in the middle of the viewport:

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

function GoToPage() {
  const pages = usePages()
  const currentPageId = useCurrentPageId()
  const scrollToPage = useScrollToPage()
  const [pageNumber, setPageNumber] = useState('')

  // -1 while there is no current page yet.
  const currentIndex = pages.findIndex((page) => page.id === currentPageId)

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault()
        // People type 1-based numbers; the list is 0-based, like PDFium page indices.
        const page = pages[Number(pageNumber) - 1]
        if (page) scrollToPage(page.id, { behavior: 'smooth' })
      }}
    >
      <input
        aria-label="Page number"
        inputMode="numeric"
        placeholder={currentIndex === -1 ? '' : String(currentIndex + 1)}
        value={pageNumber}
        onChange={(event) => setPageNumber(event.currentTarget.value)}
      />
      <span>of {pages.length}</span>
    </form>
  )
}

Converting between indices and ids

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

What to know

  • Ids survive reordering; indices don't. After FPDFPage_Delete or FPDF_MovePages, every later index points at another page. An id keeps pointing at the same page while pages are added, removed, and rearranged by the user. Ids are session-scoped: persist page numbers, not ids.
  • Ids can go stale. getPageIndex returns undefined for an unknown id, and scrollToPage throws InvalidInputError.
  • No page handles. FPDF_LoadPage, FPDF_ClosePage, and the per-page FORM_OnAfterLoadPage calls have no counterpart: the SDK loads page content when it needs it.
  • Scroll options. scrollToPage(pageId, options?) takes behavior ('auto', 'smooth', or 'instant'), vertical ('top' by default), and horizontal ('nearest' by default).
  • Links. Internal links work inside Viewport when the content policy's links level is 'interactive', so the FPDFLink_* hit test and the FFI_DoGoToAction callback go away. Bookmarks (FPDFBookmark_*), named-destination lookup (FPDF_GetNamedDestByName), and page labels (FPDF_GetPageLabel) have no equivalent.
  • Page sizes. FPDF_GetPageWidth and FPDF_GetPageHeight become the width and height of the usePages() entries: user-space units with the rotation applied, along with rotation in degrees.

Page manipulation

Before: PDFium

The editing API changes pages by index:

// Insert a blank US Letter page (612 × 792 points) at index 2.
FPDF_PAGE blank = FPDFPage_New(doc, 2, 612, 792);
FPDF_ClosePage(blank);

// Delete the page at index 4.
FPDFPage_Delete(doc, 4);

// Move the page at index 5 to the front (experimental API).
int pages_to_move[] = {5};
FPDF_MovePages(doc, pages_to_move, 1, 0);

// Set the first page's rotation to 90 degrees clockwise: values 0 to 3 are quarter turns.
FPDF_PAGE first = FPDF_LoadPage(doc, 0);
FPDFPage_SetRotation(first, 1);
FPDF_ClosePage(first);

// Append pages 1 to 3 of another document (the range is 1-based).
FPDF_ImportPages(doc, other_doc, "1-3", FPDF_GetPageCount(doc));

After: @sdk-pdf/react

Each page operation is a hook that returns a command taking page ids: usePageCreate, usePageDelete, usePageMove, useSetPageRotation, and usePageDuplicate. While you port index-based call sites, a small adapter resolves the indices against usePages:

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

// Index-based helpers for code ported from PDFium; new code passes page ids directly.
function usePageOperations() {
  const pages = usePages()
  const createPage = usePageCreate()
  const deletePages = usePageDelete()
  const { moveBefore } = usePageMove()
  const setPageRotation = useSetPageRotation()

  return {
    // FPDFPage_New(doc, index, width, height): new pages go after a page, or at the end.
    insertBlankPage(index: number, width: number, height: number) {
      const previousPage = pages[Math.min(index, pages.length) - 1]
      const pageId = createPage(width, height, previousPage?.id)
      // Without a previous page, the new page belongs at the start.
      if (!previousPage) moveBefore(pageId)
      return pageId
    },
    // FPDFPage_Delete(doc, index)
    deletePage(index: number) {
      deletePages(pages[index].id)
    },
    // FPDF_MovePages(doc, [index], 1, 0)
    moveToFront(index: number) {
      moveBefore(pages[index].id)
    },
    // FPDFPage_SetRotation(page, quarterTurns): both set an absolute rotation.
    setRotation(index: number, quarterTurns: number) {
      setPageRotation(pages[index].id, quarterTurns * 90)
    },
  }
}

What to know

OperationPDFium@sdk-pdf/react
InsertFPDFPage_New at an index, size in points, returns a page to closecreatePage(width, height, insertAfterPageId?) after a page, or appended; size in user-space units; returns the new id
DeleteFPDFPage_Delete by indexdeletePages(pageIdOrIds), one or several pages
MoveFPDF_MovePages (experimental); a failure may leave the document in an indeterminate statemoveBefore(ids, beforePageId?) (start when omitted) or moveAfter(ids, afterPageId?) (end when omitted); invalid ids throw InvalidInputError before anything moves
RotateFPDFPage_SetRotation with quarter turns 0 to 3setPageRotation with degrees 0, 90, 180, or 270; -90 and 450 are normalized
Duplicate—duplicatePage(sourcePageId, insertAfterPageId?) is asynchronous and inserts after the source by default
ExtractFPDF_ImportPages into a new documentprepareDocument({ pageIds }) saves only the listed pages, in the listed order
ImportFPDF_ImportPages from another documentNo equivalent
  • Undo steps. Deleting, moving, or rotating several pages in one call is a single undo step, and an undo can restore a deleted page with its blocks. Separate edits within 500 ms merge into one step, so the insert-then-move above undoes as one action; markUndoBoundary() from useUndoBoundary keeps an operation a separate step.
  • No page events. Page operations happen where your code calls them, so track them there; useDocumentRevision changes on any edit, undo, or redo.
  • Server-side page work stays. Importing pages from another document, N-up layouts (FPDF_ImportNPagesToOne), and page boxes (FPDFPage_SetCropBox, FPDFPage_SetMediaBox) have no equivalent: keep merging and imposition on your server with PDFium, and load the result.

Thumbnails

Before: PDFium

A thumbnail is the same render at a small size, cached by the viewer:

// A 120-pixel-wide thumbnail of the page at `page_index`.
FPDF_PAGE page = FPDF_LoadPage(doc, page_index);
double scale = 120.0 / FPDF_GetPageWidth(page);
int width = 120;
int height = (int)(FPDF_GetPageHeight(page) * scale);

FPDF_BITMAP thumbnail = FPDFBitmap_Create(width, height, 0);
FPDFBitmap_FillRect(thumbnail, 0, 0, width, height, 0xFFFFFFFF);
// Without FPDF_FFLDraw, form widgets don't appear.
FPDF_RenderPageBitmap(thumbnail, page, 0, 0, width, height, 0, FPDF_ANNOT);
FPDF_ClosePage(page);

// Your cache owns the bitmap until FPDFBitmap_Destroy().
cache_thumbnail(page_index, thumbnail);

Files that store their own thumbnails expose them through the experimental FPDFPage_GetThumbnailAsBitmap.

After: @sdk-pdf/react

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

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

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

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

What to know

  • Size with CSS, not bitmap math. The render resolution follows the canvas's displayed CSS size, or an explicit scale prop, so the pixels stay sharp without a scale computation.
  • Lazy, with no cache to manage. An off-screen canvas does no work until it becomes visible. In a virtualized list, pass isVisible to skip the per-canvas intersection observer; renderOnResize re-renders a canvas sized relative to the window.
  • Widgets included. PageCanvas draws annotations, form widgets included, unless hideAnnotations is set; where FPDF_RenderPageBitmap with FPDF_ANNOT leaves widgets to FPDF_FFLDraw, there is no second step.
  • Thumbnails that show session edits are coming soon. PageCanvas draws the page as loaded from the file; the main view shows the edits. Rotation is applied and followed live.
  • Embedded thumbnails. FPDFPage_GetThumbnailAsBitmap has no equivalent: PageCanvas always renders the page.

Forms

Before: PDFium

Forms need a form-fill environment: a FPDF_FORMFILLINFO with your viewer's callbacks, a form handle for the document, a call for each loaded page, and every mouse and keyboard event forwarded in page coordinates:

#include <string.h>

#include "public/fpdf_formfill.h"

static FPDF_FORMFILLINFO form_info;

FPDF_FORMHANDLE init_forms(FPDF_DOCUMENT doc) {
  memset(&form_info, 0, sizeof(form_info));
  form_info.version = 1;
  // Your viewer's callbacks: repaint an area, set the cursor, run timers, and look up
  // pages.
  form_info.FFI_Invalidate = on_invalidate;
  form_info.FFI_SetCursor = on_set_cursor;
  form_info.FFI_SetTimer = on_set_timer;
  form_info.FFI_KillTimer = on_kill_timer;
  form_info.FFI_GetPage = on_get_page;
  // Called when any field value changes.
  form_info.FFI_OnChange = on_change;

  // form_info must stay valid until FPDFDOC_ExitFormFillEnvironment().
  FPDF_FORMHANDLE form = FPDFDOC_InitFormFillEnvironment(doc, &form_info);
  FPDF_SetFormFieldHighlightColor(form, FPDF_FORMFIELD_UNKNOWN, 0xFFE4DD);
  return form;
}
// After FPDF_LoadPage(), and before FPDF_ClosePage():
FORM_OnAfterLoadPage(page, form);
FORM_OnBeforeClosePage(page, form);

// Your window's input, with positions converted by FPDF_DeviceToPage():
FORM_OnMouseMove(form, page, modifiers, page_x, page_y);
FORM_OnLButtonDown(form, page, modifiers, page_x, page_y);
FORM_OnLButtonUp(form, page, modifiers, page_x, page_y);
FORM_OnKeyDown(form, page, key_code, modifiers);
FORM_OnChar(form, page, char_code, modifiers);

Reading field names and values goes through the experimental annotation API: FPDFPage_GetAnnot, then FPDFAnnot_GetFormFieldName and FPDFAnnot_GetFormFieldValue with the form handle. FPDFPage_CreateAnnot doesn't create widget annotations, so adding form fields isn't part of PDFium's API.

After: @sdk-pdf/react

Users fill fields in Viewport with nothing to wire when the content policy allows interacting with widgets, such as PDF_VIEWER. For form UI outside the main view, useWidgets lists the widgets in reading order and useWidgetValue binds one widget's value like a useState backed by the document. Working with forms has the full walkthrough:

import {
  CheckboxWidget,
  TextWidget,
  useScrollToBlock,
  useWidgets,
  useWidgetValue,
  Widget,
} from '@sdk-pdf/react'

function FormFieldList() {
  const { widgets, isLoaded } = useWidgets()
  const scrollToBlock = useScrollToBlock()

  // The list is partial until every page with widgets has loaded.
  if (!isLoaded) return <p>Loading the form…</p>

  return (
    <ul>
      {widgets.map((widget) => (
        <li key={widget.id}>
          {/* Jump to the widget in the main view. */}
          <button
            onClick={() =>
              scrollToBlock(widget.id, { behavior: 'smooth', vertical: 'center' })
            }
          >
            {widget.fieldName}
          </button>
          <FieldInput widget={widget} />
        </li>
      ))}
    </ul>
  )
}

function FieldInput({ widget }: { widget: Widget }) {
  switch (widget.widgetType) {
    case 'text':
      return <TextInput widget={widget} />
    case 'checkbox':
      return <CheckboxInput widget={widget} />
    default:
      return null
  }
}

function TextInput({ widget }: { widget: TextWidget }) {
  // Typing in the main view and undo update the value here too.
  const [value, setValue] = useWidgetValue(widget)

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

function CheckboxInput({ widget }: { widget: CheckboxWidget }) {
  const [isChecked, setIsChecked] = useWidgetValue(widget)

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

From the form-fill environment to widgets

ConcernPDFium@sdk-pdf/react
SetupFPDF_FORMFILLINFO callbacks, FPDFDOC_InitFormFillEnvironment, a call per pageNothing: Viewport handles forms under a policy with interactive widgets
DrawingFPDF_FFLDraw after FPDF_RenderPageBitmapViewport draws the widgets
InputFORM_OnMouseMove, FORM_OnLButtonDown, FORM_OnChar, and moreViewport handles pointer and keyboard input
HighlightFPDF_SetFormFieldHighlightColor, FPDF_SetFormFieldHighlightAlphaThe --widget-overlay-background and --widget-focused-overlay-background CSS variables
Field valuesFPDFAnnot_GetFormFieldValue (experimental), UTF-16LE stringsuseWidgetValue(widget): string for text and choice, boolean for checkbox and radio, option indices for multiChoice
Change eventsFFI_OnChangeuseWidgetValue re-renders; widgetInput from useViewportEvent reports a value the user changed
Creating—createWidgets(props) from useWidgetsCreate, or the { type: 'addWidget', widgetProps } tool under PDF_EDITOR
ScriptsFORM_DoDocumentJSAction and IPDF_JSPLATFORM in V8 buildsNo equivalent

What to know

  • Nothing to wire. The callbacks, the timers, the cursor changes, input forwarding, and FORM_OnAfterLoadPage for each page go away, and so does the form handle. useFocusedWidgetId returns the widget being filled, where PDFium reports the focused annotation.
  • Values are read and written per widget. useWidgetValue binds one widget per hook call, and every widget with the same field name shows and synchronizes the same value. With nothing to read, the value is the kind's empty value: '', false, or [].
  • The saved PDF carries the values. prepareDocument() produces the filled PDF. Include server code that reads the fields from the saved bytes, such as with FPDFAnnot_GetFormFieldValue, in your round-trip tests.
  • Form building is new. useWidgetsCreate, the addWidget tool, useSetWidgetProps, and useBlockDelete create, restyle, and delete widgets. To lock a submitted form, call setWidgetProps(ids, { isReadOnly: true }, { excludeFromHistory: true }).
  • No scripts or XFA. FORM_DoDocumentJSAction, the page and document actions (FORM_DoPageAAction, FORM_DoDocumentAAction), and FPDF_LoadXFA have no equivalent. The SDK recognizes Acrobat date formats without running scripts and exposes them as the text widget's format (see FormatType); fields driven by other scripts read as plain text fields.
  • Signatures. signature widgets carry a read-only isCryptoSigned; the SDK never signs.

Before: PDFium

A text page gives the page's characters, and a search handle walks the matches:

#include <stdlib.h>

#include "public/fpdf_text.h"

FPDF_TEXTPAGE text_page = FPDFText_LoadPage(page);
int count = FPDFText_CountChars(text_page);

// `count` UCS-2 values plus a terminator.
unsigned short* text = malloc((count + 1) * sizeof(unsigned short));
FPDFText_GetText(text_page, 0, count, text);

// Every match on the page, with rectangles to highlight.
FPDF_SCHHANDLE search = FPDFText_FindStart(text_page, query, 0, 0);
while (FPDFText_FindNext(search)) {
  int start = FPDFText_GetSchResultIndex(search);
  int length = FPDFText_GetSchCount(search);
  int rect_count = FPDFText_CountRects(text_page, start, length);
  for (int i = 0; i < rect_count; i++) {
    double left, top, right, bottom;
    FPDFText_GetRect(text_page, i, &left, &top, &right, &bottom);
    add_highlight(page_index, left, top, right, bottom);
  }
}
FPDFText_FindClose(search);

free(text);
FPDFText_ClosePage(text_page);

After: @sdk-pdf/react

getParagraphsByPage(pageId) from useProgrammaticEditor loads a page and resolves with its paragraphs in content order, each with id, content, the 1-based pageNumber, the user-space box bounds ({ x, y, width, height }), and the paragraph layout, such as textAlign and lineHeight. A page's text is paragraphs.map((paragraph) => paragraph.content).join('\n\n'); across the whole document, getAllParagraphIds returns every paragraph id, and getPlainTextParagraphs resolves them to the same entries.

useDocumentSearch replaces the FPDFText_FindStart loop: set the query and the options, and the SDK finds the matches, highlights them on the pages, and scrolls to the active one:

import { useDocumentSearch } from '@sdk-pdf/react'

function SearchBar() {
  const {
    criteria,
    matches,
    activeMatchIndex,
    status,
    setSearchCriteria,
    goToNextMatch,
    goToPreviousMatch,
  } = useDocumentSearch()

  return (
    <div role="search">
      <input
        value={criteria.query}
        onChange={(event) =>
          setSearchCriteria({ ...criteria, query: event.currentTarget.value })
        }
      />
      <label>
        <input
          type="checkbox"
          checked={criteria.isWholeWord}
          onChange={(event) =>
            setSearchCriteria({ ...criteria, isWholeWord: event.currentTarget.checked })
          }
        />
        Whole words
      </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

  • Search options. isCaseSensitive and isWholeWord replace the FPDF_MATCHCASE and FPDF_MATCHWHOLEWORD flags. A changed query is searched once typing pauses, and status is 'searching' until the whole document is searched.
  • Matches, not rectangles. Each match carries id, pageId, paragraphId, and the matched text, in document order, and the SDK draws the highlights itself: there is no FPDFText_GetRect per match. goToMatch(match.id) activates a match from a results list.
  • Paragraphs, not characters. There are no per-character boxes (FPDFText_GetCharBox), no character index at a point (FPDFText_GetCharIndexAtPos), no font details outside the selection (FPDFText_GetFontInfo), and no web link detection (FPDFLink_LoadWebLinks). useSelectionTextStyle reads the font of the selected text, and there is no API that returns the selected text.
  • Page numbers are 1-based. pageNumber counts from 1, where PDFium page indices count from 0.
  • Batch extraction can stay on the server. Indexing documents for search doesn't need a viewer: keep it on PDFium and run it on the bytes your server stores.

Editing and interaction tools

Before: PDFium

Editing works on page objects: create them, place them with a matrix, insert them into the page, and generate the page content before saving:

// FPDF_WIDESTRING text is UTF-16LE.
static const FPDF_WCHAR kApproved[] = {'A', 'p', 'p', 'r', 'o', 'v', 'e', 'd', 0};

// A line of text in a standard font, moved to (72, 720) in page coordinates.
FPDF_PAGEOBJECT text = FPDFPageObj_NewTextObj(doc, "Helvetica", 12.0f);
FPDFText_SetText(text, kApproved);
FPDFPageObj_Transform(text, 1, 0, 0, 1, 72, 720);
FPDFPage_InsertObject(page, text);

// A JPEG image, placed and sized with a transform matrix.
FPDF_PAGEOBJECT image = FPDFPageObj_NewImageObj(doc);
FPDFImageObj_LoadJpegFileInline(NULL, 0, image, &jpeg_access);
FPDFImageObj_SetMatrix(image, 200, 0, 0, 100, 72, 500);
FPDFPage_InsertObject(page, image);

// Remove the first page object; the caller owns it afterwards (experimental API).
FPDF_PAGEOBJECT first = FPDFPage_GetObject(page, 0);
if (FPDFPage_RemoveObject(page, first)) {
  FPDFPageObj_Destroy(first);
}

// Without this, the changes are lost on save or reload.
FPDFPage_GenerateContent(page);

Any UI for placing, moving, or retyping these objects is your viewer's code.

After: @sdk-pdf/react

Programmatic text goes through paragraphs: useParagraphCreate creates an empty one, and getParagraphEditor from useProgrammaticEditor types into it:

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

// Replaces FPDFPageObj_NewTextObj, FPDFText_SetText, and FPDFPage_InsertObject.
function useAddText() {
  const createParagraph = useParagraphCreate()
  const { getParagraphEditor } = useProgrammaticEditor()

  return async (pageId: string, text: string) => {
    // The position in user-space units.
    const paragraphId = await createParagraph(pageId, 72, 720)
    const editor = getParagraphEditor(paragraphId)
    // The paragraph may be gone already: deleted by an undo.
    if (!editor) return undefined

    editor.selectAll()
    editor.deleteAll()
    editor.insertText(text)
    return paragraphId
  }
}

For users, the placement UI you built becomes the interaction mode: a content policy (what may change) and an interaction tool (what a click or drag does). useSetInteractionMode switches both at once, and useInteractionTool reads the tool back:

import {
  CONTENT_POLICY_PRESET,
  InteractionTool,
  stageImage,
  useInteractionTool,
  useSetInteractionMode,
} from '@sdk-pdf/react'

const selectTool: InteractionTool = { type: 'select' }
const addTextTool: InteractionTool = { type: 'addText' }

function InsertToolbar() {
  const interactionTool = useInteractionTool()
  const setInteractionMode = useSetInteractionMode()

  async function placeImage(file: File) {
    // Any image the browser can decode, where PDFium loads JPEG files or bitmaps.
    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">
      {/* Select, move, and resize blocks: the counterpart of your FPDFPageObj_Transform
         UI. */}
      <button
        aria-pressed={interactionTool.type === 'select'}
        onClick={() => setInteractionMode(selectTool, CONTENT_POLICY_PRESET.PDF_EDITOR)}
      >
        Select
      </button>
      <button
        aria-pressed={interactionTool.type === 'addText'}
        onClick={() => setInteractionMode(addTextTool, CONTENT_POLICY_PRESET.PDF_EDITOR)}
      >
        Add text
      </button>
      <label>
        Add image
        <input
          type="file"
          accept="image/*"
          onChange={(event) => {
            const file = event.currentTarget.files?.[0]
            if (file) placeImage(file).catch(showError)
          }}
        />
      </label>
    </div>
  )
}

From page objects to blocks

PDFium@sdk-pdf/react
FPDFPageObj_NewTextObj, FPDFPageObj_CreateTextObj, FPDFText_SetTextcreateParagraph(pageId, x, y) and getParagraphEditor, or the { type: 'addText' } tool, which needs editable text
FPDFText_SetText on an existing text objectText is editable in place under PDF_EDITOR; getParagraphEditor(paragraphId) rewrites a paragraph from code
The font and size of FPDFPageObj_NewTextObj, FPDFPageObj_SetFillColorfontFamily, fontSize, textColor, and more from useSelectionTextStyle, applied to the selection; fontFamily.options lists the available families
FPDFPageObj_NewImageObj, FPDFImageObj_SetBitmapThe { type: 'addImage', image } tool with stageImage, or useStampAnnotationCreate at given coordinates; both create a stamp annotation
FPDFPageObj_TransformThe select tool, moveSelection(direction, distance?) from useMoveSelection, useSetParagraphProps
FPDFPage_RemoveObject, FPDFPageObj_DestroydeleteBlocks(blockIds) from useBlockDelete, or useDeleteSelection
FPDFPageObj_CreateNewPath, FPDFPageObj_CreateNewRect, FPDFPath_*No equivalent
FPDFPage_CreateAnnot (experimental) for ink, FPDFAnnot_AddInkStrokeuseInkAnnotationCreate creates an ink annotation from strokes your code collects
FPDFPage_CreateAnnot for squares, highlights, notesNo equivalent; existing shapes and lines display, and useSelectionAnnotationProps changes their border color and width, ink included
FPDFPage_GenerateContentNothing to call: edits are recorded as they happen

What to know

  • Paragraphs, not text runs. A PDFium text object is one run of text at a fixed position. A paragraph wraps its text within its width, which setParagraphProps([paragraphId], { width }) from useSetParagraphProps sets along with the position and line layout, and its height follows the text.
  • Switch the policy with the tool. Pass PDF_EDITOR with the editing tools, as above, and compare the active tool by .type. textSelect needs read-only text: with editable text, the combination is unsupported and reports a message through logMessages.
  • Images become stamps. stageImage(blob) validates and measures any image the browser can decode, SVGs with an absolute root width and height included. The addImage tool places it at the next click, and createStamp({ pageId, image, x: 72, y: 500, width: 200, height: 100 }) from useStampAnnotationCreate places it like the FPDFImageObj_SetMatrix call above, with x and y the bottom-left corner in user space. Both take an optional width and height: a single dimension keeps the aspect ratio, and with none the image keeps its natural size, fit to the page.
  • Programmatic edits. They flow through the same document as user edits; Programmatic editing shows how to give your code a presence cursor for real-time collaboration, which is coming soon.
  • Selection commands. useSelection tells a paragraph in text editing ({ type: 'text', blockId }) from selected blocks ({ type: 'blocks', blockIds }), and useSelectedBlocks, useSelectBlocks, and useDuplicateSelection read and act on what the user selected, and useFocusSelection returns the focus to the text after a toolbar click.
  • Keyboard shortcuts are your code. Wire delete, arrow-key nudging, and undo with useHotkeys, with a memoized mapping.

Undo, redo, and change tracking

Before: PDFium

Undo and redo work on the focused form field, and FFI_OnChange reports field value changes. Undo for page object edits, and tracking them as unsaved changes, is your own code:

// Toolbar buttons: undo and redo inside the focused form field.
static void on_undo(Viewer* viewer) {
  if (FORM_CanUndo(viewer->form, viewer->focused_page)) {
    FORM_Undo(viewer->form, viewer->focused_page);
  }
}

static void on_redo(Viewer* viewer) {
  if (FORM_CanRedo(viewer->form, viewer->focused_page)) {
    FORM_Redo(viewer->form, viewer->focused_page);
  }
}

// FPDF_FORMFILLINFO.FFI_OnChange: some field value changed.
static void on_change(FPDF_FORMFILLINFO* info) {
  set_unsaved_changes(1);
}

After: @sdk-pdf/react

useUndoRedo returns the commands and their availability for the whole document; canUndo and canRedo re-render the component as the history changes:

import { useMemo } from 'react'
import { useHotkeys, useUndoRedo } from '@sdk-pdf/react'

function HistoryButtons() {
  const { undo, redo, canUndo, canRedo } = useUndoRedo()

  // Window-level shortcuts; the mapping must be memoized.
  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>
  )
}

The unsaved-changes flag you set in FFI_OnChange becomes a document revision: 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 indicator lives with the save button in Saving and downloading.

What to know

  • One history for the document, not for one field: deleting, moving, or rotating pages are undo steps, widget property changes are undo steps unless made with the excludeFromHistory option of useSetWidgetProps, and form values follow undo and redo.
  • Local and merged. Undo reverts the local user's changes, 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.
  • Events for user actions only. Where FFI_OnChange fed other code, put useDocumentRevision in the dependencies of a useEffect to run on any change. useViewportEvent reports what users do in the main view, such as textEditorInput, addToolApplied, blocksDragEnd, and widgetInput; page operations, undo, and redo that your code calls emit no events, so track them where you call them.

Saving and downloading

Before: PDFium

FPDF_SaveAsCopy writes the document through an FPDF_FILEWRITE whose WriteBlock receives the file in pieces; edited pages need FPDFPage_GenerateContent first:

#include <stdio.h>

#include "public/fpdf_save.h"

typedef struct {
  FPDF_FILEWRITE base;  // First member, so PDFium's pointer is also ours.
  FILE* file;
} FileWriter;

static int write_block(FPDF_FILEWRITE* self, const void* data, unsigned long size) {
  FileWriter* writer = (FileWriter*)self;
  return fwrite(data, 1, size, writer->file) == size;
}

FPDF_BOOL save_document(FPDF_DOCUMENT doc, FILE* file) {
  FileWriter writer = {{1, write_block}, file};  // FPDF_FILEWRITE version 1.
  // Flags: 0, or FPDF_INCREMENTAL or FPDF_NO_INCREMENTAL (not both),
  // optionally combined with FPDF_REMOVE_SECURITY.
  return FPDF_SaveAsCopy(doc, &writer.base, 0);
}

After: @sdk-pdf/react

useDocumentSaving applies the recorded changes to the original document and resolves with the bytes, which go back to your server:

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

function SaveControls({ documentUrl }: { documentUrl: string }) {
  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 the FFI_OnChange flag: every edit, including page edits; undoing back to the
  // saved state clears it.
  const hasUnsavedChanges = revision !== savedRevision

  async function save() {
    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()
      // Replaces FPDFPage_GenerateContent and FPDF_SaveAsCopy.
      // The bytes go to fetch, so don't hand out the loaded document's buffer.
      const bytes = await prepareDocument({ returnOriginalIfUnchanged: false })
      const response = await fetch(documentUrl, {
        method: 'PUT',
        body: bytes as BodyInit,
      })
      // A failed upload keeps the unsaved-changes indicator.
      if (!response.ok) throw new Error(`Saving failed with status ${response.status}`)

      setSavedRevision(startRevision)
    } finally {
      isSavingRef.current = false
    }
  }

  return (
    <div role="toolbar">
      <button onClick={() => save().catch(showError)}>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

  • Nothing to generate or stream. There is no FPDFPage_GenerateContent step and no write callback: prepareDocument() resolves with the complete PDF as a Uint8Array, and downloadDocument() saves the same bytes on the user's device.
  • returnOriginalIfUnchanged defaults to true, returning the buffer passed to loadPdf when nothing changed; set it to false when the bytes will be transferred or mutated, as above.
  • Page subsets. prepareDocument({ pageIds }) saves only the listed pages, in the listed order, where PDFium extracts pages with FPDF_ImportPages into a new document.
  • No save flags. Incremental saves (FPDF_INCREMENTAL), removing security (FPDF_REMOVE_SECURITY), choosing the file version (FPDF_SaveWithVersion), and flattening (FPDFPage_Flatten) have no equivalent.
  • Your server keeps its PDFium steps. The upload is a complete PDF, so work your server did after FPDF_SaveAsCopy, such as flattening, indexing, or rendering previews, can run on the uploaded bytes.

Coordinates

Both sides use PDF page space with the origin at the bottom-left corner and units of 1/72 inch, so positions such as widget boxes and paragraph bounds should carry over. Check placement on a test document before converting stored positions; page sizes come from usePages.

TopicPDFium@sdk-pdf/react
Page reference0-based page indexSession-scoped page ids; the position is the 0-based index in usePages()
OriginPage coordinates: bottom-left corner, y up; device coordinates: top left of the windowUser space with the origin at the bottom-left corner of the page
UnitsPoints, 1/72 inchUser-space units, 1/72 inch
Page sizeFPDF_GetPageWidth, FPDF_GetPageHeight, with rotation appliedwidth and height of the usePages() entries, with rotation applied
Boxesleft, top, right, bottom, such as from FPDFText_GetRectx, y, width, height, such as widget and paragraph bounds
ConversionFPDF_DeviceToPage, FPDF_PageToDeviceNo conversion helpers

Errors and logging

Before: PDFium

Calls report failure through their return values, FPDF_GetLastError explains a failed load, and an unsupported-feature handler reports content PDFium doesn't support:

#include "public/fpdf_ext.h"

static void on_unsupported(UNSUPPORT_INFO* info, int type) {
  // One of FPDF_UNSP_*, such as FPDF_UNSP_DOC_XFAFORM or FPDF_UNSP_ANNOT_3DANNOT.
  log_warning("PDFium: unsupported feature", type);
}

static UNSUPPORT_INFO unsupported_info = {1, on_unsupported};

void set_up_diagnostics(void) {
  FSDK_SetUnSpObjProcessHandler(&unsupported_info);
}

FPDF_DOCUMENT open_document(const void* data, int size) {
  FPDF_DOCUMENT doc = FPDF_LoadMemDocument(data, size, NULL);
  if (!doc) {
    // FPDF_GetLastError() explains failed loads; other calls signal failure
    // through their return value.
    report_load_error(FPDF_GetLastError());
  }
  return doc;
}

After: @sdk-pdf/react

Failures are typed errors thrown by the call that failed, and problems the SDK works around arrive through logMessages. Error handling and logging has the full rules:

import { useEffect } from 'react'
import { InvalidPasswordError, logMessages, LogLevel, useLoadPdf } from '@sdk-pdf/react'

function useSdkLogs() {
  useEffect(() => {
    // Replaces FSDK_SetUnSpObjProcessHandler; the SDK never prints to the console.
    const unsubscribe = logMessages.on('message', (logMessage) => {
      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) {
      // Replaces the switch on FPDF_GetLastError(): narrow with instanceof.
      if (error instanceof InvalidPasswordError) askForPassword()
      else showError(error)
    }
  }
}

Error classes

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

@sdk-pdf/reactThrown whenClosest PDFium situation
InvalidPasswordErrorThe document is encrypted, and the password is missing or wrong.FPDF_ERR_PASSWORD
DocumentFormatErrorThe PDF content is malformed beyond what the SDK can work around.FPDF_ERR_FORMAT
NotSupportedErrorThe document uses a PDF feature the SDK doesn't support yet.A feature the unsupported-feature handler reports, when the SDK can't work around it
InvalidInputErrorThe input passed to the API is invalid.A NULL, FALSE, or -1 return for an invalid argument
DocumentNotLoadedErrorAn operation requires a document, and none is loaded.—
UninitializedErrorAn object is used before its asynchronous initialization has completed.—
——FPDF_ERR_FILE: your own fetch fails instead

What to know

  • Nothing half-applied. Functions that modify the document validate first and throw before touching anything, where a failed FPDF_MovePages may leave the document in an indeterminate state. Functions that only read return empty values instead of throwing, and hooks never throw during render; Viewport and PageCanvas throw DocumentNotLoadedError when rendered without a document.
  • Logs are opt-in. Each message carries level (LogLevel.Debug, Info, Warning, or Error), reason (a LogReason member, such as an unsupported feature or a document format issue), a static message, data, and stack. Unsubscribe in the effect cleanup, as above.
  • Error UI is yours. Map the error classes to your own messages, as you did for the FPDF_ERR_* codes.

Cleanup and lifecycle

Before: PDFium

Every handle is closed in reverse order of creation, and the library is destroyed last. PDFium calls aren't thread-safe, so integrations also keep every call on one thread or behind a mutex:

FPDFText_ClosePage(text_page);
FPDFBitmap_Destroy(bitmap);

FORM_DoPageAAction(page, form, FPDFPAGE_AACTION_CLOSE);
FORM_OnBeforeClosePage(page, form);
FPDF_ClosePage(page);

FORM_DoDocumentAAction(form, FPDFDOC_AACTION_WC);
FPDFDOC_ExitFormFillEnvironment(form);
FPDF_CloseDocument(doc);

// Once per process, after closing everything else.
FPDF_DestroyLibrary();

After: @sdk-pdf/react

Most of this goes away:

Concern@sdk-pdf/react
LibraryNothing to initialize or destroy: PdfProviders hosts the state.
Pages, text pages, and bitmapsNothing to close: Viewport and PageCanvas render on their own, and an off-screen PageCanvas does no work.
Form handleNothing to exit: forms are part of Viewport.
DocumentresetPdf() from useResetPdf unloads the document and invalidates its ids, snapshots, and revisions.
Event listenersuseViewportEvent subscriptions follow the component lifecycle.
Log subscriptionslogMessages.on() returns an unsubscribe function.
Keyboard listenersuseHotkeys installs window-level shortcuts; pass a falsy value instead of the mapping to disable them.
BuffersDon't reuse a buffer passed to loadPdf; copy it first.

Features without an equivalent

Plan around these before you migrate:

PDFium featureIn @sdk-pdf/react
Rendering pages to images outside a browser, such as server-side previewsKeep PDFium on the server; PageCanvas renders in the browser
Per-character data (FPDFText_GetCharBox, FPDFText_GetCharIndexAtPos, FPDFText_GetFontInfo) and match rectangles (FPDFText_GetRect)No equivalent; getParagraphsByPage returns paragraph text and bounds, useDocumentSearch highlights matches itself, and useSelectionTextStyle reads the font of the selected text
Web link detection (FPDFLink_LoadWebLinks)No equivalent
Device-to-page conversion (FPDF_DeviceToPage, FPDF_PageToDevice)No equivalent
Render options: grayscale, LCD text, color schemes, view-only rotationNo equivalent
Printing (FPDF_PRINTING, FPDF_RenderPage on Windows)No print API; PageCanvas can render pages for your own print layout, drawn as loaded from the file without the session's edits
Path objects (FPDFPageObj_CreateNewPath, FPDFPath_*) and loading fonts (FPDFText_LoadFont)No equivalent
Creating markup annotations other than ink (FPDFPage_CreateAnnot)No equivalent; existing shapes and lines display, and their border color and width can be changed; useInkAnnotationCreate creates ink
Importing pages from another document, N-up, and page boxes (FPDF_ImportPages, FPDF_ImportNPagesToOne, FPDFPage_SetCropBox)No equivalent; merge on your server with PDFium and load the result
Flattening (FPDFPage_Flatten)No equivalent
Incremental saves, removing security, and choosing the file versionNo equivalent
Block readers and linearized loading (FPDF_LoadCustomDocument, FPDFAvail_*)Fetch the whole file; no load progress
JavaScript (FORM_DoDocumentJSAction, V8 builds) and XFA (FPDF_LoadXFA)No equivalent
Bookmarks, named-destination lookup, page labels, and metadata (FPDFBookmark_*, FPDF_GetNamedDestByName, FPDF_GetPageLabel, FPDF_GetMetaText)No equivalent
Attachments (FPDFDoc_GetAttachment), signature inspection (FPDF_GetSignatureCount)No equivalent; signature widgets carry a read-only isCryptoSigned, and the SDK never signs
Tagged structure (FPDF_StructTree_*)No equivalent
Embedded thumbnails (FPDFPage_GetThumbnailAsBitmap)PageCanvas renders the page

On this page