Migrating from Apryse WebViewer
This guide moves a web application built on Apryse WebViewer to @sdk-pdf/react. WebViewer ships a complete viewer UI on top of its Core APIs; @sdk-pdf/react ships the document engine and the page view and leaves the rest of the UI to you. Most of the migration is rebuilding the WebViewer UI you rely on as React components, then moving your instance.Core calls to hooks. Work through the checklist below; each step links to the section that covers it.
Beta:
@sdk-pdf/reactis in beta, and its exports and APIs may change between releases. Pin the exact package version and review the changes when you update.
Migration checklist
- Read the overview: how the two integrations differ, and the API map for every
instance.UIandinstance.Corecall you use. - List the WebViewer UI your users rely on (toolbar groups, panels, popups, dialogs): each becomes a React component.
- Install the package and replace the
WebViewer(...)mount withPdfProviders. - Load and close documents with
loadPdfandresetPdf, including the password flow. - Lay out
Viewportwith your own toolbar and sidebars. - Move page navigation from numbers to page ids.
- Move page manipulation to the page hooks.
- Move zoom and fit modes.
- Rebuild the thumbnails panel with
PageCanvas. - Move form fields from
FieldManagerto widgets. - Map tool modes, content editing, and read-only mode.
- Replace
AnnotationHistoryManagerandannotationChangedtracking. - Replace XFDF export and
getFileDatawithprepareDocument. - Check collaboration and convert stored positions using coordinates.
- Rebuild the search panel with
useDocumentSearch. - Move error handling and logging and cleanup.
- Plan for features without an equivalent.
- Remove
@pdftron/webviewer, the copied static assets, the license key, and WebViewer Server settings. Test encrypted, malformed, large, and form-heavy PDFs, page operations, and the save round-trip.
Overview
How the two integrations differ
WebViewer is a packaged application:
WebViewer(options, viewerElement)embeds a viewer in the page and resolves with an instance.instance.UIdrives the prebuilt interface;instance.CoreholdsdocumentViewer,annotationManager,Tools, andAnnotations.- Since version 11 the UI runs in a Web Component by default (an iframe is opt-in). You customize it from outside: hide elements with
UI.disableElements, switch features withUI.enableFeaturesandUI.disableFeatures, restructure it with the Modular UI, and theme it withUI.setTheme. - WebViewer serves static assets (WebAssembly modules, HTML, CSS) that you copy into your app, and runs in demo mode without a
licenseKey. Some features are licensed separately, such as Page Manipulation and the content editing add-on. - Form widgets, stamps, and markup are annotation objects managed by
annotationManager, exported as XFDF, and merged into the PDF on save withdoc.getFileData({ xfdfString }). Collaboration is XFDF that your own server syncs between clients. - Pages are 1-based numbers, and annotation positions are viewer coordinates with the origin at the top left of the page.
@sdk-pdf/react is a library: the SDK owns the document, and you own the UI.
- There are no prebuilt toolbars, panels, or dialogs.
Viewportis the main view andPageCanvasrenders single pages; everything around them is your React code driven by hooks. PdfProvidershosts the state and configuration inside your React tree. The SDK renders unstyled, and your design system supplies the look through thethemeconfiguration.- Edits don't rewrite the PDF file. Each change is an operation in a CRDT, which powers the history;
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 | WebViewer | @sdk-pdf/react |
|---|---|---|
| UI | Prebuilt, customized from outside (disableElements, Modular UI) | Viewport and PageCanvas only; you build toolbars, panels, and dialogs |
| Mounting | WebViewer(options, element) in a useEffect, Web Component | PdfProviders and components in your React tree |
| Deployment | Static assets under path, licenseKey, optional WebViewer Server | The package from a private registry |
| State access | instance.Core managers and their events | State hooks that re-render, command hooks, and event hooks |
| Pages | 1-based numbers | Session-scoped page ids |
| Forms | Fields by full name (FieldManager), widgets as annotations | Widgets with a fieldName, values bound with useWidgetValue |
| Editing text | Content editing add-on | Built in under the PDF_EDITOR content policy |
| Persistence | XFDF for annotations, getFileData for the PDF | prepareDocument() for the PDF |
| File types | PDF, Office, and more |
What this means for your code:
- Build the UI. Every WebViewer UI element your users rely on becomes a React component built from the hooks in the API reference.
- Drop the mount effect. The
WebViewer(...)promise, the instance reference, and any context that shares it are replaced byPdfProvidersand hooks. - Replace managers with hooks.
documentViewer,annotationManager,FieldManager, andAnnotationHistoryManagercalls become hook calls in the components that need them. - Replace XFDF persistence. Storing annotations or field data as XFDF becomes storing the PDF produced by
prepareDocument(). - Replace numbers and viewer coordinates with page ids and user-space coordinates; check stored positions after converting them.
- Map the read-only switches to content policies, and decide whether to expose editing of existing text, which is built in.
- Remove deployment pieces: static assets under
path, the license key, and WebViewer Server settings.
API map
Rows marked No equivalent are covered in Features without an equivalent.
| WebViewer | @sdk-pdf/react | Notes |
|---|---|---|
WebViewer({ path, licenseKey }, element) | <PdfProviders> | No asset path or license key. |
initialDoc, UI.loadDocument(url | File | Blob, options) | loadPdf(Uint8Array, { password }) from useLoadPdf | Read the file or fetch the URL into bytes first. |
loadDocument option password | loadPdf(data, { password }), catch InvalidPasswordError | |
loadDocument options onLoadingProgress, customHeaders, withCredentials | No equivalent | Headers and credentials go on your own fetch. |
documentLoaded event | loadState.status === 'ready' | |
UI.closeDocument(), documentViewer.closeDocument() | resetPdf() from useResetPdf | |
documentViewer.getPageCount() | usePageCount | |
documentViewer.getCurrentPage(), pageNumberUpdated | useCurrentPageId | Re-renders with the id of the page in the middle of the viewport. |
documentViewer.setCurrentPage(pageNumber, isSmoothScroll) | scrollToPage(pageId, { behavior: 'smooth' }) from useScrollToPage | |
documentViewer.displayPageLocation(...) | No equivalent | scrollToBlock scrolls to a block, not to coordinates. |
doc.insertBlankPages(insertBeforeThesePages, width, height) | createPage(width, height, insertAfterPageId?) from usePageCreate | Inserts after, not before. |
doc.removePages(pageArray) | deletePages(pageIdOrIds) from usePageDelete | |
doc.movePages(pageArray, insertBeforeThisPage) | moveBefore or moveAfter from usePageMove | |
doc.rotatePages(pageArray, rotation) | setPageRotation(pageIdOrIds, rotation) from useSetPageRotation | Absolute degrees, not an added rotation. |
| — | duplicatePage(sourcePageId, insertAfterPageId?) from usePageDuplicate | |
pagesUpdated event | usePages | Re-renders with the pages in their current order; page commands emit no events. |
documentViewer.zoomTo(), UI.setZoomLevel(), getZoomLevel() | useViewportScale | |
UI.setFitMode(UI.FitMode.FitWidth) | useViewportAutoFitScale, useViewportFitScale | A one-time fit, not a fixed mode. |
UI.FitMode.FitPage | Math.min(fitWidthScale, fitHeightScale) | See Zoom and fit modes. |
zoomUpdated event | scale from useViewportScale in a useEffect | zoomGestureEnd from useViewportEvent reports pinch and wheel zoom. |
UI.setLayoutMode(UI.LayoutMode.Single) and other layout modes | No equivalent | |
Thumbnails panel, doc.loadThumbnail(), doc.loadCanvas() | PageCanvas | |
annotationManager.getFieldManager().getField(name) | useWidgets filtered by fieldName | Widgets, not fields. |
field.setValue(value), field.getValue() | useWidgetValue | Inside a component. |
fieldChanged event | widgetInput from useViewportEvent | Reports values the user changes in the main view, not values set from code. |
new Annotations.Forms.Field() with TextWidgetAnnotation, addField, addAnnotation | createWidgets(props) from useWidgetsCreate | |
FormFieldCreationManager.startFormFieldCreationMode() | The { type: 'addWidget', widgetProps } tool | |
documentViewer.setToolMode(), UI.setToolMode() | useSetInteractionMode | See Editing and interaction tools. |
ContentEditManager.startContentEditMode() | The PDF_EDITOR content policy, where text is editable | |
isReadOnly, annotationManager.enableReadOnlyMode(), UI.enableViewOnlyMode() | PDF_VIEWER or PDF_PRINTER from CONTENT_POLICY_PRESET | |
UI.disableElements(), Modular UI, UI.setTheme() | Your own components and the theme configuration, see Theming and customization | |
AnnotationHistoryManager undo(), redo(), canUndo(), canRedo(), historyChanged | useUndoRedo | |
annotationChanged event | useViewportEvent for user actions, useDocumentRevision for any change | Ids, not annotation objects. |
Unsaved-change tracking through annotationChanged | useDocumentRevision, useDocumentRevisionGetter | Compare revisions to tell whether there are unsaved changes. |
annotationManager.exportAnnotations(), importAnnotations() | No equivalent | No XFDF. |
doc.getFileData({ xfdfString }) | prepareDocument() from useDocumentSaving | Resolves with a Uint8Array, not an ArrayBuffer. |
UI.downloadPdf({ filename }) | downloadDocument() | No file name option. |
flatten save option | No equivalent | |
UI.Events.LOAD_ERROR, loadDocument option onError | The rejected loadPdf promise | See Errors and logging. |
disableLogs, Core.disableLogs(), Core.enableWorkerLogs() | logMessages | The SDK never prints to the console. |
UI.dispose() | No dispose call; unsubscribe log listeners yourself | See Cleanup and lifecycle. |
UI.searchText(), documentViewer.textSearchInit() | useDocumentSearch | See Text search. |
doc.getPDFCoordinates(), doc.getViewerCoordinates() | No equivalent | See Coordinates. |
Install and set up
Before: WebViewer
WebViewer installs as @pdftron/webviewer, copies its static assets into public, and mounts into a ref from an effect:
npm i @pdftron/webviewer
npx --yes cpy-cli "node_modules/@pdftron/webviewer/public/**/*" public/lib/webviewerimport { useEffect, useRef } from 'react'
import WebViewer from '@pdftron/webviewer'
function App() {
const viewerRef = useRef(null)
useEffect(() => {
WebViewer(
{
path: '/lib/webviewer',
licenseKey: 'YOUR_LICENSE_KEY',
initialDoc: 'https://example.com/files/contract.pdf',
},
viewerRef.current
).then((instance) => {
// const { UI, Core } = instance
})
}, [])
return <div ref={viewerRef} style={{ height: '100vh', width: '100%' }} />
}Under React Strict Mode the effect runs twice in development, so WebViewer integrations add a hasMounted ref guard.
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 in your tree. Its props must keep their identity between renders, so memoize the configuration:
import { useMemo } from 'react'
import { CONTENT_POLICY_PRESET, PdfProviders } from '@sdk-pdf/react'
import '@sdk-pdf/react/style.css'
export function App({ isReadOnly }: { isReadOnly: boolean }) {
// The PDF application re-renders whenever a PdfProviders prop changes identity.
const config = useMemo(
() => ({
theme: { viewportPadding: 16 },
// Applied when the viewport first mounts; switch later with useSetInteractionMode.
initialContentPolicy: isReadOnly
? CONTENT_POLICY_PRESET.PDF_VIEWER
: CONTENT_POLICY_PRESET.PDF_EDITOR,
}),
[isReadOnly]
)
return (
<PdfProviders {...config}>
<DocumentLoader />
</PdfProviders>
)
}What to know
- No assets, no license key in code. The install is the registry scope and the package; there is no asset folder to copy and no
pathorlicenseKeyto pass. - No mount effect. The SDK renders as components, so the
useRefanduseEffectmount, the Strict Mode guard, and sharing the instance through React context go away: any component underPdfProvidersreaches the document through hooks. - Both default to editable. WebViewer's
isReadOnlydefaults tofalse, andPdfProvidersdefaults toPDF_EDITOR, where text, annotations, links, and widgets are all editable. Map your read-only flag toinitialContentPolicy, as above. The initial policy applies when the viewport first mounts; switch at runtime withuseSetInteractionMode, as in Editing and interaction tools.
Load and reset a document
Before: WebViewer
UI.loadDocument(documentPath, options, documentViewerKey) takes a URL, File, Blob, Core.Document, or Core.PDFNet.PDFDoc. Its options include filename, extension, password (a string, or a function that provides it through a callback), onLoadingProgress (for network loads only), and onError. The document is usable after documentLoaded:
const { documentViewer } = instance.Core
documentViewer.addEventListener('documentLoaded', () => {
showDocumentUi()
})
instance.UI.loadDocument('https://example.com/files/contract.pdf', {
filename: 'contract.pdf',
password: enteredPassword,
// Only for files loaded over network.
onLoadingProgress: (percent) => showProgress(percent),
onError: (error) => showError(error),
})loadDocument closes the current document itself, so there is no need to close it before loading another one; UI.closeDocument() closes it explicitly.
After: @sdk-pdf/react
loadPdf from useLoadPdf takes the file bytes. A File from an input becomes a Uint8Array first, and the load state decides what to render:
import { InvalidPasswordError, useLoadPdf, useResetPdf } from '@sdk-pdf/react'
function DocumentLoader() {
const [loadState, loadPdf] = useLoadPdf()
async function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.currentTarget.files?.[0]
if (!file) return
const data = new Uint8Array(await file.arrayBuffer())
try {
// loadPdf reads from the buffer it receives, so keep `data` for a retry and pass a
// copy.
await loadPdf(data.slice())
} catch (error) {
if (error instanceof InvalidPasswordError) {
// The password is missing or wrong: ask for it, then call
// loadPdf(data.slice(), { password }) again.
}
}
}
if (loadState.status === 'initial') {
return <input type="file" accept=".pdf" onChange={handleFileChange} />
}
if (loadState.status === 'loading') {
return <p>Loading the document…</p>
}
return <PdfApplication />
}
function CloseDocumentButton() {
const resetPdf = useResetPdf()
// Replaces UI.closeDocument(): the load state returns to 'initial'.
return <button onClick={() => resetPdf()}>Close</button>
}For a URL, fetch it and pass new Uint8Array(await response.arrayBuffer()).
What to know
- PDF only.
useLoadPdfloads PDF documents; there is no Office, image, or XOD input, and noextensionorfilenameto set. - Keep a copy for retries. The document reads from the given buffer for its whole lifetime, and another
loadPdfcall can detach it. To reuse the bytes for a password retry, pass a copy:loadPdf(data.slice(), { password }). - Without a password, the empty password is tried, as the PDF specification prescribes; a missing or wrong password rejects with
InvalidPasswordError. Instead of a password function, catch the rejection and callloadPdfagain. - No progress or multi-tab loading. There is no
onLoadingProgress, and no equivalent of passing several documents toinitialDocfor multi-tab mode. - 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: WebViewer
The prebuilt UI renders the document, toolbars, and panels; integrations trim and theme it from outside:
instance.UI.disableElements(['leftPanel', 'leftPanelButton'])
instance.UI.setTheme(instance.UI.Theme.DARK)After: @sdk-pdf/react
Viewport renders the pages, and your components render everything else. A typical shell puts a toolbar above the viewport and a sidebar beside it, as Building a PDF application walks through:
import { Config, useViewportAutoFitScale, Viewport } from '@sdk-pdf/react'
// A fuller theme than the one in App: pass it as PdfProviders' `theme` prop, defined
// outside components so its identity is stable.
export const theme: Config['theme'] = {
viewportPadding: [24, 16],
classNames: { scrollViewport: 'my-viewport', page: 'my-page' },
styles: {
scrollViewport: { '--selection-color': 'rebeccapurple' } as React.CSSProperties,
},
}
function PdfApplication() {
// Fit the width once per loaded document, like opening in FitWidth.
useViewportAutoFitScale('width')
return (
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr', height: '100vh' }}>
<ThumbnailsSidebar />
<div style={{ display: 'grid', gridTemplateRows: 'auto 1fr', minHeight: 0 }}>
{/* Your toolbar: the navigation, zoom, and history controls from this guide. */}
<Toolbar />
{/* The viewport fills its parent, so the parent needs an explicit size. */}
<div style={{ minHeight: 0 }}>
<Viewport />
</div>
</div>
</div>
)
}What to know
- There is nothing to hide.
disableElements,enableFeatures, and the Modular UI configuration have no counterpart. Selection, text editing, form filling, links, and zoom gestures work insideViewportand are limited by the content policy; every control around it exists only if you build it. - Theming is CSS you own.
theme.classNamesandtheme.stylestarget the SDK's elements (scrollViewport,page,pageCanvas,resizable,widgetOverlay, and more), and CSS variables such as--selection-colorand--widget-overlay-backgroundtune the selection and widget visuals; Theming and customization lists them. There are no built-in light and dark themes. - Render only when ready.
ViewportthrowsDocumentNotLoadedErrorwhen no document is loaded. - One layout.
UI.setLayoutModewithSingle,Continuous,Facing, and the facing and cover variants, andDisplayModeManager.setDisplayModeonDocumentViewer, have no equivalent:Viewportshows the pages in one scrollable area.
Pages and navigation
Before: WebViewer
DocumentViewer navigates by 1-based page number and reports changes with pageNumberUpdated:
const { documentViewer } = instance.Core
pageCountLabel.textContent = documentViewer.getPageCount()
documentViewer.addEventListener('pageNumberUpdated', (pageNumber) => {
pageSelect.value = pageNumber
})
pageSelect.onchange = () => documentViewer.setCurrentPage(Number(pageSelect.value), true)After: @sdk-pdf/react
usePages bridges positions and ids: a position is an index in the list, and navigation with useScrollToPage takes an id. useCurrentPageId replaces pageNumberUpdated:
import { useCurrentPageId, usePages, useScrollToPage } from '@sdk-pdf/react'
function PagePicker() {
const pages = usePages()
// The page in the middle of the viewport; undefined until the viewport has one.
const currentPageId = useCurrentPageId()
const scrollToPage = useScrollToPage()
return (
<select
value={currentPageId ?? pages[0]?.id}
onChange={(event) =>
scrollToPage(event.currentTarget.value, { behavior: 'smooth' })
}
>
{pages.map((page, index) => (
<option key={page.id} value={page.id}>
Page {index + 1} of {pages.length}
</option>
))}
</select>
)
}When you need a page's number inside a handler, such as for analytics, getPageIndex(pageId) from usePageIndexGetter resolves an id to its zero-based index without re-rendering on order changes.
What to know
- Ids survive reordering; numbers don't. An id keeps pointing at the same page while pages are added, removed, and rearranged by the user, where a number would drift to another page. Ids are session-scoped: persist page numbers, not ids.
- Scroll options.
scrollToPagealigns the page withvertical('top'by default) andhorizontal('nearest'by default);isSmoothScrollbecomesbehavior: 'smooth'. - Scrolling to a spot.
displayPageLocation(pageNumber, horizontalPosition, verticalPosition)has no equivalent.scrollToBlock(blockId, options?)fromuseScrollToBlockscrolls to a block, such as a form widget.
Page manipulation
Before: WebViewer
Core.Document changes pages by number. These methods work on PDF documents only, resolve with a PageUpdateResult, and are licensed as part of Page Manipulation:
const doc = instance.Core.documentViewer.getDocument()
// Inserts blank pages before the given pages; the size is in PDF points.
await doc.insertBlankPages([2], 612, 792)
await doc.removePages([4])
await doc.movePages([5], 1)
// Adds the rotation to the pages' current rotation.
await doc.rotatePages([1], instance.Core.PageRotation.E_90)After: @sdk-pdf/react
Each page operation is a hook that returns a command taking page ids: usePageCreate, usePageDelete, usePageMove, useSetPageRotation, and usePageDuplicate:
import {
usePageCreate,
usePageDelete,
usePageDuplicate,
usePageMove,
usePages,
useSetPageRotation,
} from '@sdk-pdf/react'
function PageActions({ pageId }: { pageId: string }) {
const pages = usePages()
const createPage = usePageCreate()
const deletePages = usePageDelete()
const duplicatePage = usePageDuplicate()
const { moveBefore } = usePageMove()
const setPageRotation = useSetPageRotation()
const page = pages.find((page) => page.id === pageId)
if (!page) return null
return (
<div role="toolbar">
{/* A4 is roughly 595 × 842 user-space units; the new page goes after this one. */}
<button onClick={() => createPage(595, 842, pageId)}>
Insert blank page after
</button>
<button disabled={pages.length <= 1} onClick={() => deletePages(pageId)}>
Delete
</button>
<button onClick={() => void duplicatePage(pageId)}>Duplicate</button>
{/* Without a target page, moveBefore inserts at the start of the document. */}
<button onClick={() => moveBefore(pageId)}>Move to start</button>
{/* Rotation is absolute: add to the current angle yourself. */}
<button onClick={() => setPageRotation(pageId, page.rotation + 90)}>
Rotate clockwise
</button>
</div>
)
}What to know
| Operation | WebViewer | @sdk-pdf/react |
|---|---|---|
| Insert | Before given page numbers; defaults 612 × 792 PDF points | After insertAfterPageId, or appended; width and height in user-space units (1/72 of an inch), no defaults |
| Move | Before insertBeforeThisPage | moveBefore(ids, beforePageId?) (start when omitted) or moveAfter(ids, afterPageId?) (end when omitted) |
| Rotate | Adds a Core.PageRotation to the current rotation | Sets an absolute angle; -90 and 450 are normalized; angles off a quarter turn throw InvalidInputError |
| Duplicate | — | duplicatePage is asynchronous and inserts after the source by default |
- Undo. Deleting, moving, or rotating several pages in one call is a single undo step, and an undo can restore a deleted page; separate edits within 500 ms merge into one step.
- No page events. Page commands emit no events: track them where you call them.
usePagesre-renders with the current pages and their order, which covers whatpagesUpdatedreported. - Extracting pages. The counterpart of
extractPagesisprepareDocument({ pageIds })fromuseDocumentSaving, which saves only the listed pages, in the listed order. - No page import, merging, or cropping.
insertPages,mergeDocument, andcropPageshave no equivalent.
Zoom and fit modes
Before: WebViewer
Zoom is a number or a percentage, and a fit mode fixes the zoom: FitWidth fits the width of the page, and FitPage fits the width or the height, whichever is smaller. Both calls need a loaded document:
const { documentViewer } = instance.Core
documentViewer.addEventListener('documentLoaded', () => {
instance.UI.setFitMode(instance.UI.FitMode.FitWidth)
// or a fixed zoom: instance.UI.setZoomLevel('150%')
})
documentViewer.addEventListener('zoomUpdated', (zoom) => {
persistZoom(zoom)
})After: @sdk-pdf/react
useViewportScale reads and sets the scale, useViewportFitScale calculates fit scales on demand, and useViewportAutoFitScale (in PdfApplication above) fits once per loaded document:
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, like zoomUpdated.
useEffect(() => persistZoom(scale), [scale])
function fitPage() {
const { fitWidthScale, fitHeightScale } = getFitScale()
// The smaller scale fits the pages in both directions, like FitPage.
setScale(Math.min(fitWidthScale, fitHeightScale))
}
return (
<div role="toolbar">
<button onClick={() => setScale(scale / 1.25)}>−</button>
<span>{Math.round(scale * 100)}%</span>
<button onClick={() => setScale(scale * 1.25)}>+</button>
<button onClick={() => setScale(getFitScale().fitWidthScale)}>Fit width</button>
<button onClick={fitPage}>Fit page</button>
</div>
)
}What to know
- Fits are one-time calculations.
useViewportAutoFitScaleapplies when a document loads and doesn't re-apply on viewport resizes or manual zooming;useViewportFitScalereflects the viewport size at the moment of the call. There is no fit mode that stays applied as the viewport resizes. - What the fits measure.
fitWidthScalefits the widest page into the viewport width andfitHeightScalethe tallest page into its height, both accounting forviewportPadding. - Scale 1 is 100%: one user-space unit per CSS pixel.
setScaletakes a positive finite number, not a percentage string. - 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. - Gestures. Pinch and wheel zoom are built in and tuned with
zoomGestures(minScale,maxScale, wheel speed) onPdfProviders. During a gesture,scalekeeps its value until the gesture ends;zoomGestureStartandzoomGestureEndfromuseViewportEventmark it.
Thumbnails
Before: WebViewer
The prebuilt UI has a thumbnails tab in the left panel with page operations; UI.Feature.ThumbnailReordering and ThumbnailMerging toggle reordering and merging. Custom thumbnails come from loadThumbnail:
const { documentViewer } = instance.Core
documentViewer.addEventListener('documentLoaded', () => {
const doc = documentViewer.getDocument()
doc.loadThumbnail(1, (thumbnail) => {
// thumbnail is a HTMLCanvasElement or HTMLImageElement
thumbnailContainer.appendChild(thumbnail)
})
})After: @sdk-pdf/react
PageCanvas renders a page lazily at the canvas's CSS size; the sidebar, its selection, and its page operations are yours. A sidebar with reordering buttons:
import { PageCanvas, usePageMove, usePages, useScrollToPage } from '@sdk-pdf/react'
function ThumbnailsSidebar() {
const pages = usePages()
const scrollToPage = useScrollToPage()
const { moveBefore, moveAfter } = usePageMove()
return (
<nav>
{pages.map((page, index) => (
<div key={page.id}>
<button onClick={() => scrollToPage(page.id)}>
<PageCanvas
pageId={page.id}
className="thumbnail"
role="img"
aria-label={`Thumbnail ${index + 1}`}
/>
</button>
<button
disabled={index === 0}
onClick={() => moveBefore(page.id, pages[index - 1]?.id)}
>
Move up
</button>
<button
disabled={index === pages.length - 1}
onClick={() => moveAfter(page.id, pages[index + 1]?.id)}
>
Move down
</button>
</div>
))}
</nav>
)
}What to know
- Lazy and sized with CSS. An off-screen canvas does no work until it becomes visible, and the render resolution follows the displayed CSS size or an explicit
scale. In a virtualized list, passisVisible. - Annotations included.
PageCanvasdraws annotations, form widgets included, unlesshideAnnotationsis set; there is no separate draw step as withloadCanvasanddrawAnnotations. - Thumbnails that show session edits are coming soon.
PageCanvasdraws the page as loaded from the file; the main view shows the edits. Rotation is followed live. - No merging. Dropping another file into the thumbnails (
ThumbnailMerging) has no equivalent.
Forms
Before: WebViewer
Fields are looked up by full name through the field manager, and widgets are annotations:
const { documentViewer, annotationManager } = instance.Core
documentViewer.addEventListener('annotationsLoaded', () => {
// getField returns null when no field has that full name.
const field = annotationManager.getFieldManager().getField('customer.name')
if (field) field.setValue('Jane Doe')
})
annotationManager.addEventListener('fieldChanged', (field, value) => {
autosave(field.name, value)
})Creating a field takes a Field, a widget annotation positioned in viewer coordinates, and both managers:
const { documentViewer, annotationManager, Annotations } = instance.Core
documentViewer.addEventListener('documentLoaded', () => {
const field = new Annotations.Forms.Field('customer.name', { type: 'Tx' })
const widgetAnnot = new Annotations.TextWidgetAnnotation(field)
widgetAnnot.PageNumber = 1
widgetAnnot.X = 100
widgetAnnot.Y = 100
widgetAnnot.Width = 200
widgetAnnot.Height = 50
annotationManager.getFieldManager().addField(field)
annotationManager.addAnnotation(widgetAnnot)
annotationManager.drawAnnotationsFromList([widgetAnnot])
})After: @sdk-pdf/react
Forms are widget-centric: you work with the widgets on the pages, and each value is shaped like the value of the matching HTML form control, as Working with forms explains. The counterpart of getField(name) is a widget lookup by fieldName, bound with useWidgetValue:
import {
TextWidget,
useWidgets,
useWidgetsCreate,
useWidgetValue,
Widget,
} from '@sdk-pdf/react'
function isTextWidget(widget: Widget): widget is TextWidget {
return widget.widgetType === 'text'
}
// The counterpart of getFieldManager().getField(fieldName) for a text field, as a bound
// input.
function TextFieldByName({ fieldName }: { fieldName: string }) {
const { widgets } = useWidgets()
const widget = widgets
.filter(isTextWidget)
.find((widget) => widget.fieldName === fieldName)
return widget ? <TextField widget={widget} /> : null
}
function TextField({ widget }: { widget: TextWidget }) {
// Like a useState backed by the document: filling in the main view and undo update it.
const [value, setValue] = useWidgetValue(widget)
return (
<label>
{widget.fieldName}
<input value={value} onChange={(event) => setValue(event.currentTarget.value)} />
</label>
)
}
function useAddNameField() {
const createWidgets = useWidgetsCreate()
return async (pageId: string) => {
const [widget] = await createWidgets({
pageId,
widgetType: 'text',
// User-space units: the origin is at the bottom-left corner of the page.
x: 72,
y: 700,
width: 180,
height: 24,
fieldName: 'customer.name',
})
return widget
}
}From fields to widgets
| Concept | WebViewer | @sdk-pdf/react |
|---|---|---|
| Lookup | getField(name), getFields() | useWidgets() (re-renders), or await getWidgets() from useWidgetsGetter (one-time, loads every page with widgets first) |
| Shared values | A field with several widgets | Every widget with the same field name shows and synchronizes the same value |
| Values | field.setValue(value), getValue() | useWidgetValue(widget): string for text and choice, boolean for checkbox and radio, option indices for multiChoice |
| Change events | fieldChanged(field, value) | widgetInput from useViewportEvent reports a value the user changed in the main view; values set from code, undo, and redo emit nothing, and useWidgetValue re-renders on any change |
| Creation | Field, widget annotation, addField, addAnnotation | createWidgets(props) from useWidgetsCreate; required: pageId, widgetType, x, y, width, height; a unique fieldName is generated when omitted |
| Interactive creation | FormFieldCreationManager.startFormFieldCreationMode() | The { type: 'addWidget', widgetProps } interaction tool, under an editable widgets policy |
| Deletion | annotationManager.deleteAnnotations() | useBlockDelete with widget ids; same-named widgets act as one field |
| Properties | Widget annotation properties and flags | setWidgetProps(ids, props, options?) from useSetWidgetProps: isReadOnly, borderColor, fillColor, fontSize, isMultiline, options, and more |
| Form data | XFDF export and import | No equivalent |
What to know
- Coordinates differ. WebViewer widget
XandYare viewer coordinates with the origin at the top left of the page;@sdk-pdf/reactwidget positions are user-space units with the origin at the bottom left. Check placement on a test document before converting stored positions. - Values are read and written per widget.
useWidgetValuebinds one widget per hook call, and there is no setter by field name: to prefill a form, render a bound component per widget, likeTextFieldByNameabove. - Read-only fields.
isReadOnlyrestricts filling the field, not authoring the widget; with{ excludeFromHistory: true }, an undo can't unlock a submitted form. - Date fields. A text widget's
formatexposes Acrobat date formats asFormatType.DateTime, recognized without running scripts; seeFormatType. Fields driven by other scripts read as plain text fields. - Signatures.
signaturewidgets carry a read-onlyisCryptoSigned; the SDK never signs.
Editing and interaction tools
Before: WebViewer
Tools are switched by name, content editing is an add-on mode, and read-only is a switch on the annotation manager or the UI:
const { documentViewer, annotationManager, Tools } = instance.Core
documentViewer.setToolMode(documentViewer.getTool(Tools.ToolNames.EDIT))
instance.UI.setToolMode(Tools.ToolNames.TEXT_SELECT)
// Content editing is a separately licensed add-on.
instance.UI.enableFeatures([instance.UI.Feature.ContentEdit])
documentViewer.getContentEditManager().startContentEditMode()
// No annotations can be added or commented on.
annotationManager.enableReadOnlyMode()
// Disables all editing features in the UI.
instance.UI.enableViewOnlyMode()After: @sdk-pdf/react
The interaction mode combines a content policy (what may change) and an interaction tool (what a gesture does); useSetInteractionMode switches both at once:
import {
CONTENT_POLICY_PRESET,
InteractionTool,
useInteractionTool,
useSetInteractionMode,
} from '@sdk-pdf/react'
const textSelectTool: InteractionTool = { type: 'textSelect' }
const selectTool: InteractionTool = { type: 'select' }
const addTextTool: InteractionTool = { type: 'addText' }
function ModeSwitch() {
const interactionTool = useInteractionTool()
const setInteractionMode = useSetInteractionMode()
return (
<div role="toolbar">
{/* Like UI.enableViewOnlyMode(): read-only text, interactive links and widgets. */}
<button
aria-pressed={interactionTool.type === 'textSelect'}
onClick={() =>
setInteractionMode(textSelectTool, CONTENT_POLICY_PRESET.PDF_VIEWER)
}
>
View
</button>
{/* Edit mode: select, move, and resize blocks, with editable text. */}
<button
aria-pressed={interactionTool.type === 'select'}
onClick={() => setInteractionMode(selectTool, CONTENT_POLICY_PRESET.PDF_EDITOR)}
>
Edit
</button>
<button
aria-pressed={interactionTool.type === 'addText'}
onClick={() => setInteractionMode(addTextTool, CONTENT_POLICY_PRESET.PDF_EDITOR)}
>
Add paragraph
</button>
</div>
)
}Mapping the tools
Core.Tools.ToolNames and modes | @sdk-pdf/react |
|---|---|
EDIT | { type: 'select' }: select, move, and resize blocks. |
TEXT_SELECT | { type: 'textSelect' }: native text selection for reading and copying; needs read-only text. |
CONTENT_EDIT and startContentEditMode() | Text is editable in place when the policy's text level is 'editable' (PDF_EDITOR); there is no separate mode or add-on. |
ADD_PARAGRAPH | { type: 'addText' }: creates a paragraph at the click position and focuses it for typing; needs editable text. |
ADD_IMAGE_CONTENT, STAMP with an image | { type: 'addImage', image, width?, height? } with await stageImage(blob) places the image as a stamp annotation, not page content; the optional size is in user-space units, and a single dimension keeps the aspect ratio. See stageImage; useStampAnnotationCreate places one from code. |
FormFieldCreationManager | { type: 'addWidget', widgetProps }, under an editable widgets policy. |
PAN | No tool: Viewport scrolls and zooms on its own. |
FREETEXT | The nearest tool is addText (see ADD_PARAGRAPH), which creates a text paragraph and needs editable text. |
FREEHAND | No drawing tool. useInkAnnotationCreate creates an ink annotation from strokes your code collects. |
RECTANGLE, HIGHLIGHT, STICKY, SIGNATURE, REDACTION, measurement tools | No equivalent. |
isReadOnly, enableReadOnlyMode(), UI.enableViewOnlyMode() | CONTENT_POLICY_PRESET.PDF_VIEWER (widgets stay fillable) or PDF_PRINTER (widgets read-only too). |
What to know
- Switch the policy with the tool. Pass
PDF_EDITORwith the editing tools, as above.textSelectneeds read-only text: with editable text, the combination is unsupported and reports a message throughlogMessages. - Per-kind permissions. A custom content policy is a plain object with
text,annotations,links, andwidgetslevels, finer than a single read-only switch.'hidden'doesn't render that kind of content at all, and a level can be a function of the block id, such aswidgets: (blockId) => (lockedIds.has(blockId) ? 'readonly' : 'interactive'). There is no hook to read the policy back: keep the mode you set in your own state to show or hide editing UI. - Local mode. Switching the mode affects this client only.
- Selection toolbars.
useSelectionTextStyle(font, size, color, bold, italic, alignment, spacing) anduseSelectionAnnotationProps(border color and width) replace the style popups of the prebuilt UI. For widget settings such as multiline, read the selected widgets fromuseSelectedBlocksand change them withsetWidgetPropsfromuseSetWidgetProps.useSelectiontells a paragraph in text editing from selected blocks. - Programmatic text edits.
useProgrammaticEditorreads paragraphs and rewrites them without UI.
Undo, redo, and change tracking
Before: WebViewer
AnnotationHistoryManager handles undo and redo of annotation changes and emits historyChanged. Change tracking listens to annotationChanged and skips imported changes:
const { documentViewer, annotationManager } = instance.Core
const historyManager = documentViewer.getAnnotationHistoryManager()
undoButton.onclick = () => historyManager.undo()
redoButton.onclick = () => historyManager.redo()
historyManager.addEventListener('historyChanged', () => {
undoButton.disabled = !historyManager.canUndo()
redoButton.disabled = !historyManager.canRedo()
})
annotationManager.addEventListener(
'annotationChanged',
(annotations, action, { imported }) => {
if (imported) return
setUnsavedChanges(true)
}
)After: @sdk-pdf/react
import { useMemo } from 'react'
import { useHotkeys, useUndoRedo } from '@sdk-pdf/react'
function HistoryToolbar() {
// canUndo and canRedo re-render the component as the history changes, like
// historyChanged.
const { undo, redo, canUndo, canRedo } = useUndoRedo()
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>
)
}Unsaved-change tracking moves to document revisions: useDocumentRevision re-renders with a new revision on every edit, and undo and redo bring back the revision of that point in the history. Keep the revision you last saved and compare: a different revision means unsaved changes. A save records the revision it saved, so the indicator lives with the save button in Saving and downloading.
What to know
- Beyond annotations.
useUndoRedocovers the document: deleting, moving, or rotating pages are undo steps, widget property changes are undo steps unless excluded, and form values follow undo and redo. - Local and merged. Undo reverts the local user's changes, and edits within 500 ms 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
annotationChangedfed analytics or side effects,useViewportEventreports what users do in the main view, such astextEditorInput,addToolApplied,blocksDragEnd, andwidgetInput, with block ids rather than annotation objects. Commands your code calls, undo, and redo emit no events, so there is noimportedflag to check: track your own calls where you make them, and watchuseDocumentRevisionfor any change. - No history reset.
AnnotationHistoryManager.clear()has no equivalent; a save only moves the revision you compare against.
Saving and downloading
Before: WebViewer
Annotations are exported as XFDF and merged into the file data; getFileData resolves with an ArrayBuffer:
const { documentViewer, annotationManager } = instance.Core
const doc = documentViewer.getDocument()
const xfdfString = await annotationManager.exportAnnotations()
const data = await doc.getFileData({ xfdfString })
const blob = new Blob([new Uint8Array(data)], { type: 'application/pdf' })
const body = new FormData()
body.append('mydoc.pdf', blob, 'mydoc.pdf')
await fetch('/api/documents', { method: 'POST', body })
// Or let the UI download it.
await instance.UI.downloadPdf({ filename: 'contract.pdf' })After: @sdk-pdf/react
useDocumentSaving applies the recorded changes to the original document; there is no separate annotation layer to export:
import { useRef, useState } from 'react'
import {
useDocumentRevision,
useDocumentRevisionGetter,
useDocumentSaving,
} from '@sdk-pdf/react'
function SaveButtons() {
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 annotationChanged-based tracking: undoing back to the saved state clears it
// too.
const hasUnsavedChanges = revision !== savedRevision
async function upload() {
if (isSavingRef.current) return
isSavingRef.current = true
try {
// Edits made after the save starts may be missing from the saved bytes, so only
// this revision becomes saved.
const startRevision = getRevision()
// The buffer goes to fetch, so don't hand out the loaded document's original
// buffer.
const bytes = await prepareDocument({ returnOriginalIfUnchanged: false })
const body = new FormData()
body.append(
'mydoc.pdf',
new Blob([bytes as BlobPart], { type: 'application/pdf' }),
'mydoc.pdf'
)
const response = await fetch('/api/documents', { method: 'POST', body })
// A failed upload keeps the unsaved-changes indicator.
if (!response.ok) return
setSavedRevision(startRevision)
} finally {
isSavingRef.current = false
}
}
return (
<div role="toolbar">
<button onClick={() => void upload()}>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
- One artifact. WebViewer lets you store XFDF apart from the PDF, or post each change as an XFDF command.
@sdk-pdf/reactsaves the complete PDF:prepareDocument()resolves with its bytes 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. - Save options. Besides
returnOriginalIfUnchanged,pageIdssaves only the listed pages, in the listed order. There is no download file name, flattening, linearization (Core.SaveOptions.LINEARIZED), or encryption on save.
Collaboration
WebViewer leaves collaboration to you: your server syncs XFDF between clients, optionally as XFDF commands from exportAnnotationCommand.
Real-time collaboration in @sdk-pdf/react is coming soon. Your XFDF sync code doesn't carry over: edits are CRDT operations, not annotation exports.
Coordinates
| Topic | WebViewer | @sdk-pdf/react |
|---|---|---|
| Page reference | 1-based page numbers | Session-scoped page ids; the position is the index in usePages() |
| Origin | Viewer coordinates: top left of the page, y pointing down; PDF coordinates: bottom left | User space with the origin at the bottom-left corner of the page |
| Units | PDF points for insertBlankPages | User-space units, one unit is 1/72 of an inch |
| Page size | documentViewer.getPageWidth(), getPageHeight(), with rotation applied | width and height of the usePages() entries, with rotation applied |
| Conversion | getPDFCoordinates, getViewerCoordinates | No conversion helpers |
Text search
Before: WebViewer
The prebuilt UI has a search panel, and UI.searchText runs a search from code with options such as caseSensitive, wholeWord, and regex:
instance.UI.searchText('invoice', { caseSensitive: true, wholeWord: true })After: @sdk-pdf/react
useDocumentSearch holds the query, the options, and the matches; the SDK highlights the matches on the pages and scrolls to the active one, and the search panel is yours:
import { useDocumentSearch } from '@sdk-pdf/react'
function SearchPanel() {
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 word
</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
- Options.
isCaseSensitiveandisWholeWordreplacecaseSensitiveandwholeWord; regular expressions and wildcards have no equivalent. - Matches. Each match carries
id,pageId,paragraphId, and the matchedtext, in document order;goToMatch(match.id)activates one from a results list. A changed query is searched once typing pauses, andstatusis'searching'until the whole document is searched. - Styling. The highlights take their look from the
searchMatchkey oftheme.classNamesandtheme.styles, as Theming and customization describes. - No selection API.
getSelectedTexthas no equivalent.
Errors and logging
Before: WebViewer
Load failures arrive through the loaderror UI event or the onError option, and logging is switched off with disableLogs or Core.disableLogs:
instance.UI.addEventListener(instance.UI.Events.LOAD_ERROR, (error) => {
showError(error)
})
instance.Core.disableLogs(true)After: @sdk-pdf/react
Failures are typed errors from the call that failed, and recoverable problems are log messages you subscribe to with logMessages. Error handling and logging has the full rules:
import { useEffect } from 'react'
import { InvalidPasswordError, logMessages, LogLevel, useLoadPdf } from '@sdk-pdf/react'
function useSdkDiagnostics() {
// The SDK never prints to the console: subscribe, and unsubscribe on unmount.
useEffect(() => {
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 LOAD_ERROR listener for this load.
if (error instanceof InvalidPasswordError) askForPassword()
else showError(error)
}
}
}What to know
- Error classes.
DocumentNotLoadedError,InvalidInputError,InvalidPasswordError,DocumentFormatError(malformed beyond what the SDK can work around),NotSupportedError(a PDF feature not supported yet), andUninitializedError, all extendingGenericErrorwith a staticmessageand an optionaldataobject. - Nothing half-applied. Commands validate first and throw before touching anything; readers return empty values instead of throwing; hooks never throw during render.
- Logs replace console output. Where WebViewer logs to the console unless disabled,
@sdk-pdf/reactdeliverslevel,reason, a staticmessage,data, andstackto your subscriber. There is noUI.displayErrorMessage: error UI is yours.
Cleanup and lifecycle
Before: WebViewer
UI.dispose() cleans up the instance's listeners and data and belongs in the code that removes WebViewer from the DOM. In a React effect, the cleanup also has to handle an unmount that happens before WebViewer() resolves:
useEffect(() => {
let instance
let isUnmounted = false
WebViewer({ path: '/lib/webviewer', licenseKey }, viewerRef.current).then((created) => {
instance = created
// The component may unmount before WebViewer resolves, such as under Strict Mode.
if (isUnmounted) instance.UI.dispose()
})
return () => {
isUnmounted = true
instance?.UI.dispose()
}
}, [])After: @sdk-pdf/react
- Subscriptions follow components.
useViewportEventsubscribes for the component's lifetime, with callbacks that don't need memoizing.useHotkeysinstalls window-level shortcuts, and passing a falsy value instead of the mapping disables them, such as while a modal is open. - Log subscriptions return an unsubscribe function.
- Closing a document is
resetPdf()fromuseResetPdf, which returns the load state to'initial'.
Features without an equivalent
Plan around these before you migrate:
| WebViewer feature | In @sdk-pdf/react |
|---|---|
Reading the selected text (getSelectedText) | No equivalent |
| Creating markup and shape annotations, sticky notes, free text, and measurements; drawing ink | No equivalent; existing shapes, lines, and ink display, and their border color and width can be changed. Ink can be created from code with useInkAnnotationCreate |
| Redaction, digital signatures | No equivalent; the SDK never signs |
| XFDF import and export, annotation commands, flattening | No equivalent |
Coordinate conversion (getPDFCoordinates, getViewerCoordinates) | No equivalent |
| Office, image, and XOD files; multi-tab mode | PDF only |
Layout modes, fixed fit modes, displayPageLocation | No equivalent |
| Inserting pages from another document, merging, cropping | No equivalent; prepareDocument({ pageIds }) extracts pages |
| Load progress, custom headers, credentials | Use your own fetch; no progress |
| Built-in UI, themes, localization | The UI is yours |
| WebViewer Server | No equivalent |