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/reactis in beta, and its exports and APIs may change between releases. Pin the exact package version and review the changes when you update.
Migration checklist
- Read the overview: how the two integrations differ, and the API map for the
FPDF_*,FORM_*, andFPDFText_*calls you use. - Decide what moves to the browser (viewing, form filling, editing) and what stays with PDFium on your server, such as batch rendering, merging, and flattening.
- Install the package and replace library setup with
PdfProviders, with a viewer content policy if you ship a reader. - Load and close documents with
loadPdfandresetPdf, including the password flow. - Replace the render loop and zoom with
Viewport. - Move page navigation from indices to page ids.
- Move page manipulation to the page hooks.
- Rebuild thumbnails with
PageCanvas. - Replace the form-fill environment with widgets.
- Move text extraction and search.
- Replace page object editing with interaction tools and hooks.
- Replace
FORM_UndoandFFI_OnChangetracking. - Replace
FPDF_SaveAsCopywithprepareDocument. - Check stored positions against coordinates.
- Move error handling and logging and drop handle cleanup.
- Plan for features without an equivalent.
- Remove PDFium from the viewer: the build setup, the linked library, the bitmap and input plumbing, and the form-fill callbacks. Test encrypted, malformed, large, and form-heavy PDFs, page operations, and the save round-trip, including any server code that reads the saved bytes with PDFium.
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 inpublic/:fpdfview.h,fpdf_formfill.h,fpdf_text.h,fpdf_edit.h,fpdf_save.h,fpdf_ppo.h, and more. FPDF_InitLibraryWithConfigsets up process-wide state before any other call, andFPDF_DestroyLibraryreleases it.- Everything is an opaque handle you open and close yourself:
FPDF_DOCUMENT,FPDF_PAGE,FPDF_BITMAP,FPDF_TEXTPAGE, andFPDF_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_GenerateContentwrites them into the page, andFPDF_SaveAsCopystreams the file through your write callback.
@sdk-pdf/react runs in the browser; the SDK owns the document, and you own the UI:
Viewportrenders the pages and handles scrolling, zooming, selection, text editing, form filling, and link navigation on its own.PageCanvasrenders single pages for thumbnails and previews.PdfProvidershosts 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;
useDocumentSavingbuilds 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.
| Concern | PDFium | @sdk-pdf/react |
|---|---|---|
| Where it runs | Native code: a desktop app or a server | The browser, in your React app |
| Setup | Build from source, link, FPDF_InitLibraryWithConfig | The package from a private registry and PdfProviders |
| UI | None: you draw bitmaps and forward input | Viewport and PageCanvas; you build toolbars and panels |
| Resources | Handles you open and close in order | Owned by the SDK; resetPdf() unloads the document |
| Pages | 0-based indices | Session-scoped page ids |
| Forms | A form-fill environment, your callbacks, and your input forwarding | Built into Viewport; widgets and useWidgetValue for your own form UI |
| Editing | Page objects (FPDFPageObj_*) and FPDFPage_GenerateContent | Interaction tools, paragraph and widget hooks, and page hooks |
| Undo | The focused form field (FORM_Undo) | The whole document (useUndoRedo) |
| Saving | FPDF_SaveAsCopy with a write callback | prepareDocument() resolves with the bytes |
| Errors | NULL or FALSE returns; FPDF_GetLastError after a failed load | Typed errors thrown before any change; logMessages |
What this means for your code:
- 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. - Delete the render loop and input plumbing. Bitmap allocation,
FPDF_RenderPageBitmapfollowed byFPDF_FFLDraw, page layout and scrolling, zooming by re-rendering,FPDF_DeviceToPage, and theFPDF_FORMFILLINFOcallbacks move intoViewport. - Delete handle bookkeeping. There is no library to initialize, and no page, text page, bitmap, or form handle to close.
- Replace indices with ids. Every page index in your state becomes a page id; convert at the edges with
usePagesandusePageIndexGetter. - Rethink editing. Page objects become paragraphs, widgets, and images, edited through interaction tools and hooks;
FPDFPage_GenerateContentandFPDF_SaveAsCopybecomeprepareDocument(). - Choose the policy. A PDFium viewer allows exactly what you implemented;
PdfProvidersdefaults toPDF_EDITOR, so a reader needsPDF_VIEWERorPDF_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/react | Notes |
|---|---|---|
FPDF_InitLibraryWithConfig, FPDF_DestroyLibrary | <PdfProviders> | Nothing to initialize or destroy. |
FPDF_LoadMemDocument(data, size, password) | loadPdf(data, { password }) from useLoadPdf | Both 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 equivalent | Fetch the whole file. |
FPDF_GetLastError() returning FPDF_ERR_PASSWORD | InvalidPasswordError | See Errors and logging. |
FPDF_ERR_FORMAT | DocumentFormatError | |
FPDF_CloseDocument | resetPdf() from useResetPdf | Returns the load state to 'initial'. |
FPDF_GetPageCount | usePageCount | 0 while no document is loaded. |
FPDF_LoadPage(doc, index), FPDF_ClosePage | usePages()[index].id | No page handles. |
FPDF_GetPageWidth, FPDF_GetPageHeight | width and height of the usePages entries | Both with rotation applied. |
FPDFBitmap_Create, FPDF_RenderPageBitmap, FPDF_FFLDraw | Viewport | No bitmaps to allocate or draw. |
FPDF_RenderPageBitmap_Start, FPDF_RenderPage_Continue | Nothing to call | Viewport renders on its own. |
| The render size you compute to zoom | useViewportScale | Scale 1 is one user-space unit per CSS pixel. |
| A fit scale you compute from the page and window sizes | useViewportFitScale, useViewportAutoFitScale | One-time fits. |
The rotate argument of FPDF_RenderPageBitmap, FPDF_GRAYSCALE, FPDF_COLORSCHEME | No equivalent | useSetPageRotation edits the document. |
| Thumbnails rendered as small bitmaps | PageCanvas | Renders lazily, sized with CSS. |
FPDFPage_GetThumbnailAsBitmap | No equivalent | PageCanvas renders the page instead. |
FPDF_DeviceToPage, FPDF_PageToDevice | No equivalent | See Coordinates. |
| The current page index your viewer tracks | useCurrentPageId | The page in the middle of the viewport. |
| Scrolling your viewer to a page index | scrollToPage(pageId) from useScrollToPage | |
FPDFLink_GetLinkAtPoint, FPDFDest_GetDestPageIndex, FFI_DoGoToAction | Nothing to call | Internal links work in Viewport when links is 'interactive'. |
FPDFBookmark_*, FPDF_GetPageLabel | No equivalent | |
FPDFPage_New(doc, index, width, height) | createPage(width, height, insertAfterPageId?) from usePageCreate | Inserts after a page id. |
FPDFPage_Delete(doc, index) | deletePages(pageIdOrIds) from usePageDelete | |
FPDF_MovePages | moveBefore or moveAfter from usePageMove | |
FPDFPage_SetRotation(page, quarterTurns) | setPageRotation(pageIdOrIds, degrees) from useSetPageRotation | Both set an absolute rotation. |
| — | duplicatePage(sourcePageId, insertAfterPageId?) from usePageDuplicate | |
FPDF_ImportPages into a new document, to extract pages | prepareDocument({ pageIds }) | Saves only the listed pages, in the listed order. |
FPDF_ImportPages from another document, FPDF_ImportNPagesToOne | No equivalent | |
FPDFDOC_InitFormFillEnvironment, FORM_OnAfterLoadPage, FORM_OnLButtonDown, FORM_OnChar | Viewport under a content policy with interactive widgets | See Forms. |
FFI_Invalidate, FFI_SetCursor, FFI_SetTimer | Nothing to implement | |
FFI_OnChange | widgetInput from useViewportEvent | Reports a value the user changed in the main view. |
FPDFAnnot_GetFormFieldName, FPDFAnnot_GetFormFieldValue | useWidgets, useWidgetValue | Widgets, not annotations. |
FPDF_SetFormFieldHighlightColor | The --widget-overlay-background CSS variable | See Theming and customization. |
| — | createWidgets(props) from useWidgetsCreate, the addWidget tool | FPDFPage_CreateAnnot doesn't create widgets. |
FORM_DoDocumentJSAction, FPDF_LoadXFA | No equivalent | |
FPDFText_LoadPage, FPDFText_GetText | getParagraphsByPage(pageId) from useProgrammaticEditor | Paragraphs, not characters. |
FPDFText_GetRect | The paragraph's bounds | Bounds per paragraph, not per text segment. |
FPDFText_GetCharBox, FPDFText_GetCharIndexAtPos | No equivalent | |
FPDFText_FindStart, FPDFText_FindNext | useDocumentSearch | Highlights the matches and scrolls to the active one. |
FPDFPageObj_NewTextObj, FPDFText_SetText, FPDFPage_InsertObject | useParagraphCreate and getParagraphEditor, or the addText tool | See Editing and interaction tools. |
FPDFPageObj_NewImageObj, FPDFImageObj_LoadJpegFileInline | The addImage tool or useStampAnnotationCreate, with stageImage | Places a stamp annotation. |
FPDFPageObj_Transform | The select tool, useMoveSelection, useSetParagraphProps | |
FPDFPage_RemoveObject | useBlockDelete, useDeleteSelection | Blocks, not page objects. |
FPDFPage_CreateAnnot with FPDF_ANNOT_INK, FPDFAnnot_AddInkStroke | useInkAnnotationCreate | Ink from strokes your code collects. |
FPDFPageObj_CreateNewPath, FPDFPage_CreateAnnot for other subtypes | No equivalent | |
FPDFPage_GenerateContent | Nothing to call | prepareDocument() applies the edits. |
FORM_Undo, FORM_Redo, FORM_CanUndo, FORM_CanRedo | useUndoRedo | The whole document, not the focused field. |
An unsaved-changes flag set in FFI_OnChange | useDocumentRevision, useDocumentRevisionGetter | Compare revisions to tell whether there are unsaved changes. |
FPDF_SaveAsCopy with FPDF_FILEWRITE | prepareDocument() from useDocumentSaving | Resolves with a Uint8Array. |
FPDF_INCREMENTAL, FPDF_REMOVE_SECURITY, FPDF_SaveWithVersion | No equivalent | |
FPDFPage_Flatten | No equivalent | |
FSDK_SetUnSpObjProcessHandler | logMessages | Problems the SDK works around arrive as log messages. |
FPDFDOC_ExitFormFillEnvironment, FPDFText_ClosePage, FPDFBitmap_Destroy | Nothing to call | See 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/reactSet up the project as Getting started describes: import the stylesheet, render the SDK on the client only, alias canvas in the bundler, and turn off React Strict Mode.
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_CONFIGfields go away: the install is the registry scope and the package. - No library lifetime.
FPDF_InitLibraryWithConfigandFPDF_DestroyLibraryhave no counterpart.PdfProvidershosts 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.
PdfProvidersdefaults to theselecttool andCONTENT_POLICY_PRESET.PDF_EDITOR, where text, annotations, links, and widgets are all editable. A reader starts withPDF_VIEWER(text and annotations read-only, links and widgets interactive) and thetextSelecttool, as above; the initial policy applies when the viewport first mounts. - No JavaScript or XFA switch. Where a PDFium build chooses
pdf_enable_v8andpdf_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.
loadPdftakes the whole file: block readers (FPDF_LoadCustomDocumentwith anFPDF_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_LoadMemDocumentneeds the buffer while the document is open.loadPdfreads from the buffer for the document's whole lifetime, and anything else touching the buffer can detach it, such as anotherloadPdfcall. 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 callloadPdfagain. - A load state instead of a handle.
loadState.statusis'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, callresetPdf()and thenloadPdf.
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_RenderPageBitmapandFPDF_FFLDraw, repainting onFFI_Invalidate, rendering in steps, and the page layout move intoViewport. 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.
ViewportthrowsDocumentNotLoadedErrorwhen no document is loaded. It takes no props:PdfProvidersconfigures 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.
setScaletakes a positive finite number and throwsInvalidInputErrorotherwise. - Zoom around a point.
setScale(scale, pivot?)keeps the document still atpivot, a point{ x, y }given as fractions of the viewport area (0the top or left edge,1the bottom or right edge), the center by default. The third item ofuseViewportScale(),animateScale(scale, pivot?), animates the change and resolves when it ends. - The scale updates after a gesture. During a pinch or wheel zoom,
scalekeeps its value until the gesture ends;zoomGestureStartandzoomGestureEndfromuseViewportEventmark the gesture. - Fits are one-time calculations.
fitWidthScalefits the widest page into the viewport width andfitHeightScalethe tallest page into its height, both accounting forviewportPadding, at the moment of the call.useViewportAutoFitScalefits 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
zoomGesturesonPdfProviders(minScale0.5andmaxScale4by default). - Styling is CSS you own.
theme.classNamesandtheme.stylestarget the SDK's elements,theme.viewportPaddingsets 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
rotateargument have no equivalent; rotating a page is a document edit withuseSetPageRotation.Viewportshows 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 have | You need | Use |
|---|---|---|
A 0-based PDFium page index i | A page id | usePages()[i].id |
| A page id | Its index | getPageIndex(pageId) from usePageIndexGetter: a 0-based index or undefined |
| A page id, rendered | Its index | usePageIndexGetter paired with useRenderOnPageOrderChange |
| Only the count | usePageCount, the counterpart of FPDF_GetPageCount |
What to know
- Ids survive reordering; indices don't. After
FPDFPage_DeleteorFPDF_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.
getPageIndexreturnsundefinedfor an unknown id, andscrollToPagethrowsInvalidInputError. - No page handles.
FPDF_LoadPage,FPDF_ClosePage, and the per-pageFORM_OnAfterLoadPagecalls have no counterpart: the SDK loads page content when it needs it. - Scroll options.
scrollToPage(pageId, options?)takesbehavior('auto','smooth', or'instant'),vertical('top'by default), andhorizontal('nearest'by default). - Links. Internal links work inside
Viewportwhen the content policy'slinkslevel is'interactive', so theFPDFLink_*hit test and theFFI_DoGoToActioncallback go away. Bookmarks (FPDFBookmark_*), named-destination lookup (FPDF_GetNamedDestByName), and page labels (FPDF_GetPageLabel) have no equivalent. - Page sizes.
FPDF_GetPageWidthandFPDF_GetPageHeightbecome thewidthandheightof theusePages()entries: user-space units with the rotation applied, along withrotationin 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
| Operation | PDFium | @sdk-pdf/react |
|---|---|---|
| Insert | FPDFPage_New at an index, size in points, returns a page to close | createPage(width, height, insertAfterPageId?) after a page, or appended; size in user-space units; returns the new id |
| Delete | FPDFPage_Delete by index | deletePages(pageIdOrIds), one or several pages |
| Move | FPDF_MovePages (experimental); a failure may leave the document in an indeterminate state | moveBefore(ids, beforePageId?) (start when omitted) or moveAfter(ids, afterPageId?) (end when omitted); invalid ids throw InvalidInputError before anything moves |
| Rotate | FPDFPage_SetRotation with quarter turns 0 to 3 | setPageRotation 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 |
| Extract | FPDF_ImportPages into a new document | prepareDocument({ pageIds }) saves only the listed pages, in the listed order |
| Import | FPDF_ImportPages from another document | No 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()fromuseUndoBoundarykeeps an operation a separate step. - No page events. Page operations happen where your code calls them, so track them there;
useDocumentRevisionchanges 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
scaleprop, 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
isVisibleto skip the per-canvas intersection observer;renderOnResizere-renders a canvas sized relative to the window. - Widgets included.
PageCanvasdraws annotations, form widgets included, unlesshideAnnotationsis set; whereFPDF_RenderPageBitmapwithFPDF_ANNOTleaves widgets toFPDF_FFLDraw, there is no second step. - Thumbnails that show session edits are coming soon.
PageCanvasdraws the page as loaded from the file; the main view shows the edits. Rotation is applied and followed live. - Embedded thumbnails.
FPDFPage_GetThumbnailAsBitmaphas no equivalent:PageCanvasalways 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
| Concern | PDFium | @sdk-pdf/react |
|---|---|---|
| Setup | FPDF_FORMFILLINFO callbacks, FPDFDOC_InitFormFillEnvironment, a call per page | Nothing: Viewport handles forms under a policy with interactive widgets |
| Drawing | FPDF_FFLDraw after FPDF_RenderPageBitmap | Viewport draws the widgets |
| Input | FORM_OnMouseMove, FORM_OnLButtonDown, FORM_OnChar, and more | Viewport handles pointer and keyboard input |
| Highlight | FPDF_SetFormFieldHighlightColor, FPDF_SetFormFieldHighlightAlpha | The --widget-overlay-background and --widget-focused-overlay-background CSS variables |
| Field values | FPDFAnnot_GetFormFieldValue (experimental), UTF-16LE strings | useWidgetValue(widget): string for text and choice, boolean for checkbox and radio, option indices for multiChoice |
| Change events | FFI_OnChange | useWidgetValue 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 |
| Scripts | FORM_DoDocumentJSAction and IPDF_JSPLATFORM in V8 builds | No equivalent |
What to know
- Nothing to wire. The callbacks, the timers, the cursor changes, input forwarding, and
FORM_OnAfterLoadPagefor each page go away, and so does the form handle.useFocusedWidgetIdreturns the widget being filled, where PDFium reports the focused annotation. - Values are read and written per widget.
useWidgetValuebinds 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 withFPDFAnnot_GetFormFieldValue, in your round-trip tests. - Form building is new.
useWidgetsCreate, theaddWidgettool,useSetWidgetProps, anduseBlockDeletecreate, restyle, and delete widgets. To lock a submitted form, callsetWidgetProps(ids, { isReadOnly: true }, { excludeFromHistory: true }). - No scripts or XFA.
FORM_DoDocumentJSAction, the page and document actions (FORM_DoPageAAction,FORM_DoDocumentAAction), andFPDF_LoadXFAhave no equivalent. The SDK recognizes Acrobat date formats without running scripts and exposes them as the text widget'sformat(seeFormatType); fields driven by other scripts read as plain text fields. - Signatures.
signaturewidgets carry a read-onlyisCryptoSigned; the SDK never signs.
Text extraction and search
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.
isCaseSensitiveandisWholeWordreplace theFPDF_MATCHCASEandFPDF_MATCHWHOLEWORDflags. A changed query is searched once typing pauses, andstatusis'searching'until the whole document is searched. - Matches, not rectangles. Each match carries
id,pageId,paragraphId, and the matchedtext, in document order, and the SDK draws the highlights itself: there is noFPDFText_GetRectper 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).useSelectionTextStylereads the font of the selected text, and there is no API that returns the selected text. - Page numbers are 1-based.
pageNumbercounts 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_SetText | createParagraph(pageId, x, y) and getParagraphEditor, or the { type: 'addText' } tool, which needs editable text |
FPDFText_SetText on an existing text object | Text is editable in place under PDF_EDITOR; getParagraphEditor(paragraphId) rewrites a paragraph from code |
The font and size of FPDFPageObj_NewTextObj, FPDFPageObj_SetFillColor | fontFamily, fontSize, textColor, and more from useSelectionTextStyle, applied to the selection; fontFamily.options lists the available families |
FPDFPageObj_NewImageObj, FPDFImageObj_SetBitmap | The { type: 'addImage', image } tool with stageImage, or useStampAnnotationCreate at given coordinates; both create a stamp annotation |
FPDFPageObj_Transform | The select tool, moveSelection(direction, distance?) from useMoveSelection, useSetParagraphProps |
FPDFPage_RemoveObject, FPDFPageObj_Destroy | deleteBlocks(blockIds) from useBlockDelete, or useDeleteSelection |
FPDFPageObj_CreateNewPath, FPDFPageObj_CreateNewRect, FPDFPath_* | No equivalent |
FPDFPage_CreateAnnot (experimental) for ink, FPDFAnnot_AddInkStroke | useInkAnnotationCreate creates an ink annotation from strokes your code collects |
FPDFPage_CreateAnnot for squares, highlights, notes | No equivalent; existing shapes and lines display, and useSelectionAnnotationProps changes their border color and width, ink included |
FPDFPage_GenerateContent | Nothing 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 })fromuseSetParagraphPropssets along with the position and line layout, and its height follows the text. - Switch the policy with the tool. Pass
PDF_EDITORwith the editing tools, as above, and compare the active tool by.type.textSelectneeds read-only text: with editable text, the combination is unsupported and reports a message throughlogMessages. - Images become stamps.
stageImage(blob)validates and measures any image the browser can decode, SVGs with an absolute root width and height included. TheaddImagetool places it at the next click, andcreateStamp({ pageId, image, x: 72, y: 500, width: 200, height: 100 })fromuseStampAnnotationCreateplaces it like theFPDFImageObj_SetMatrixcall above, withxandythe bottom-left corner in user space. Both take an optionalwidthandheight: 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.
useSelectiontells a paragraph in text editing ({ type: 'text', blockId }) from selected blocks ({ type: 'blocks', blockIds }), anduseSelectedBlocks,useSelectBlocks, anduseDuplicateSelectionread and act on what the user selected, anduseFocusSelectionreturns 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
excludeFromHistoryoption ofuseSetWidgetProps, 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()fromuseUndoBoundarybefore it starts and after its edits finish. - Events for user actions only. Where
FFI_OnChangefed other code, putuseDocumentRevisionin the dependencies of auseEffectto run on any change.useViewportEventreports what users do in the main view, such astextEditorInput,addToolApplied,blocksDragEnd, andwidgetInput; page operations, undo, and redo that your code calls emit no events, 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_GenerateContentstep and no write callback:prepareDocument()resolves with the complete PDF as aUint8Array, anddownloadDocument()saves the same bytes on the user's device. - returnOriginalIfUnchanged defaults to
true, returning the buffer passed toloadPdfwhen nothing changed; set it tofalsewhen 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 withFPDF_ImportPagesinto 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.
| Topic | PDFium | @sdk-pdf/react |
|---|---|---|
| Page reference | 0-based page index | Session-scoped page ids; the position is the 0-based index in usePages() |
| Origin | Page coordinates: bottom-left corner, y up; device coordinates: top left of the window | User space with the origin at the bottom-left corner of the page |
| Units | Points, 1/72 inch | User-space units, 1/72 inch |
| Page size | FPDF_GetPageWidth, FPDF_GetPageHeight, with rotation applied | width and height of the usePages() entries, with rotation applied |
| Boxes | left, top, right, bottom, such as from FPDFText_GetRect | x, y, width, height, such as widget and paragraph bounds |
| Conversion | FPDF_DeviceToPage, FPDF_PageToDevice | No 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/react | Thrown when | Closest PDFium situation |
|---|---|---|
InvalidPasswordError | The document is encrypted, and the password is missing or wrong. | FPDF_ERR_PASSWORD |
DocumentFormatError | The PDF content is malformed beyond what the SDK can work around. | FPDF_ERR_FORMAT |
NotSupportedError | The 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 |
InvalidInputError | The input passed to the API is invalid. | A NULL, FALSE, or -1 return for an invalid argument |
DocumentNotLoadedError | An operation requires a document, and none is loaded. | — |
UninitializedError | An 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_MovePagesmay leave the document in an indeterminate state. Functions that only read return empty values instead of throwing, and hooks never throw during render;ViewportandPageCanvasthrowDocumentNotLoadedErrorwhen rendered without a document. - Logs are opt-in. Each message carries
level(LogLevel.Debug,Info,Warning, orError),reason(aLogReasonmember, such as an unsupported feature or a document format issue), a staticmessage,data, andstack. 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 |
|---|---|
| Library | Nothing to initialize or destroy: PdfProviders hosts the state. |
| Pages, text pages, and bitmaps | Nothing to close: Viewport and PageCanvas render on their own, and an off-screen PageCanvas does no work. |
| Form handle | Nothing to exit: forms are part of Viewport. |
| Document | resetPdf() from useResetPdf unloads the document and invalidates its ids, snapshots, and revisions. |
| Event listeners | useViewportEvent subscriptions follow the component lifecycle. |
| Log subscriptions | logMessages.on() returns an unsubscribe function. |
| Keyboard listeners | useHotkeys installs window-level shortcuts; pass a falsy value instead of the mapping to disable them. |
| Buffers | Don't reuse a buffer passed to loadPdf; copy it first. |
Features without an equivalent
Plan around these before you migrate:
| PDFium feature | In @sdk-pdf/react |
|---|---|
| Rendering pages to images outside a browser, such as server-side previews | Keep 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 rotation | No 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 version | No 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 |