Migration

Migrating from Ghostscript

This guide moves the document previews of a web application built on Ghostscript to @sdk-pdf/react. It covers both common setups: a back end that runs the gs command, and one that embeds the interpreter through the gsapi C API, rendering pages to PNG or JPEG files that the browser shows as images. Ghostscript runs on the server and @sdk-pdf/react runs in the browser, so the guide moves the part users see and interact with into a React app: the server keeps fetching and storing the PDF bytes, and its conversion jobs, such as pdfwrite optimization and PDF/A, stay where they are. Work through the checklist below; each step links to the section that covers it.

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

Migration checklist

Overview

How the two integrations differ

Ghostscript is an interpreter for PostScript and PDF built on a graphics library, and it runs where your back end runs:

  • It's a native program: the gs executable, or the interpreter library (libgs.so on Linux, a DLL on Windows) that C code drives through gsapi_new_instance, gsapi_init_with_args, gsapi_exit, and gsapi_delete_instance. It's available under the AGPL and under a commercial license.
  • A job is a command line: input files, -d and -s switches, an output device, and an output file. Raster devices such as png16m and jpeg write page images; high-level devices such as pdfwrite and txtwrite write a new PDF or text.
  • Nothing stays open between jobs. Every preview, zoom level, and thumbnail size is another render, and the output files are yours to serve, cache, and delete.
  • Pages are 1-based numbers (-dFirstPage, -dLastPage, -sPageList), and the output size is a resolution in pixels per inch (-r, usually 72 by default).
  • The browser sees images: no selectable text, clickable links, or fillable fields.

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

  • PdfProviders hosts the document and viewport state, and useLoadPdf loads the document from its bytes.
  • Viewport renders the pages and handles selection, text editing, form filling, link navigation, and zoom gestures, within the limits of the content policy. PageCanvas renders single pages for thumbnails and previews.
  • The document stays loaded. Pages are addressed by session-scoped ids, and state hooks re-render your components when it changes.
  • Edits don't rewrite the PDF file. Each change is an operation in a CRDT, which powers undo/redo; useDocumentSaving builds the final PDF when you need it.
ConcernGhostscript@sdk-pdf/react
Where it runsThe server: the gs process or the interpreter library in CThe browser, as React components and hooks
OutputPage images, a rewritten PDF, text filesA live page view (Viewport), canvas renders (PageCanvas), the final PDF
InteractionNone: the browser shows pictures of the pagesSelection, links, form filling, and editing, per content policy
InputPostScript, EPS, and PDF files, by path or from standard inputPDF bytes
Page addressing1-based page numbersSession-scoped page ids
SizeA resolution per render (-r), or a fixed size (-g)The viewport scale, with zoom gestures
Savingpdfwrite writes a new PDF from what the interpreter drawsprepareDocument() applies the recorded changes to the original document
Diagnosticsgsapi_* return codes, messages on standard output and standard errorTyped errors thrown before any change; logMessages
DistributionA native binary or shared library, under the AGPL or a commercial licenseA package from a private registry

What this means for your code:

  1. Move the preview into the browser. The render endpoints, the page-image cache, and the lists of <img> elements become Viewport and PageCanvas.
  2. Serve the PDF instead of images. The browser fetches the file and passes its bytes to loadPdf, so the access checks of your image endpoints move to the file endpoint.
  3. Replace page numbers with ids at the edges: deep links, analytics, and stored positions.
  4. Replace resolutions with a scale. A render at -r144 becomes setScale(144 / 72); see Zoom and resolution.
  5. Choose the content policy. An image can't be selected, clicked, or filled; pick how much of that the users get now.
  6. Keep conversion on the server. pdfwrite, PDF/A, image exports, and printing stay on the server, now fed the bytes that prepareDocument returns.
  7. Editing is new. Ghostscript has no editing tools, undo, or change tracking, so this guide has no sections for them: Building a PDF application covers interaction tools and history.
API map

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

Ghostscript@sdk-pdf/reactNotes
The gs executable, gsapi_new_instance() and gsapi_init_with_args()<PdfProviders>Runs in the browser; nothing to install on the server.
An input file path, or - for standard inputloadPdf(Uint8Array) from useLoadPdffetch the file into bytes first.
-sPDFPassword=passwordloadPdf(data, { password }), catch InvalidPasswordErrorThe user types the password in the browser.
-dSAFER, --permit-file-readNothing to configureloadPdf takes bytes, not paths.
PostScript and EPS input, ps2pdfNo equivalentPDF only: convert on the server first.
-sDEVICE=png16m, -sOutputFile=page-%03d.png, an image per pageViewportReal pages instead of pictures.
-dTextAlphaBits, -dGraphicsAlphaBits, -dDownScaleFactorNothing to configureViewport takes no props.
.PDFInfo /NumPages, counting the rendered filesusePageCount0 while no document is loaded.
-dFirstPage, -dLastPage, -sPageListscrollToPage(pageId) from useScrollToPageThe viewport shows the whole document.
Page n (1-based)usePages()[n - 1].id with usePagesSee Pages and navigation.
-r per zoom leveluseViewportScale-rN shown at its natural size is setScale(N / 72).
-g with -dPDFFitPageuseViewportFitScale, useViewportAutoFitScaleFits into the viewport, not into a pixel box.
Low-resolution thumbnail rendersPageCanvasSized with CSS, rendered lazily.
-dShowAnnots=false, -dShowAcroForm=falseContent policy annotations: 'hidden', widgets: 'hidden'; hideAnnotations on PageCanvasThe policy hides them in Viewport.
-dPrintedNo equivalent
Field appearances drawn into the imageLive widgets: useWidgets, useWidgetValueUsers fill fields in Viewport under PDF_VIEWER.
-sDEVICE=pdfwrite to produce the downloadable PDFprepareDocument() and downloadDocument() from useDocumentSavingApplies the changes to the original document.
-dPDFSETTINGS, -dCompatibilityLevel, -dFastWebViewNo equivalentKeep pdfwrite on the server.
-dPDFA, -dPDFX, -sOwnerPassword, -sUserPasswordNo equivalentKeep pdfwrite on the server.
-sDEVICE=txtwritegetAllParagraphIds() and getPlainTextParagraphs() from useProgrammaticEditorParagraph text and bounds, not a text file.
gsapi_* return codesThe rejected promise or the thrown errorSee Errors and logging.
gsapi_set_stdio() callbacks, -q, -dQUIETlogMessagesThe SDK never prints to the console.
-dPDFSTOPONERROR, -dPDFSTOPONWARNINGNo equivalentRepairs arrive as log messages.
gsapi_exit(), gsapi_delete_instance()Nothing to call; resetPdf() from useResetPdf closes the documentSee Cleanup and lifecycle.
Temporary files in TMPDIR, rendered filesNothing to delete
-sDEVICE=jpeg, png16m for consumers outside your appNo equivalentKeep these renders on the server.

Install and set up

Before: Ghostscript

Ghostscript is a native program. A source release builds with ./configure, make, and make install, which installs gs in /usr/local/bin by default, with the fonts installed separately; Windows has installers, with gswin64c.exe as the console binary, and Linux also has a snap. gs -h prints the version and the output devices the build includes:

./configure
make
make install

gs -h

The back end runs gs as a process for each job, or embeds the interpreter library and passes the same arguments through gsapi. Embedding needs the shared library: build it with make so and install it with make soinstall:

#include "ierrors.h"
#include "iapi.h"

/* Runs one job with command-line arguments; argv[0] is ignored. */
int run_ghostscript(int argc, char **argv)
{
    void *instance = NULL;
    int code, exit_code;

    code = gsapi_new_instance(&instance, NULL);
    if (code < 0)
        return code;

    code = gsapi_set_arg_encoding(instance, GS_ARG_ENCODING_UTF8);
    if (code == 0)
        code = gsapi_init_with_args(instance, argc, argv);

    /* Required once gsapi_init_with_args has been called, and before deleting the
       instance. */
    exit_code = gsapi_exit(instance);
    if (code == 0 || code == gs_error_Quit)
        code = exit_code;

    gsapi_delete_instance(instance);
    return code;
}

After: @sdk-pdf/react

The package comes from a private registry. Add the @sdk-pdf scope to an .npmrc next to your package.json, keep the token in an environment variable, and install; Getting started has the details:

@sdk-pdf:registry=https://registry.sdk.pdf.net
//registry.sdk.pdf.net/:_authToken=${PDF_NET_REGISTRY_TOKEN}
npm install @sdk-pdf/react

Set up the project as Getting started describes: import the stylesheet, render the SDK on the client only, alias canvas in the bundler, and turn off React Strict Mode.

Then wrap the part of your app that shows documents in PdfProviders. Its props must keep their identity between renders, so define static configuration outside the component:

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

// Static configuration lives outside the component, so its identity never changes.
const initialInteractionTool: InteractionTool = { type: 'textSelect' }

export function DocumentPage({ documentId }: { documentId: string }) {
  return (
    <PdfProviders
      initialInteractionTool={initialInteractionTool}
      initialContentPolicy={CONTENT_POLICY_PRESET.PDF_VIEWER}
    >
      {/* The endpoint that used to return page images now returns the PDF. */}
      <DocumentLoader url={`/api/documents/${documentId}/file`} />
    </PdfProviders>
  )
}

What to know

  • Nothing to install on the server for display. The install is the registry scope, the package, and PdfProviders. Ghostscript stays installed only on the servers that still run conversion jobs.
  • Pick the content policy deliberately. A page image can't be selected, clicked, or filled. PdfProviders defaults to the select tool and CONTENT_POLICY_PRESET.PDF_EDITOR, which makes text, annotations, links, and widgets editable. For a reader, start with PDF_VIEWER (text and annotations read-only, links and widgets interactive) and the textSelect tool, as above; PDF_PRINTER also makes forms read-only.
  • Links stay clickable under both read-only presets. To keep them inert, like in an image, pass a custom policy such as { ...CONTENT_POLICY_PRESET.PDF_VIEWER, links: 'hidden' }, which keeps text read-only for the textSelect tool; the levels are listed in useSetInteractionMode. 'hidden' works for every content kind, and a level can also be a function of the block id, such as widgets: (blockId) => (lockedIds.has(blockId) ? 'readonly' : 'interactive').
  • React only. The viewer is React components and hooks; the code that built image lists in the browser goes away.

Load and reset a document

Before: Ghostscript

Ghostscript reads the input files named on the command line. A PDF needs random access, so a PDF piped to standard input (-) is first copied to a temporary file. -sPDFPassword sets the user or owner password of an encrypted file. Since 9.50, -dSAFER is the default: file access from PostScript input is limited to permitted paths, which you extend with --permit-file-read and the related options, and since 10.04.0, PostScript input can only switch to the device given on the command line (or to nulldevice) unless you pass --permit-devices:

gs -dSAFER -dBATCH -dNOPAUSE -sPDFPassword="$DOCUMENT_PASSWORD" \
  -sDEVICE=png16m -r144 \
  -sOutputFile=/var/cache/previews/42/page-%03d.png /srv/files/42.pdf

Each request runs a new job, so no document stays open between requests.

After: @sdk-pdf/react

The browser fetches the file and passes the bytes to loadPdf from useLoadPdf; the load state decides what to render. When the document id changes, resetPdf() from useResetPdf unloads the open document, the way each Ghostscript job started from a clean state:

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()
  // The fetched bytes and their URL, kept for a password retry.
  const bytesRef = useRef<{ url: string; data: Uint8Array } | null>(null)
  const [openedUrl, setOpenedUrl] = useState<string | null>(null)
  const [passwordUrl, setPasswordUrl] = useState<string | null>(null)
  const [errorUrl, setErrorUrl] = useState<string | null>(null)

  // Another document id while a document is open: unload it first.
  useEffect(() => {
    if (loadState.status === 'ready' && openedUrl !== url) resetPdf()
  }, [loadState.status, openedUrl, url, resetPdf])

  async function open(password?: string) {
    const requestedUrl = url
    try {
      if (!bytesRef.current || bytesRef.current.url !== requestedUrl) {
        const response = await fetch(requestedUrl)
        if (!response.ok)
          throw new Error(`The document request failed with ${response.status}`)
        bytesRef.current = {
          url: requestedUrl,
          data: new Uint8Array(await response.arrayBuffer()),
        }
      }

      // loadPdf reads from the buffer it receives for the document's lifetime,
      // so keep the original for a retry and pass a copy.
      await loadPdf(bytesRef.current.data.slice(), { password })
      setOpenedUrl(requestedUrl)
      setPasswordUrl(null)
    } catch (error) {
      // Replaces -sPDFPassword: ask the user, then call open(password) again.
      if (error instanceof InvalidPasswordError) setPasswordUrl(requestedUrl)
      else setErrorUrl(requestedUrl)
    }
  }

  if (errorUrl === url) return <p role="alert">The document couldn't be opened.</p>
  if (passwordUrl === url) return <PasswordPrompt onSubmit={open} />
  if (loadState.status === 'loading') return <p>Loading the document…</p>
  if (loadState.status === 'ready' && openedUrl === url) return <PdfApplication />

  return <button onClick={() => void open()}>Open</button>
}

A close action uses the same command:

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

function CloseDocumentButton() {
  const resetPdf = useResetPdf()

  // The load state returns to 'initial'.
  return <button onClick={() => resetPdf()}>Close</button>
}

What to know

  • The browser gets the file, not pictures of it. With Ghostscript, the browser received images of the pages you rendered; now it holds the whole PDF. Serve the file endpoint only to users who may have the document, with the same checks your image endpoints made.
  • Bytes, not paths. loadPdf takes a Uint8Array, so headers, cookies, and caching belong to your fetch and your endpoint. -dSAFER and --permit-file-read have no counterpart: there are no file permissions to configure.
  • Passwords move to the browser. Instead of the server passing -sPDFPassword, the user types the password. Without a password, the empty password is tried, as the PDF specification prescribes; a missing or wrong password rejects with InvalidPasswordError.
  • Keep a copy for retries. The document reads from the given buffer for its whole lifetime, and another loadPdf call can detach it. Pass bytesRef.current.data.slice() and keep the original.
  • PDF only. useLoadPdf loads PDF documents. For PostScript or EPS uploads, keep converting them to PDF on the server and serve the result.
  • Switching documents invalidates ids. After resetPdf(), whether from a close action or a new document id, page and block ids, widget snapshots, and document revisions from the old document are no longer valid.

Display the document

Before: Ghostscript

The back end renders every page to a PNG file with a raster device, a resolution, and anti-aliasing for text and graphics (4 is the highest setting):

gs -dSAFER -dBATCH -dNOPAUSE -sDEVICE=png16m -r144 \
  -dTextAlphaBits=4 -dGraphicsAlphaBits=4 \
  -sOutputFile=/var/cache/previews/42/page-%03d.png /srv/files/42.pdf

The %03d in the output file name becomes the page number, so each page gets its own file. -dDownScaleFactor is the other route to smooth edges: -r432 -dDownScaleFactor=3 renders at 432 dpi and writes 144 dpi images. The server caches the files, and the browser shows one <img> per page, asking for new renders when the user zooms.

After: @sdk-pdf/react

Viewport renders the pages, and your components render everything around them, as Building a PDF application walks through:

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

function PdfApplication() {
  // Fit the page width once per loaded document, like previews sized to their column.
  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 page, zoom, 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>
    </div>
  )
}

What to know

  • Real pages, not pictures. Text selection, link navigation, form filling, and zoom gestures work inside Viewport, within the limits of the content policy.
  • No render settings. The device, -dTextAlphaBits, -dGraphicsAlphaBits, and -dDownScaleFactor have no counterpart: Viewport takes no props, and PdfProviders configures the theme, the interaction mode, and the gestures. -r becomes the viewport scale; see Zoom and resolution.
  • No image pipeline. The render jobs, the cache of page images, and the cleanup of old renders go away for display.
  • Render only when ready. Viewport throws DocumentNotLoadedError when no document is loaded, so render it once the load state is 'ready'.
  • Styling. theme.classNames and theme.styles target the SDK's elements, such as scrollViewport and page, and theme.viewportPadding sets the gap around the pages; see Theming and customization.
  • No page boxes or color management. -dUseCropBox, -dUseTrimBox, -dUseArtBox, -dUseBleedBox, and the ICC profile switches have no equivalent.

Pages and navigation

Before: Ghostscript

The browser asks for pages by number, and the back end renders the requested range. The page count comes from the PDF interpreter, whose .PDFInfo operator returns a dictionary with /NumPages; the file has to be permitted for reading:

# Prints the page count.
gs -q -dNODISPLAY --permit-file-read=/srv/files/42.pdf \
  -c "<< >> .PDFInit dup (/srv/files/42.pdf) exch .PDFFile .PDFInfo /NumPages get = quit"

# Renders page 3 only, for /documents/42/pages/3.
gs -dSAFER -dBATCH -dNOPAUSE -sDEVICE=png16m -r144 \
  -dFirstPage=3 -dLastPage=3 \
  -sOutputFile=/var/cache/previews/42/page-3.png /srv/files/42.pdf

-sPageList selects several pages or ranges, such as -sPageList=1,3,5-7; it overrides -dFirstPage and -dLastPage, and the %d in the output file name then counts the rendered pages, not the original page numbers.

After: @sdk-pdf/react

usePages returns the pages in document order, so a page number 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 { useCurrentPageId, usePages, useScrollToPage } from '@sdk-pdf/react'

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

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

  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    const pageNumber = Number(new FormData(event.currentTarget).get('page'))
    // A 1-based number, like -dFirstPage, is an index in the page list.
    const page = pages[pageNumber - 1]
    if (page) scrollToPage(page.id, { behavior: 'smooth' })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="page"
        type="number"
        min={1}
        max={pages.length}
        aria-label="Page number"
      />
      <button type="submit">Go</button>
      <span>
        {currentIndex === -1 ? '–' : currentIndex + 1} of {pages.length}
      </span>
    </form>
  )
}

When only the count matters, such as for a label that replaces the /NumPages lookup, usePageCount re-renders only when the count changes.

Converting between numbers and ids

You haveYou needUse
A 1-based page number nA page idusePages()[n - 1].id
A page idIts positiongetPageIndex(pageId) from usePageIndexGetter: a zero-based index or undefined
A page id, renderedIts positionusePageIndexGetter paired with useRenderOnPageOrderChange
Only the countusePageCount(), 0 while no document is loaded

What to know

  • Ranges become scroll targets. -dFirstPage, -dLastPage, and -sPageList choose what to render; Viewport shows the document's pages in one scrollable area, so a page range in a deep link becomes scrollToPage to its first page.
  • Ids are session-scoped. Keep page numbers in URLs and stored state, such as /documents/42/pages/3, and resolve them to ids once the document is ready.
  • Pages can change during a session. usePageCreate, usePageDelete, usePageMove, usePageDuplicate, and useSetPageRotation edit the loaded document. An id keeps pointing at the same page while pages are added, removed, and rearranged, where a number would drift to another page.
  • Stale ids. usePageIndexGetter returns undefined for an unknown id, and scrollToPage throws InvalidInputError.

Zoom and resolution

Before: Ghostscript

A zoom level is another render at a different resolution. A fit is a fixed image size: -g sets the width and height in pixels, and -dPDFFitPage scales each page to fit it:

# 150% of a 72 dpi preview: 72 × 1.5 = 108 dpi.
gs -dSAFER -dBATCH -dNOPAUSE -sDEVICE=png16m -r108 \
  -dFirstPage=3 -dLastPage=3 -dTextAlphaBits=4 -dGraphicsAlphaBits=4 \
  -sOutputFile=/var/cache/previews/42/page-3-r108.png /srv/files/42.pdf

# Every page fitted into a 1200 × 1600 pixel image.
gs -dSAFER -dBATCH -dNOPAUSE -sDEVICE=png16m -g1200x1600 -dPDFFitPage \
  -dTextAlphaBits=4 -dGraphicsAlphaBits=4 \
  -sOutputFile=/var/cache/previews/42/fit-%03d.png /srv/files/42.pdf

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, from these buttons or from pinch and wheel gestures.
  useEffect(() => saveZoomPreference(scale), [scale])

  function fitPage() {
    const { fitWidthScale, fitHeightScale } = getFitScale()
    // The smaller scale fits the pages in both directions, like -dPDFFitPage.
    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>
      {/* Call the getter inside the handler: the result reflects the viewport size at
         that moment. */}
      <button onClick={() => setScale(getFitScale().fitWidthScale)}>Fit width</button>
      <button onClick={fitPage}>Fit page</button>
    </div>
  )
}

What to know

  • Resolution becomes scale. Ghostscript sizes output in pixels per inch; the viewport scale is CSS pixels per user-space unit, and a user-space unit is 1/72 of an inch. An image rendered at -rN and shown at its natural size is setScale(N / 72): -r72 is 1 (100%), and -r144 is 2. Convert stored zoom levels this way.
  • Zooming is local. A zoom changes the scale of the loaded document in the browser instead of starting a render job on the server.
  • Fits are one-time calculations. useViewportAutoFitScale applies when a document loads and doesn't re-apply on viewport resizes or manual zooming; useViewportFitScale reflects the viewport size at the moment of the call. fitWidthScale fits the widest page and fitHeightScale the tallest, both accounting for viewportPadding.
  • Gestures are built in. Pinch and wheel zoom go through the same scale and are tuned with zoomGestures on PdfProviders (minScale 0.5 and maxScale 4 by default). During a gesture, scale keeps its value until the gesture ends.
  • Zoom around a point. setScale(scale, pivot?) keeps the document still at pivot, a point { x, y } given as fractions of the viewport area (0 the top or left edge, 1 the bottom or right edge), the center by default. The third item of useViewportScale(), animateScale(scale, pivot?), animates the change and resolves when it ends. Both throw InvalidInputError unless the scale is a positive finite number.

Thumbnails

Before: Ghostscript

Thumbnails are another render job at a small fixed size, with -g for the box and -dPDFFitPage to fit pages of any size into it:

gs -dSAFER -dBATCH -dNOPAUSE -sDEVICE=png16m -g160x208 -dPDFFitPage \
  -dTextAlphaBits=4 -dGraphicsAlphaBits=4 \
  -sOutputFile=/var/cache/thumbnails/42/page-%03d.png /srv/files/42.pdf

After: @sdk-pdf/react

PageCanvas renders one page onto a canvas anywhere outside the main view; with usePages and useScrollToPage, it makes a navigation sidebar:

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

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

  return (
    <nav aria-label="Pages">
      {pages.map((page, index) => (
        <button key={page.id} onClick={() => scrollToPage(page.id)}>
          {/* The render resolution follows the canvas CSS size: style .thumbnail to your
             old thumbnail width. */}
          <PageCanvas
            pageId={page.id}
            className="thumbnail"
            role="img"
            aria-label={`Page ${index + 1}`}
          />
          <span>{index + 1}</span>
        </button>
      ))}
    </nav>
  )
}

What to know

  • Sized with CSS, not -g. The render resolution follows the canvas's displayed CSS size, or an explicit scale prop, so the pixels stay sharp at the size you style.
  • Lazy by default. An off-screen canvas does no work until it becomes visible. In a virtualized list, pass isVisible to skip the per-canvas intersection observer.
  • Annotations. PageCanvas draws annotations, form widgets included; hideAnnotations leaves them all out, like -dShowAnnots=false and -dShowAcroForm=false together. hideExtractableText skips the text content.
  • Thumbnails that show session edits are coming soon. PageCanvas draws the page as loaded from the file; the main view shows the edits. The page rotation is applied and followed live.
  • Render only when ready. PageCanvas throws DocumentNotLoadedError when no document is loaded.

Forms

Before: Ghostscript

Form fields are drawn into the image like the rest of the page. -dShowAcroForm=false leaves out the annotations of the form dictionary, and -dShowAnnots=false the page's annotations. Devices that write an output file, such as png16m, use the file's print settings for annotations; -dPrinted=false switches to the screen settings:

gs -dSAFER -dBATCH -dNOPAUSE -sDEVICE=png16m -r144 -dPrinted=false \
  -sOutputFile=/var/cache/previews/42/page-%03d.png /srv/files/42.pdf

The image shows the fields drawn into the page; users can't type into a picture.

After: @sdk-pdf/react

Users fill fields in Viewport with nothing to build when the content policy allows interacting with widgets, as PDF_VIEWER does. 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 builds a full sidebar:

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

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

function TextFieldsPanel() {
  const { widgets, isLoaded } = useWidgets()

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

  return (
    <form>
      {widgets.filter(isTextWidget).map((widget) => (
        <TextField key={widget.id} widget={widget} />
      ))}
    </form>
  )
}

function TextField({ widget }: { widget: TextWidget }) {
  // Typing here and typing into the field in the main view update the same value.
  const [value, setValue] = useWidgetValue(widget)

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

What to know

  • Fields are live. Under PDF_VIEWER, users fill fields in the page; under PDF_PRINTER, widgets are read-only, the closest match to an image.
  • Values are shaped like HTML form controls: text and choice are strings, checkbox and radio booleans, and multiChoice the selected option indices. Every widget with the same field name shows and synchronizes the same value.
  • Values follow the document. Filling in the main view and undo/redo show up as they happen. useWidgetValue binds one widget per hook call.
  • Display switches. hideAnnotations on PageCanvas hides annotations and form widgets together, like -dShowAnnots=false with -dShowAcroForm=false. In Viewport, the content policy levels annotations: 'hidden' and widgets: 'hidden' leave them out separately. -dPrinted has no equivalent.
  • The filled PDF comes from the browser. prepareDocument() returns the document with the entered values; see Saving and downloading.
  • Date fields. A text widget's format exposes Acrobat date formats as FormatType.DateTime, recognized without running scripts; see FormatType.

Saving and downloading

Before: Ghostscript

The download is the stored file, or a copy that pdfwrite rewrites on the way out, such as with a -dPDFSETTINGS preset:

gs -dSAFER -dBATCH -dNOPAUSE -sDEVICE=pdfwrite -dPDFSETTINGS=/ebook \
  -sOutputFile=/var/cache/downloads/42.pdf /srv/files/42.pdf

pdfwrite doesn't edit the input: it builds a new PDF from what the interpreter draws, which should look the same as the original while its insides differ.

After: @sdk-pdf/react

useDocumentSaving applies the changes recorded during the session to the original document. prepareDocument() returns the bytes for your server, and downloadDocument() saves them on the user's device:

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

function SaveControls({ uploadUrl }: { uploadUrl: 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()

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

  async function saveToServer() {
    if (!hasUnsavedChanges || isSavingRef.current) return
    isSavingRef.current = true
    try {
      // Edits made after the save starts may be missing from the saved bytes, so only
      // this revision becomes saved.
      const startRevision = getRevision()
      // The buffer goes to fetch, so don't hand out the loaded document's original
      // buffer.
      const bytes = await prepareDocument({ returnOriginalIfUnchanged: false })
      // The server stores the file and runs its Ghostscript jobs on it.
      const response = await fetch(uploadUrl, { method: 'PUT', body: bytes as BodyInit })
      // A failed upload keeps the unsaved-changes state.
      if (!response.ok) return

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

  return (
    <div role="toolbar">
      <button onClick={() => void saveToServer()}>Save</button>
      {/* Builds the file in the browser instead of a download endpoint. */}
      <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

  • Applied, not redrawn. pdfwrite creates a new file from the drawing operations, and not everything that doesn't make marks on the page carries over. prepareDocument() applies the recorded changes to the original document instead.
  • returnOriginalIfUnchanged defaults to true: with no changes, the buffer passed to loadPdf comes back with no copy. Set it to false when you transfer or mutate the result, or the loaded document's buffer is detached and later reads throw.
  • Saving selected pages. prepareDocument({ pageIds }) saves only the listed pages, in the listed order, like -sPageList with pdfwrite.
  • No optimization options. There is no -dPDFSETTINGS, compatibility level, linearization (-dFastWebView), or encryption on save, and downloadDocument() has no file name option. Run those jobs on the server on the uploaded bytes; see Conversion and optimization.
  • Check post-processing with forms. If the server still runs pdfwrite on saved files, test it with filled forms and annotations, since it writes a new file rather than copying the saved one.
  • Both functions throw DocumentNotLoadedError when no document is loaded.

Errors and logging

Before: Ghostscript

The PDF interpreter tries to repair broken files and prints a warning; -dPDFSTOPONERROR makes it stop with an error instead, and -q drops the startup messages:

gs -q -dSAFER -dBATCH -dNOPAUSE -dPDFSTOPONERROR -sDEVICE=png16m -r144 \
  -sOutputFile=/var/cache/previews/42/page-%03d.png /srv/files/42.pdf

Through gsapi, stdio callbacks collect the messages, and the return code tells success from failure: 0 is no error, gs_error_Quit isn't an error, a negative code is an error, and a code at or below gs_error_Fatal is fatal:

#include <stdio.h>
#include "ierrors.h"
#include "iapi.h"

static int GSDLLCALL read_nothing(void *caller_handle, char *buf, int len)
{
    return 0; /* End of input. */
}

static int GSDLLCALL write_to_log(void *caller_handle, const char *str, int len)
{
    fwrite(str, 1, len, (FILE *)caller_handle);
    return len;
}

int run_with_log(int argc, char **argv, FILE *log)
{
    void *instance = NULL;
    int code, exit_code;

    /* The handle passed here reaches the stdio callbacks. */
    code = gsapi_new_instance(&instance, log);
    if (code < 0)
        return code;

    gsapi_set_stdio(instance, read_nothing, write_to_log, write_to_log);
    code = gsapi_set_arg_encoding(instance, GS_ARG_ENCODING_UTF8);
    if (code == 0)
        code = gsapi_init_with_args(instance, argc, argv);

    exit_code = gsapi_exit(instance);
    if (code == 0 || code == gs_error_Quit)
        code = exit_code;
    gsapi_delete_instance(instance);

    /* Negative: the job failed, and the log holds the messages. */
    return code;
}

After: @sdk-pdf/react

The SDK reports problems through two channels. When an operation is impossible, it throws a typed error and changes nothing. When it hits a problem it can work around, such as a malformed document, it completes the operation and emits a log message through logMessages. Error handling and logging has the full rules:

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

function useReportSdkLogs(documentId: string) {
  // Replaces the stdio callbacks: subscribe, and unsubscribe on unmount.
  useEffect(() => {
    const unsubscribe = logMessages.on('message', (logMessage) => {
      // Where Ghostscript printed a warning about a repaired file, the SDK sends a
      // message.
      if (logMessage.level >= LogLevel.Warning) {
        reportToMonitoring(logMessage.message, {
          documentId,
          reason: logMessage.reason,
          data: logMessage.data,
        })
      }
    })
    return () => {
      unsubscribe()
    }
  }, [documentId])
}

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 checking the return code of the job.
      if (error instanceof InvalidPasswordError) askForPassword()
      else showError(error)
    }
  }
}

Error classes

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

@sdk-pdf/reactThrown whenClosest Ghostscript situation
InvalidPasswordErrorThe document is encrypted, and the password is missing or wrong.An encrypted file without the right -sPDFPassword
DocumentFormatErrorThe PDF content is malformed beyond what the SDK can work around.A broken file that fails the job
NotSupportedErrorThe document uses a PDF feature the SDK doesn't support yet.—
DocumentNotLoadedErrorAn operation requires a document, and none is loaded.—
InvalidInputErrorThe input passed to the API is invalid.—
UninitializedErrorAn object is used before its asynchronous initialization has completed.—

What to know

  • Repairs are log messages. Like Ghostscript, the SDK works around malformed documents; it reports each problem as a log message whose reason names the kind of problem, such as a document format issue. There is no switch like -dPDFSTOPONERROR to turn repairs into failures: decide in your subscriber what to do with them.
  • Nothing printed. The SDK never prints to the browser console, so there is nothing like -q to silence. Each message carries level (LogLevel.Debug, Info, Warning, Error), reason, a static message, data, and stack; filter by level in your subscriber.
  • Throw-versus-return rules. Functions that modify the document validate first and throw before touching anything; functions that only read return empty values instead of throwing; hooks never throw during render. Viewport and PageCanvas are the exception: they throw DocumentNotLoadedError when rendered without a document.
  • Network errors are yours. Failures of the file request come from your own fetch, as in DocumentLoader above.

Cleanup and lifecycle

Before: Ghostscript

In the gsapi integration, every job ends with gsapi_exit and gsapi_delete_instance, as in run_ghostscript above: gsapi_exit is required once gsapi_init_with_args has been called, and comes before deleting the instance. Ghostscript writes temporary files to /tmp on Unix, or to the directory in TMPDIR or TEMP, and after a job that exits on an error some may stay behind. The rendered files stay until your own cleanup removes them:

TMPDIR=/var/tmp/gs-jobs gs -dSAFER -dBATCH -dNOPAUSE -sDEVICE=png16m -r144 \
  -sOutputFile=/var/cache/previews/42/page-%03d.png /srv/files/42.pdf

# Later, when the file changes or the cache expires:
rm -r /var/cache/previews/42 /var/cache/thumbnails/42

After: @sdk-pdf/react

Most of this moves into the SDK and React's component lifecycle:

Concern@sdk-pdf/react
InstancesNothing to create or delete: PdfProviders hosts the document state.
Rendered filesNothing to store: Viewport renders in the page, and PageCanvas renders lazily and does no work off-screen.
Event listenersuseViewportEvent subscriptions follow the component lifecycle.
Log subscriptionslogMessages.on() returns an unsubscribe function; call it in the effect cleanup, as in Errors and logging.
Keyboard listenersuseHotkeys installs window-level shortcuts; memoize the mapping, and pass a falsy value instead of it to disable them.
Closing a documentresetPdf() from useResetPdf unloads the document and invalidates its ids, snapshots, and revisions.
BuffersDon't reuse a buffer passed to loadPdf; copy it first.

What to know

  • The server keeps less state. The page-image cache and its expiry go away with the preview endpoints; the stored PDF and any conversion outputs remain.
  • Temporary files remain a server concern only where conversion jobs still run Ghostscript.

Text extraction

Ghostscript extracts text with the txtwrite device, which by default writes UTF-8 text that approximates the page layout:

gs -dSAFER -dBATCH -dNOPAUSE -sDEVICE=txtwrite \
  -sOutputFile=/srv/text/42.txt /srv/files/42.pdf

In @sdk-pdf/react, useProgrammaticEditor reads the loaded document's text as paragraphs: getAllParagraphIds() lists every paragraph in page and content order, and getPlainTextParagraphs(ids) resolves them to entries with id, content, pageNumber, the user-space box bounds ({ x, y, width, height }), and the paragraph layout, such as textAlign and lineHeight:

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

function useDocumentText() {
  const { getAllParagraphIds, getPlainTextParagraphs } = useProgrammaticEditor()

  return async () => {
    // Loads every page in turn, which takes a while on a large document;
    // getParagraphsByPage(pageId) reads a single page.
    const paragraphIds = await getAllParagraphIds()
    const paragraphs = await getPlainTextParagraphs(paragraphIds)
    return paragraphs.map((paragraph) => paragraph.content).join('\n\n')
  }
}

The result is plain paragraph text with bounds in user space, not a layout-preserving text file or XML. For indexing documents that no browser opens, keep txtwrite on the server.

Conversion and optimization

Ghostscript's other jobs don't move to the browser: pdfwrite compression and presets, PDF/A and PDF/X, encryption, image exports for email or link previews, PostScript and EPS conversion, and printing. Keep them on the server. What changes is their input: when users edit or fill a document, the current version is the file that prepareDocument() uploads, so run the jobs on that:

# PDF/A-2 from the uploaded file. PDFA_def.ps is your copy of the sample definition file,
# pointing at an ICC profile in a directory that SAFER permits reading.
gs -dSAFER -dBATCH -dNOPAUSE --permit-file-read=/srv/icc/ -sDEVICE=pdfwrite -dPDFA=2 \
  -sColorConversionStrategy=RGB -sBlendConversionStrategy=Simple \
  -sOutputFile=/srv/archive/42.pdf PDFA_def.ps /srv/files/42.pdf

@sdk-pdf/react doesn't compress, convert, encrypt, or print, so none of these jobs has a browser-side replacement; Features without an equivalent lists them.

Features without an equivalent

Plan around these before you migrate:

Ghostscript featureIn @sdk-pdf/react
Page images (png16m, jpeg, and other raster devices) for email, link previews, or other consumersNo equivalent; keep these renders on the server
pdfwrite rewriting: -dPDFSETTINGS, -dCompatibilityLevel, -dFastWebViewNo equivalent; run it on the server on the bytes from prepareDocument()
PDF/A and PDF/X outputNo equivalent
Encryption on save (-sOwnerPassword, -sUserPassword, -dPermissions)No equivalent
Merging files (several inputs to pdfwrite)No equivalent
PostScript and EPS input, ps2pdfPDF only; convert on the server first
Printing through printer devices, ps2write, or pxlcolorNo print API; PageCanvas can render pages for your own print layout, as loaded from the file, without session edits
Page boxes (-dUseCropBox, -dUseTrimBox, -dUseArtBox, -dUseBleedBox)No equivalent
Color management: ICC profiles, overprint simulation, spot color outputNo equivalent
Print or screen appearances (-dPrinted)No equivalent
Stopping on repairable problems (-dPDFSTOPONERROR, -dPDFSTOPONWARNING)No equivalent; logMessages reports the problems
Text files (txtwrite) and DOCX output (docxwrite)useProgrammaticEditor reads paragraph text; no file output
N-up layout (-sNupControl)No equivalent

On this page