npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

docedit-pdf-engine

v0.3.1

Published

A from-scratch, zero-dependency document engine in pure TypeScript: PDF (ISO 32000-1), DOCX and XLSX — parse, render, annotate, fill forms and write.

Downloads

982

Readme

DOCEdit — document parsing engines written from scratch

Pure-TypeScript implementations of PDF (ISO 32000-1), DOCX and XLSX (ECMA-376), plus a PDF canvas renderer, a React annotation overlay, a paginated document renderer, a PDF writer, and a virtualised canvas spreadsheet with its own formula engine. No parsing, grid or spreadsheet library is used for any of it — including the ZIP and DEFLATE decoding the OOXML formats need.

Zero dependencies. Nothing in src/ imports a third-party package, and nothing in src/ imports a Node built-in either — it runs unchanged in the browser, in a worker, or on the server. It takes an ArrayBuffer and works on bytes. The one optional extra, the React annotation overlay, is a peer dependency behind its own entry point (docedit/react); a test walks the import graph to prove the core barrel never reaches it.

Install

npm install docedit-pdf-engine

One package, several entry points. Which one you import depends on where it runs — and that choice is not cosmetic.

| Entry | For | Needs | |---|---|---| | docedit-pdf-engine | the engine: parse, render, write, forms, text | nothing | | docedit-pdf-engine/docx | DOCX | nothing | | docedit-pdf-engine/xlsx | XLSX | nothing | | docedit-pdf-engine/react | web UI — toolbar, overlay, panels | react, react-dom | | docedit-pdf-engine/native | React Native UI | react, react-native, @shopify/react-native-skia, react-native-gesture-handler, react-native-reanimated | | docedit-pdf-engine/sdk | framework-free web component | nothing | | docedit-pdf-engine/server | storage helpers | mongoose |

Every peer is optional: a React Native app cannot install react-dom and a web app cannot install Skia, so requiring either would make the package uninstallable for half its users.

React and React Native are not interchangeable

They share the engine and nothing else. /react renders with <div> and <svg> and reaches for document, IntersectionObserver and PointerEvent. React Native has none of those — /native draws with Skia instead. Importing the wrong one fails at build time, which is the good case; the bad case is a bundler that resolves it and a blank screen at runtime.

// engine only — server, worker, CLI. No React at all.
import { PDFDocument, extractPageText, appendAnnotations } from "docedit-pdf-engine";

// web
import { PdfToolbar, AnnotationOverlay, SearchPanel } from "docedit-pdf-engine/react";

// react native
import { PdfView } from "docedit-pdf-engine/native";

React Native: enable package exports

Metro resolves subpaths like /native through the exports field, which is on by default from React Native 0.79. Below that, turn it on:

// metro.config.js
const config = { resolver: { unstable_enablePackageExports: true } };

Without it Metro falls back to main and every subpath import fails to resolve — an error that reads like a missing file rather than a missing setting.

Verifying a release

npm run pack:check packs the real tarball, installs it into an empty directory with no peers present, and imports every advertised entry. It catches the failures that only appear once published: a path in exports that files does not ship, a missing .d.ts, or a "zero-dependency" core that has quietly started importing React.


import { PDFDocument } from "./src/index.js";

const doc = PDFDocument.load(arrayBuffer);

doc.version;    // "1.7"
doc.pageCount;  // 42
doc.catalog;    // the /Type /Catalog dictionary

for (const page of doc.getPages()) {
  console.log(page.index, page.width, page.height, page.rotate);
  const ops = doc.getPageContent(page); // decoded content stream bytes
}

What it does

| Area | Covered | | --- | --- | | Header (§7.5.2) | %PDF-n.m, including files with junk prepended before it | | Lexer (§7.2) | literal/hex strings, escapes, octal, #xx names, comments, reals | | Objects (§7.3) | all eight COS types, indirect references | | XRef table (§7.5.4) | classic xref sections, subsections, trailer | | XRef stream (§7.5.8) | /Type /XRef, /W, /Index, type 0/1/2 entries | | Object streams (§7.5.7) | /Type /ObjStm — objects packed inside a compressed stream | | Hybrid files (§7.5.8.4) | /XRefStm merged correctly against the classic table | | Incremental updates | /Prev chains, newest-definition-wins, deletions | | Filters (§7.4) | Flate, LZW, ASCIIHex, ASCII85, RunLength | | Predictors (§7.4.4.4) | PNG 10–15 (None/Sub/Up/Average/Paeth), TIFF 2 | | Catalog + pages (§7.7) | tree walk, inherited attributes, /Rotate geometry | | Damaged files | full-file rebuild when the xref is unusable |

DEFLATE is implemented from scratch in src/inflate.ts — RFC 1951 (stored, fixed and dynamic Huffman blocks, LZ77 back-references) plus the RFC 1950 zlib wrapper. This is not optional: cross-reference streams and object streams are Flate-compressed by definition, so without an inflater you cannot locate the Catalog of any PDF 1.5+ file at all.

Text extraction

extractPageText runs the content stream through a full graphics state machine and returns one structured record per text-showing operation:

import { PDFDocument, extractPageText, itemsToText } from "./src/index.js";

const doc = PDFDocument.load(arrayBuffer);
const page = doc.getPages()[0];

const items = extractPageText(doc, page);
// [
//   {
//     "text": "ABC",
//     "x": 100, "y": 700,            // baseline origin, page units (1/72")
//     "endX": 124.672, "endY": 700,
//     "width": 24.672,               // advance along the baseline
//     "height": 12,                  // on-page em height (effective font size)
//     "bbox": [100, 697, 124.672, 709],
//     "fontSize": 12,                // the /Tf operand, before matrix scaling
//     "fontName": "F1",              // resource name
//     "baseFont": "Helvetica",
//     "fontRef": "5 0 R",
//     "color": { "space": "DeviceRGB", "components": [1,0,0],
//                "rgb": [255,0,0], "hex": "#ff0000" },
//     "renderMode": 0,               // 3 and 7 are invisible (OCR layers)
//     "rotation": 0,                 // baseline direction, degrees
//     "matrix": [12, 0, 0, 12, 100, 700],   // text rendering matrix
//     "charSpacing": 0, "wordSpacing": 0,
//     "horizontalScale": 1, "rise": 0,
//     "formDepth": 0, "glyphCount": 3
//   }
// ]

itemsToText(items);  // plain text, regrouped into reading order

Pass { includeGlyphs: true } to get per-character { text, x, y, width, code }.

Operators handled

| Group | Operators | | --- | --- | | Text objects | BT ET | | Positioning | Td TD Tm T* | | Showing | Tj TJ ' " | | Text state | Tf Tc Tw Tz TL Ts Tr | | Graphics state | q Q cm gs | | Colour | g G rg RG k K cs CS sc SC scn SCN | | XObjects | Do (recurses into Form XObjects, applying /Matrix) | | Marked content | BDC BMC EMC (captures /ActualText) | | Inline images | BI ID EI (skipped safely — the binary payload is not lexed) |

Fonts and colour

  • Simple fonts/Encoding base + /Differences, glyph names resolved via the Adobe Glyph List, widths from /Widths.
  • Standard 14 — a font may legally omit /Widths, in which case the metrics are the consumer's problem. src/text/metrics.ts is generated from Adobe's published AFM files, so those advances are exact.
  • Composite (Type0/CID)Identity-H/V and embedded CMaps, CIDs via /W + /DW, text via /ToUnicode.
  • Type3 — glyph space taken from /FontMatrix rather than a fixed 1/1000.
  • Colour — Gray/RGB/CMYK/ICCBased/Indexed/Lab/Separation/DeviceN, with PDF function types 0, 2, 3 and 4 implemented (including a small PostScript calculator) so a spot colour's tint transform is actually evaluated rather than guessed.

Coordinates

Defaults: origin at the crop box's lower-left, /Rotate applied so coordinates agree with page.width/page.height, Y increasing upward (PDF's own convention). Each is switchable:

extractPageText(doc, page, {
  normalizeToCropBox: true,      // subtract the crop box origin
  applyRotation: true,           // honour /Rotate
  origin: "bottom-left",         // or "top-left" for screen coordinates
  inferWordGaps: true,           // [(Hello)-250(World)] -> "Hello World"
  includeGlyphs: false,
  includeInvisible: true,        // keep render modes 3 and 7
});

Items come back in drawing order, which is not reading order — producers emit text in whatever sequence suits them. Use itemsToText, or sort by (y, x) yourself.

Rendering to a Canvas

CanvasTextRenderer draws the extracted items onto an HTML5 canvas.

import { PDFDocument, extractPageText, CanvasTextRenderer } from "./src/index.js";

const doc = PDFDocument.load(arrayBuffer);
const page = doc.getPages()[0];
const items = extractPageText(doc, page, { includeGlyphs: true });

const renderer = new CanvasTextRenderer(document.querySelector("canvas"), {
  fit: "width",
  containerWidth: container.clientWidth,
});

const stats = renderer.render({ width: page.width, height: page.height, items });
// { drawn: 317, skipped: 0, culled: 0, scale: 1, dpr: 2,
//   cssWidth: 612, cssHeight: 792, canvasWidth: 1224, canvasHeight: 1584, ms: 11.4 }

A runnable viewer with zoom, rotation and hover inspection is in examples/viewer.html:

npm run build && npm run serve     # http://localhost:8080

(It must be served over HTTP — ES module imports do not work from file://.)

Two problems it solves

Coordinates. PDF puts the origin bottom-left with Y up; Canvas puts it top-left with Y down. Each item also carries its own text rendering matrix, already containing the font size, horizontal scale, text matrix and CTM. Helpfully, PDF's [a b c d e f] and Canvas's setTransform(a,b,c,d,e,f) are the same convention, so the matrix passes straight through. The chain is:

text space --(item.matrix)--> page space --(rotation, flip, zoom, DPR)--> device

Metrics. The PDF's embedded font is not available to the browser, so a system substitute is used and its advance widths will not match. Left alone, runs come out visibly too wide or too narrow. The renderer measures the substitute and applies a horizontal scale so each run occupies exactly the advance the PDF declared. With includeGlyphs data it goes further and places each character at its own recorded position.

Device pixel ratio

The backing store is sized css x dpr while the CSS size stays put, so text is crisp on high-DPI displays. DPR is read from devicePixelRatio unless overridden, and is clamped so the backing store stays within the browser's allocation limit — exceeding it does not throw, it silently yields a blank canvas, so it has to be checked in advance.

new CanvasTextRenderer(canvas, {
  dpr: 2,                    // default: devicePixelRatio
  maxDpr: 3,
  maxCanvasDimension: 16384, // clamps dpr rather than overflowing
});

Options

| Option | Default | Purpose | | --- | --- | --- | | fit | "none" | "width", "height", "contain", or "none" with scale | | scale | 1 | Zoom when fit is "none" | | rotation | 0 | Extra view rotation, 0/90/180/270 | | itemOrigin | "bottom-left" | Must match the extraction's origin | | dpr / maxDpr | auto / 3 | High-DPI handling | | positioning | "auto" | "glyph" per character, "run" per run, "auto" picks | | correctWidth | true | Scale runs to the PDF's declared advance | | renderInvisible | false | Draw render modes 3 and 7 (OCR layers) | | background | "#ffffff" | Or null for transparent | | debugBoxes | false | Outline every run | | cull | true | Skip items outside the canvas |

pageToCanvas / canvasToPage convert between spaces, and hitTest(cssX, cssY) returns the run under a point — enough to build selection or annotation on top.

Scope

The renderer draws text only, since that is what the extractor produces. Paths, shadings and images are not painted. It also does not load the PDF's embedded font programs; it substitutes system fonts and corrects the widths.

Interactive annotation overlay (React)

AnnotationOverlay is an SVG layer that sits on top of the rendered page and handles ink, text selection and text boxes. React is a peer dependency — the parsing engine itself still has none.

React lives behind its own entry point so the core barrel stays importable in a browser, a worker or Node with nothing installed:

import { PDFDocument, extractPageText, CanvasTextRenderer } from "docedit";
import { AnnotationOverlay } from "docedit/react";

const items = extractPageText(doc, page, { includeGlyphs: true });

<div style={{ position: "relative" }}>
  <canvas ref={canvasRef} />
  <AnnotationOverlay
    page={{ index: page.index, width: page.width, height: page.height }}
    items={items}
    scale={scale}
    rotation={rotation}
    tool={tool}                       // "pen" | "highlighter" | "select" | "textbox" | "eraser"
    toolOptions={{ color: "#e11d48", strokeWidth: 2 }}
    onChange={(annotations, doc) => save(doc)}
    onSelectionChange={(sel) => setSelection(sel)}
  />
</div>

Pass the overlay the same scale and rotation you gave the renderer. Both resolve them through the shared computeViewMatrix, so the layers cannot drift apart — a unit test asserts they agree point-for-point.

Try it: npm run build && npm run serve and open examples/viewer.html.

The JSON it produces

{
  "version": 1,
  "space": "pdf-page",
  "page": { "index": 0, "width": 612, "height": 792 },
  "annotations": [
    {
      "id": "ink_...", "type": "ink", "tool": "pen",
      "color": "#e11d48", "opacity": 1, "strokeWidth": 2,
      "paths": [{ "points": [[100, 400], [200, 400]] }],
      "bbox": [99, 399, 201, 401],
      "createdAt": "2026-07-29T16:20:11.004Z"
    },
    {
      "id": "hl_...", "type": "textHighlight",
      "color": "#facc15", "opacity": 0.4,
      "quads": [[100, 697, 124.67, 709]],
      "text": "ABC",
      "refs": [{ "itemIndex": 0, "start": 0, "end": 3 }],
      "bbox": [100, 697, 124.67, 709]
    },
    {
      "id": "box_...", "type": "textBox",
      "rect": [200, 300, 360, 340],
      "text": "", "fontSize": 12, "color": "#111827"
    }
  ]
}

Every coordinate is in PDF page space — points (1/72"), origin lower-left, Y up — which is the single decision the whole design rests on. Screen coordinates would silently detach from the content the moment the user zoomed, rotated the page, or opened the file on a different display. Page coordinates make the JSON a property of the document: portable between viewers, stable across sessions, and mapping straight onto PDF annotation dictionaries, where /Rect, /QuadPoints and /InkList use exactly this space.

A browser test asserts this directly: the same physical spot drawn at 100% zoom, at 200% zoom, and on a 90°-rotated page all produce the same stored coordinates.

The three interactions

Freehand inkpen and highlighter. Highlighter is wide, translucent and multiply-blended, and renders beneath the pen layer. Strokes are simplified with Ramer–Douglas–Peucker on commit: a one-second stroke is several hundred pointer samples, nearly all redundant, and this typically removes 80–90% of them while keeping every point within simplifyTolerance page units of the original. What survives is drawn as a Catmull-Rom spline so simplification does not make the ink look faceted. Stroke width is stored in page units, so it scales with zoom like real ink.

Text selection — flow selection over the extracted runs, the way dragging in a viewer behaves. Runs arrive in drawing order, so buildGlyphIndex regroups characters into lines by baseline and orders them for reading; dragging then selects an inclusive range. Double-click takes the word, triple-click the line, Alt-drag switches to a rectangular marquee. The result carries the text, one merged quad per line, and refs giving character ranges back into the original runs.

Text boxes — drag out a rectangle, then type. Editing uses a real <textarea> positioned over the box rather than SVG text, so the caret, IME and wrapping all behave.

Why SVG rather than a second canvas

Annotations are persistent, individually selectable objects, not pixels. As SVG they stay crisp at any zoom and on any display with no device-pixel-ratio handling at all, they hit-test natively, and changing one shape does not repaint the layer. The one case a canvas would win — hundreds of points streaming in during a single stroke — is handled instead by writing the in-progress stroke directly to one path element's d attribute through a ref, so no React render happens per pointer sample; state is touched once, on pointerup.

Input goes through Pointer Events, so mouse, touch and stylus share one code path and pressure comes along for free. setPointerCapture keeps a stroke alive when the pointer leaves the element, and touch-action: none stops the browser scrolling the page out from under a finger drag.

Imperative handle

const ref = useRef<AnnotationOverlayHandle>(null);

ref.current.toJSON();               // the full document, ready to serialise
ref.current.highlightSelection();   // commit the live selection as a highlight
ref.current.undo();
ref.current.clear();
ref.current.pageToCss(x, y);        // position your own UI over the page

Writing: incremental updates

appendAnnotations takes the original file plus the annotation JSON and returns a new file with an incremental update appended.

import { appendAnnotations, toArrayBuffer } from "docedit";

const res = appendAnnotations(originalArrayBuffer, annotationDoc);
// { appended: 2361, objects: [3,12,...], xrefStyle: "table",
//   rebuiltXRef: false, annotations: 3 }

// Browser download:
const blob = new Blob([toArrayBuffer(res)], { type: "application/pdf" });

Append-only

<the original file, verbatim — not one byte changed>
<new and replaced objects>
xref                       covering only what changed
trailer << ... /Prev <previous xref offset> >>
startxref
<offset of the xref just written>
%%EOF

This is not a stylistic choice. Appending is what keeps an existing digital signature over the earlier revision valid, and it means every prior revision stays recoverable from the same file. A test asserts the original is a byte-identical prefix of the output — on every file in the corpus.

Three details that decide whether the result opens elsewhere

  • Offsets are measured from %PDF-, not byte 0. Files served over HTTP often carry bytes in front of the header.
  • /Prev must be the previous startxref value in that same header-relative space.
  • The update must match the original's cross-reference form. A classic table has no way to say "this object lives inside an object stream", so a PDF 1.5+ file has to be extended with another cross-reference stream. The writer detects which form the file uses and follows it.

Cross-reference streams are written uncompressed. /Filter is optional on them, so skipping it costs a few hundred bytes and removes any need for a deflate encoder — the file is still fully conforming.

What it emits

| Our type | PDF subtype | Carries | | --- | --- | --- | | ink | /Ink (§12.5.6.13) | /InkList, /C, /CA, /BS | | textHighlight | /Highlight (§12.5.6.10) | /QuadPoints, /C, /Contents | | textBox | /FreeText (§12.5.6.6) | /Contents, /DA, /DR |

Standard subtypes, so the output opens as annotations in any viewer rather than being a private format. Each one also gets an /AP appearance stream: the spec lets a viewer synthesise an appearance, but says it may instead require /AP, and several (including pdf.js) draw nothing without one.

Two traps worth naming. /QuadPoints is written as (upper-left, upper-right, lower-left, lower-right) — the spec's own wording implies counter-clockwise, but every real producer and consumer uses this order, and following the literal text produces highlights that other viewers draw as bow-ties. And annotation coordinates are converted back to raw user space by inverting pageCoordinateMatrix — the same function extraction used — because /Rect and /QuadPoints are in raw user space while extracted coordinates are crop-box-normalised and /Rotate-applied.

Refusals

Encrypted documents are refused, not silently corrupted: new strings and streams would have to be encrypted with the document key, and writing them in the clear produces a file that opens and then shows garbage.

If the original's cross-reference data is unusable, the writer cannot chain to it — /Prev would point at nothing — so it publishes a complete self-sufficient section instead and reports rebuiltXRef: true.

Merging documents

mergeDocuments appends the pages of one or more source files to a base file, and returns the combined document.

import { mergeDocuments, toArrayBuffer } from "docedit";

const res = mergeDocuments(baseArrayBuffer, [
  { bytes: coverPdf },                     // all of it
  { bytes: reportPdf, pages: [0, 2, 3] },  // selected pages, in this order
]);

// { pageCount: 12, imported: 4, appended: 48211, dropped: [], renamedFields: [],
//   objects: [...], xrefStyle: "table", rebuiltXRef: false }

const blob = new Blob([toArrayBuffer(res)], { type: "application/pdf" });

pages is 0-based and takes the pages in the order given, so it selects and reorders in one step. An out-of-range index throws rather than being skipped: a caller asking for page 12 of a 5-page file has a bug, and dropping it silently hides it.

It is still an incremental update

The base file is not rewritten. Merge appends, exactly as appendAnnotations does, so the same guarantee holds — the original is a byte-identical prefix of the output, and a test asserts it on every file in the corpus. Only the imported objects and the rewritten page tree are new bytes.

Streams are copied as raw encoded bytes, never decoded and re-encoded. A source using a filter this engine does not implement therefore survives intact, because nothing ever looked inside it.

Three things that make copying a page harder than it sounds

A PDF object graph has cycles. A page's /Annots point back at the page through /P. The copier memoises on the source reference and records the new object number before recursing, so a cycle terminates the second time round. Without that it does not merely duplicate work — it never returns.

Inherited attributes must be materialised. /Resources, /MediaBox, /CropBox and /Rotate can live on an ancestor node the imported page is about to be detached from. They are resolved onto the page as it is re-parented, taking the raw value rather than the resolved one so that a shared resource dictionary is copied once instead of once per page.

Two form fields with the same name are ONE field. Not a collision a viewer resolves — a single field with a shared value, so typing in one fills the other. Merging two copies of the same form is exactly when this happens, so imported fields are renamed by default and reported back:

mergeDocuments(base, sources, { onFieldConflict: "rename" }); // default
// renamedFields: [["signature", "signature_2"]]

"keep" links them deliberately — occasionally what you want, when the same field really should hold one value. "error" refuses the merge.

/Kids is the trap underneath: a kid carrying /T is a child field, and a kid without one is that field's own widget. Treating them alike either loses every radio button or invents a field per widget.

What does not come across

Reported in dropped rather than buried in a changelog. Document-level features are keyed to a document, and merging two of them means choosing or rewriting rather than copying:

  • outlines (bookmarks), page labels, article threads
  • named destinations — and therefore any link that targets one by name
  • the logical structure tree, so merged output is not tagged
  • optional-content configuration

Links with explicit destinations, naming a page object directly, do survive: that page is copied too and the reference is remapped with everything else.

if (res.dropped.length) {
  // ["outlines (bookmarks)", "named destinations"]
}

The cost is size

Output is roughly the sum of its inputs. Nothing is deduplicated across documents, so two files embedding the same font ship it twice.

That is the deliberate trade for a first implementation: a merge that produces a bigger file is a nuisance, and a merge that quietly drops a page's resources is a data-loss bug.

Refusals

Encrypted documents are refused on both sides. Merging into one would require encrypting the appended objects with the document key; merging from one would copy ciphertext into a file with a different key, producing a document that opens and then shows nothing.

DOCX / OpenXML

import { parseDocx } from "docedit/docx";

const doc = parseDocx(arrayBuffer);
doc.blocks     // paragraphs and tables, in order
doc.sections   // page size, margins, orientation, columns
doc.headers    // header/footer parts
doc.text       // plain text
doc.metadata   // title, creator, dates

A .docx is a ZIP of XML parts, so this needs three things that did not exist yet: a ZIP reader, an XML parser, and the OOXML model itself.

The ZIP reader

No DecompressionStream needed — entries are raw DEFLATE and src/inflate.ts, written for PDF's /FlateDecode, decodes them unchanged. extractEntryAsync uses the platform's DecompressionStream when you want native speed, falling back automatically.

Two details that decide whether real archives read:

  • Read from the END. The authoritative index is the central directory at the tail. Local file headers are a partial duplicate whose size fields are allowed to be zero, so walking them front-to-back — the intuitive approach — breaks on anything written by a streaming producer.
  • The EOCD signature occurs by chance inside compressed data, so the backwards scan verifies that the declared comment length actually reaches the end of the file before accepting a match.

ZIP64, stored and deflate methods, and CRC-32 verification are all handled; a corrupt entry raises rather than silently returning garbage.

The XML parser

Namespace-aware, because OOXML prefixes are not fixed: a conforming producer may bind the WordprocessingML namespace to any prefix, so matching the literal string "w:p" is a latent bug that surfaces on files from less common tools. Elements carry a resolved namespace URI and a local name, and attr(node, "val") looks up by local name.

Iterative rather than recursive (a test parses 20 000 levels of nesting), handles CDATA, comments, DOCTYPE with an internal subset, character and named entities, and > inside quoted attribute values. It does not resolve external entities — which is also how XXE attacks work, so that is a feature.

The document model

Everything is normalised to points. OOXML measures one document in five units — twips, half-points, eighths of a point, EMU, and fiftieths of a percent — depending on which attribute you read, and a consumer should not have to know which is which.

| Covered | | | --- | --- | | Paragraphs | alignment, indents, spacing, borders, shading, tabs, keep-with-next | | Runs | bold, italic, underline, strike, caps, colour, highlight, size, spacing, super/subscript | | Fonts | w:rFonts plus theme resolution (minorHAnsi → the real typeface from theme1.xml) | | Tables | grid, widths, borders, cell margins, shading, gridSpan, vMerge, nesting, header rows | | Sections | page size, orientation, all six margins, columns | | Lists | numbering definitions and running counters, so each item gets its rendered marker | | Images | relationship id, extent in points, alt text | | Metadata | title, creator, dates, application |

The style cascade is resolved, so the emitted JSON holds final values and no consumer has to walk w:basedOn:

w:docDefaults  →  style chain (root-first, through basedOn)  →  direct formatting

For a run that is: defaults → the paragraph style's w:rPr → the character style → the run's own w:rPr. An explicit <w:b w:val="0"/> must switch a style's bold off rather than being ignored, which is why "not specified" and "specified false" are distinct throughout.

Lists get real markers. A paragraph carries only a pointer into numbering.xml; the visible "2." or "b)" exists only once something counts the paragraphs. Numbering acts as that counter, resetting deeper levels when a shallower one increments. Each placeholder in a template like %1.%2) renders in the format of the level it refers to, not the level being numbered — get that wrong and a lettered sub-list under a numbered list produces "b.a)" instead of "2.a)".

Where content hides

Three wrappers silently swallow body text if you only look for w:r children of w:p, and all three are walked into:

  • w:hyperlink, w:sdt (content controls), w:smartTag, w:ins
  • w:txbxContent — text boxes anchored in shapes. In converted documents this can be most of the page.
  • mc:AlternateContent — which holds the same content twice, in mc:Choice and mc:Fallback. Exactly one is read; taking both duplicates every shape in the document.

Tracked deletions and hidden (w:vanish) text are excluded by default and available behind includeDeleted / includeHidden.

Paginated DOCX rendering

DocxPages renders a parsed document as real A4 sheets, with page breaks computed from measured heights.

import { parseDocx } from "docedit/docx";
import { DocxPages } from "docedit/react";

const doc = parseDocx(arrayBuffer);

<DocxPages
  doc={doc}
  zoom={1}
  resolveImage={(relId) => imageUrls.get(relId)}
  onPaginate={(pages) => setPageCount(pages.length)}
/>

Try it: npm run build && npm run serve, then open examples/docx.html.

Two passes, because heights are not knowable in advance

  1. Every block is rendered once into a hidden layer exactly as wide as the section's content area, and its height is read back from the DOM.
  2. paginate() — a pure function — assigns those heights to pages. The visible sheets are rendered from that plan.

Keeping pagination a function of numbers means the interesting logic is testable without a browser; 42 assertions cover it directly.

Two measurement decisions do most of the work:

  • Block heights come from the distance between successive block tops, not from offsetHeight. That single choice makes margin collapsing come out exactly as the browser computes it — offsetHeight excludes margins and would be wrong for every paragraph with spacing.
  • Paragraphs split at real line boxes from Range.getClientRects(), and the break point is mapped back to a character offset by binary search. Estimating lines from character counts drifts on any document with mixed fonts or sizes. Only paragraphs that actually straddle a break pay for the search.

Pagination re-runs once document.fonts.ready resolves, because text laid out in a fallback face breaks lines differently and any plan computed before that is wrong.

What the page-break logic handles

| | | | --- | --- | | Paragraph splitting | at line boundaries, with the remainder continuing overleaf | | Table splitting | by row, repeating w:tblHeader rows on continuation pages | | w:pageBreakBefore | forces a new page (and does not add a blank first page) | | w:keepNext | moves a heading down with the block it introduces | | w:keepLines | moves the whole paragraph rather than splitting it | | Widow/orphan | never strands fewer than 2 lines either side; switchable | | Sections | each starts a new page and may change size, margins and orientation | | Oversized content | a row or image taller than a page is placed, not dropped |

Rendering

Runs carry bold, italic, underline (including style and colour), strike, caps, small caps, colour, highlight, size, letter spacing and super/subscript. Paragraphs carry alignment, indents (with hanging indents as a negative first line), spacing, borders and shading. Tables use table-layout: fixed with a <colgroup> from w:tblGrid so columns keep their Word widths instead of being re-fitted to content, and gridSpan / vMerge become colSpan / rowSpan. List markers are positioned in the hanging indent.

Everything is emitted in pt, because the parsed model is already in points and browsers accept pt natively — so a sheet declared 595.3pt wide really is A4 at 100% zoom, with no conversion factor to get wrong.

XLSX / SpreadsheetML

import { parseXlsx, toTextMatrix, toCsv } from "docedit/xlsx";

const wb = parseXlsx(arrayBuffer);
const sheet = wb.sheets[0];

sheet.cells[0][0]        // the Cell at A1, or null
sheet.cell("B2")         // by address
sheet.dimension.ref      // "A1:S11"
toTextMatrix(sheet)      // string[][] of formatted display text

Each cell carries its address, 0-based row/col, type, raw text, typed value, formatted display text, formula and resolved style:

{
  "address": "C3", "row": 2, "col": 2,
  "type": "date", "raw": "45000",
  "value": "2023-03-15T00:00:00.000Z", "text": "2023-03-15",
  "formula": { "text": "B5*2", "kind": "shared", "si": 0 },
  "styleIndex": 3,
  "style": { "numFmtId": 164, "numFmtCode": "yyyy\\-mm\\-dd", "isDate": true,
             "font": { "name": "Arial", "size": 14, "bold": true },
             "fill": { "pattern": "solid", "fgColor": "#ffff00" },
             "alignment": { "horizontal": "center", "wrapText": true } }
}

The matrix is dense over the used rangecells[0][0] is always A1 and empty cells are null — while the file itself is sparse. The declared <dimension> is treated as a hint and unioned with the cells actually present, because producers get it wrong and a cell outside it would otherwise be dropped.

Four things that decide whether a workbook reads correctly

  • A number has no type of its own. 45000 is a quantity or 15 March 2023 depending entirely on the number format attached to its style. So date detection means resolving scellXfsnumFmtId → format code, and a parser that skips styles cannot type its own output.
  • The 1900 leap-year bug. Excel treats 1900 as a leap year, so serial 60 is "29 February 1900" — a date that never existed. Serials at or above 61 are one day ahead of a naive count.
  • The 1904 date system. Old Mac workbooks set date1904, shifting the epoch by 1462 days. Ignoring it puts every date four years and a day out.
  • Shared formulas are stored once. Every cell but the master carries <f t="shared" si="N"/> with no text at all; the formula has to be re-derived by shifting the master's relative references — absolute $ parts staying put. Without this, a filled-down column reports no formulas, which in a real spreadsheet is most of them.

Also handled

Cell types (n, s, str, b, e, d, inlineStr), shared strings with rich-text runs, sheets located through the relationship graph (sheet order has no connection to sheetN.xml numbering), absolute relationship targets, r attributes omitted on rows and cells (position from document order), formatting-only cells, merged ranges, column widths, row heights, frozen panes, defined names, hidden sheets, theme colours with tints, and the legacy indexed palette.

A number-format renderer produces the text field: General, the four ;-separated sections with colours, decimals, thousands separators, percent, scientific notation, and date/time tokens including elapsed [h]. Fraction formats (# ?/?) and locale ids fall back to a plain rendering rather than being approximated wrongly.

Canvas spreadsheet grid + formula engine

import { GridModel } from "docedit/xlsx";
import { CanvasGrid } from "docedit/react";

const model = new GridModel();               // 1,048,576 x 16,384 by default
model.setByAddress("A1", "10");
model.setByAddress("A2", "20");
model.setByAddress("A3", "=SUM(A1:A2)");     // -> 30

<CanvasGrid model={model} onSelectionChange={setSelection} />

Try it: npm run build && npm run serve, then open examples/grid.html — it has demo data, a 100k-row load test, and a button to load a real .xlsx straight into the grid.

The grid

A native scroll container provides the scrollbars and the scroll position; a canvas sized to the viewport (never to the content) is repainted on scroll. So a frame costs what is on screen, not what exists: a browser test confirms that a 1,048,576-row sheet considers fewer than 500 cells per frame, at the top and equally at row 500,000.

Three decisions carry the performance:

  • Selection and scroll live in refs, not state. A drag fires at pointer rate and a scroll at frame rate; routing those through React state means a reconcile per event. The refs are mutated and a repaint is queued for the next frame instead, so React re-renders only when a DOM element actually depends on the change.
  • Draw calls are batched by style, not by cell. All grid lines are one path with one stroke; cell backgrounds are grouped by colour. Assigning fillStyle per cell is the most expensive thing a canvas grid can do.
  • The spacer is clamped. A 1M-row sheet is 21M pixels tall and browsers cap element height, so the spacer stops at 15M and scroll positions map through the clamp — measured against the scrollable ranges, not the total sizes, or the last rows can never be reached.

Handled: click and drag selection, arrow/shift-arrow/Tab/Home/End/PageUp/PageDown/ ctrl-arrow navigation, type-to-edit and double-click-to-edit with an overlaid input, Delete over a range, DPR-scaled crisp text, frozen headers with the selection highlighted, light and dark themes, and per-row/column sizes.

The formula engine

=SUM(), =AVERAGE() and the arithmetic operators as asked, plus the immediate neighbours a grid needs to be usable: MIN, MAX, COUNT, COUNTA, COUNTBLANK, PRODUCT, MEDIAN, ABS, SQRT, INT, ROUND/UP/DOWN, POWER, MOD, IF, IFERROR, AND, OR, NOT, CONCAT, LEN, UPPER, LOWER, TRIM, IS*, ROWS, COLUMNS.

Two Excel grammar quirks that a conventional expression parser gets wrong, both implemented and tested:

  • Unary minus binds tighter than ^. -2^2 is 4, not -4. Every C-family language does the opposite.
  • % is postfix and binds tightest. 50% is 0.5 and 50%^2 is 0.25.

And the semantics that separate an evaluator from a spreadsheet:

  • Errors propagate, first one wins. #N/A + 1/0 is #N/A.
  • Empty is 0 in arithmetic but "" in concatenation.
  • Range aggregation ignores text; a direct argument is coerced. SUM(A1:A3) skips a cell holding "abc", but SUM("2",3) is 5 and SUM("abc",3) is #VALUE!. Collapsing those two paths is the most common way an engine drifts from Excel.
  • AVERAGE divides by the count of numbers, not of cells.
  • IF is lazyIF(TRUE,1,1/0) is 1, not an error.
  • Comparison does not coerce across types: number < text < boolean, so 1<"a" is TRUE, and text compares case-insensitively.

Dependency-driven recalculation

GridModel is not just an evaluator. It keeps a dependency graph and recomputes only the affected subgraph, returning the list of cells whose value changed so a renderer can repaint just those. A full sweep of every formula on every keystroke is what makes a naive grid unusable.

Two details matter:

  • Range dependencies are tracked positionally. A formula reading A1:A100 must recompute when A50 is first written — and A50 has no entry to hang a dependency on until then.
  • Cycles are detected, not recursed. A1: =A1 yields #CIRCULAR!, and breaking the cycle recovers rather than staying poisoned.

React Native (Skia)

docedit/native runs the engine on a phone. The headline is what isn't here:

There is no native module. No C++ port, no JSI bindings, no WASM blob. src/ is pure TypeScript that touches only ArrayBuffer, DataView, Uint8Array, Map and TextDecoder — all of which Hermes provides — so the parser, the from-scratch inflater, the font metrics, the text extractor and the incremental writer already run on device at full speed with zero bridge traffic. A C++ port would mean maintaining a second ISO 32000-1 implementation and serialising every result across the JSI boundary, to solve a problem that isn't the bottleneck.

The actual mobile problem is threading: synchronous work on the JS thread stalls the UI. So the design separates the two:

| | runs on | may block | |---|---|---| | gesture → transform → Skia matrix | UI thread (Reanimated worklets) | never | | text extraction, picture recording | JS thread | briefly, off the gesture path |

Rendering: the Canvas 2D adapter

CanvasTextRenderer was written against a structural Canvas 2D interface rather than the DOM. SkiaCanvas2D implements that interface on top of an SkCanvas, so the entire rendering path — view matrices, page rotation, text render modes, per-glyph placement, the substitute-font width correction — is reused unmodified. There is no second renderer to keep in sync.

Three mismatches had to be handled:

  • Matrix convention. Canvas 2D maps row vectors (x' = a·x + c·y + e); Skia maps column vectors. toSkiaMatrix transposes the shear terms and moves the translation to the third column. Getting this wrong translates the page by a scaled amount so it slides off screen as you zoom.
  • setTransform vs concat. Canvas 2D replaces the CTM; Skia only composes. Resetting Skia's CTM would also discard the active clip, since clips and transforms share one stack — so the adapter keeps Skia's CTM at identity and wraps each draw in save/concat/restore, pre-transforming clip paths in JS instead. Clips therefore persist across draws exactly as the spec requires.
  • DPR. The renderer folds the device pixel ratio into its transform for a high-resolution web backing store. A Skia surface already has the screen's density baked in, so drawPage pins dpr: 1. Applying it twice renders the page at 2x or 3x its correct size — the most common mistake when porting a web canvas renderer to Skia.

Why pinch stays smooth

A page is recorded once into an SkPicture — a display list of vector draw calls — and the gesture animates a matrix applied to that picture:

  • The gesture never re-runs extraction or layout. The transform lives in three Reanimated shared values that Skia reads on the UI thread; a pinch stays at full frame rate even while the JS thread is parsing another page.
  • A picture is vector, not raster, so replaying it at 4x zoom re-rasterises the glyphs at 4x. The page is sharp at every scale with no re-record and no blurry interstitial frame.

Snapshotting to an SkImage instead is cheaper per frame but goes soft past the raster scale, so it's offered rather than default; quantizeRasterScale and needsReraster drive it for pathologically heavy pages. Rounding the raster scale up a geometric ladder means content is never blurry (only over-sampled, which is invisible), and a 1x→16x pinch costs 9 rasters instead of hundreds.

Usage

import { NativePdf } from "docedit/native";
import { PdfView } from "docedit/native/view";

const pdf = await NativePdf.load({ uri: fileUri });   // or { base64 }, ArrayBuffer, Uint8Array

<PdfView
  pdf={pdf}
  fit="width"
  scaleLimits={{ min: 0.5, max: 8 }}
  onPageChange={(i) => setPage(i + 1)}
  onTapPage={({ page, x, y }) => addAnnotation(page, x, y)}
/>

onTapPage reports the tap in that page's own PDF user space (y up from the bottom-left), ready to feed straight into the annotation model from docedit/react and then into appendAnnotationsNativePdf retains the original bytes precisely so the incremental writer can append to them.

Peers, needed only for docedit/native/view: @shopify/react-native-skia, react-native-gesture-handler, react-native-reanimated, react-native. The docedit/native entry point itself imports none of them, and a test walks the import graph to keep that true.

Fonts are supplied by the host, because how you obtain a typeface differs completely between setups (system font manager, a bundled .ttf via useFont, a preloaded atlas):

const fontMgr = Skia.FontMgr.System();
<PdfView pdf={pdf} render={{ fontResolver: ({ families, size, weight, style }) => {
  for (const family of families) {
    const tf = fontMgr.matchFamilyStyle(family, { weight, slant: style === "italic" ? 1 : 0 });
    if (tf) return Skia.Font(tf, size);
  }
  return null;
} }} />

Loading large documents without stalling

PDFDocument.load is cheap by construction — header, xref chain, catalog; it never touches content streams — so it is safe to call inline. Extraction is per-page, lazy and cached. For heavier work:

await pdf.prefetch([2, 3, 4]);                       // yields to the event loop between pages
for await (const { index, items } of pdf.extractPagesIncremental()) { /* search */ }
pdf.releaseText([currentPage]);                      // free the rest

To move parsing off the JS thread entirely, hand it to a Reanimated worklet runtime — a real second JS thread. The engine needs no adaptation because it has no environment dependencies to satisfy:

import { createWorkletRuntime, runOnRuntime } from "react-native-reanimated";
const parseRuntime = createWorkletRuntime("pdf-parse");
const runner: TaskRunner = (work) => runOnRuntime(parseRuntime, work)();

Touch annotation

docedit/native/annotate captures stylus and finger input, converts it to PDF page space, and keeps it in a store built for a mobile app's lifecycle.

Ink latency is the whole problem. A 100ms delay in a scroll is barely noticed; the same delay on a pen stroke makes the line visibly trail the finger. And the JS thread is where a PDF viewer is busiest. So a stroke is captured entirely on the UI thread, in worklets:

touch sample → pageAtViewPoint (worklet) → distance threshold (worklet)
             → appended to a shared array → Skia rebuilds the path (worklet)

No runOnJS per sample, no React render per sample. The JS thread is touched once per stroke, on release, with the whole point array — and that is where the One Euro filter, RDP simplification and the store commit happen. The consequence, stated plainly: the live preview is the raw polyline and what gets stored is the filtered, simplified path, so the stroke shifts very slightly when you lift the pen.

One finger draws, two navigate. That can't be done with two sibling GestureDetectors — both would claim the one-finger drag and the resolution is not deterministic. The controller exposes a gesture that PdfView composes into its single detector, and the pan gesture switches to minPointers(2) while a drawing tool is active.

Four problems are specific to touch and are why this is not just "append the points to an array":

  • Sample rate. 60–120 events/second, many a fraction of a point apart. A distance threshold in screen units (converted to page units by the live scale) keeps fidelity constant at any zoom — 120 samples over 6pt collapse to ~6 points, and zooming to 8x captures ~8x the detail.
  • Jitter vs lag. A fingertip's centroid wobbles visibly at rest. Fixed smoothing fixes that and adds lag, which is worse. OneEuroFilter smooths as a function of speed: measured, 73% of the jitter removed at rest for 1.7pt of lag at writing speed.
  • Palm rejection. A stylus puts a hand on the screen. shouldRejectAsPalm is a single worklet-safe function used by both the UI-thread path and the JS-side PointerArbiter, so they cannot drift apart.
  • Pinch onset. The two fingers of a pinch land 10–80ms apart, and a naive controller has already started a stroke. Handled by the recogniser rather than a timer: maxPointers(1) means a second finger cancels, and onEnd discards the stroke on success: false.

Palm rejection has a real limit. iOS and Android both expose contact patch size (UITouch.majorRadius, MotionEvent.getSize) — the strongest palm signal, since a palm is simply much larger than a fingertip. React Native does not surface it and neither does Gesture Handler. So rejection uses pointer type and timing only: reliable when a stylus identifies itself, and not attempted for palm-vs-finger with no stylus involved.

The store is designed around three mobile constraints:

  • The app can die at any moment. State persists continuously, debounced (800ms default), with flush() for an AppState background handler. The debounce window is the exposure: a crash loses at most the last stroke.
  • Undo must not cost memory. Snapshotting the document per stroke is the obvious implementation and the wrong one. Inverse commands are stored instead, so an undo entry costs one annotation. A removal records its index, so undo restores z-order rather than re-appending.
  • React must not re-render per sample. The store is an external store; useSyncExternalStore reads a numeric revision, so a commit repaints and a sample does not.
const viewport = useViewportController();          // shared, so both read the LIVE transform
const { store } = useAnnotationStore({ docId, storage, storageKey: `annotations:${docId}` });
const revision = useAnnotationRevision(store);
const controller = useAnnotationController({ store, layout: pdf.layout, viewport, settings: PEN });

<PdfView pdf={pdf} viewport={viewport} annotation={controller}
  overlay={(pages) => (
    <AnnotationLayer store={store} layout={pdf.layout} pages={pages}
      live={controller.live} settings={controller.settings} revision={revision} />
  )} />

Everything is stored in PDF page space (points, origin lower-left, Y up) — the same model as docedit/react, so store.documents(...) feeds straight into appendAnnotations and the exported file is byte-identical to the original up to the appended section. A full-width example is in examples/native/PdfScreen.tsx.

Erasing splits paths rather than deleting them: an eraser through the middle of a stroke leaves two runs, which is the difference between erasing a typo and losing the sentence it was in. One drag across eight strokes is one undo step.

Not implemented: variable-width ink. Pressure is captured, stored and exported, and widthForPressure maps it — but the renderer draws each stroke at one constant width. True pressure-varying ink needs a filled outline path per stroke rather than a stroked polyline, which is a substantially larger piece of work. Text-box annotations render their box but not their text, which needs a resolved SkFont from the host.

What is and isn't verified

npm run test:native — 426 assertions, all passing, in Node with no RN packages installed:

  • Gesture maths, tested against defining invariants rather than recorded output: the pinch focal point stays put to within 1e-6 px over 50k randomised cases; clamping keeps content covering the viewport over 20k cases with zero violations; pinch is frame-count independent, so the same physical gesture lands identically on a slow device.
  • The Skia adapter, driven by the real CanvasTextRenderer rendering a real parsed PDF into a recording mock SkCanvas. The mock reimplements Skia's column-vector matrix maths independently, so the row→column conversion is checked against a second implementation rather than against itself: all 17 runs land inside the page box, the Y flip is confirmed (PDF y=700 → device y=92), and all four run colours reach a Skia paint.
  • The input pipeline against its actual metrics, not its shape: jitter reduction and lag are measured; simplification is checked to collapse a straight line and preserve a corner vertex; pressure is checked to stay index-aligned with its point across simplification and across an eraser split.
  • Touch → page conversion cross-checked against the independently written viewToPagePoint over 20k randomised zoom/pan states, plus the Y flip asserted explicitly at both page corners.
  • The store: undo restores z-order rather than append order, a throwing transaction rolls back, an eraser drag across five strokes is one undo step, a failed write leaves the store dirty so it retries, and a snapshot from another document is refused.
  • End to end: 120 synthetic jittery finger samples at 2x zoom → converted → captured → committed → persisted → reloaded → exported → written into a real PDF with appendAnnotations, asserting the original bytes are an untouched prefix and the result still parses.
  • Base64 against node:Buffer over 500 random buffers.
  • The import graph, proving docedit/native, ink.ts and annotation-store.ts reach no RN peer and no Node builtin.

Not verified: that Skia, Reanimated and Gesture Handler behave on a device as view.tsx assumes. That needs hardware and has not been run on any. The component is written against those libraries' documented APIs and typechecked against stubs in types/native-peers.d.ts (build-time only, never shipped) — a deliberate check that catches a wrong argument type, but not a wrong assumption about runtime behaviour. Frame rates quoted above are design properties of the architecture, not measurements.

Real bugs the tests caught, all in the code rather than the tests:

  • softClamp used a power law for pinch resistance. Monotonic and damped, but unbounded — a hard pinch against a 10x limit reached 114x. That is a slow-motion runaway, not a rubber band. Now saturating, asymptotic at 1.6x.
  • decodeBase64 sized its output from whole 4-character groups, silently dropping the tail of unpadded base64 ("QQ" decoded to nothing). Native file pickers do emit unpadded base64.
  • The One Euro filter's beta defaulted to the published 0.9, which is wrong for point-scale coordinates. beta has units of Hz per (unit/second), so it is scale-dependent: at rest, ±0.5pt of sensor noise looks like ~60pt/s of speed, drives the cutoff to ~57Hz, and lets the noise straight through. Measured, it removed only 28% of the jitter. Swept against real metrics and set to 0.05 — 73% removed for 1.7pt of lag.
  • Skia.Path() was assumed callable. React Native Skia exposes Skia.Path.Make() — a namespace with a factory. Both shapes are now accepted, because the failure mode is a TypeError at the first draw with no useful stack.

One design error caught while writing, worth recording: the first version of the controller called a JS-side PointerArbiter from the gesture worklet to decide palm rejection. runOnJS is fire-and-forget, so it cannot gate anything — the "decision" was a no-op wrapper that always returned true. The rule now lives in a worklet-safe function evaluated on the UI thread, shared with the JS arbiter.

WebViewer SDK (@ourcompany/webviewer)

A drop-in embeddable viewer that boots the canvas engines inside a Shadow DOM.

import { WebViewer } from "@ourcompany/webviewer";

const viewer = await WebViewer({
  licenseKey: "ov1....",
  container: "#viewer",        // element or selector; MUST have a height
  documentUrl: "/reports/q3.pdf",
});

viewer.goToPage(4);
viewer.fit("width");
viewer.getText();              // whole document, or getText(2) for one page
viewer.on("pagechange", ({ page }) => setPage(page));
viewer.destroy();              // leaves the container exactly as it was found

Or declaratively, which works identically in Angular, Vue, Svelte, Rails and a CMS template with no adapter:

<script type="module" src="/node_modules/@ourcompany/webviewer/dist/src/sdk/element.js"></script>
<ourco-webviewer license-key="ov1...." src="/reports/q3.pdf" style="display:block;height:80vh"></ourco-webviewer>

Package structure

| Entry point | Contains | Side effects | | --- | --- | --- | | @ourcompany/webviewer | WebViewer, licensing, shadow utilities | none | | @ourcompany/webviewer/element | registers <ourco-webviewer> | yes — that is the point |

sideEffects is an explicit array naming only the element entry, not a blanket false. Marking the whole package pure would let a bundler tree-shake the custom element registration away, and the element would silently never be defined.

The three engines are behind () => import("./pdf.js") and friends, so a bundler emits them as separate chunks. Verified in a browser: importing the barrel fetches zero engine chunks, opening a PDF fetches exactly one (pdf.js), and opening a spreadsheet afterwards adds xlsx.js while docx.js is never fetched at all. A test walks the import graph to keep that true — a stray import { PDFDocument } added for convenience would silently undo it.

Shadow DOM: what it does and does not isolate

"Shadow DOM avoids CSS conflicts" is about 80% true, and the missing 20% is what makes an embedded viewer look broken:

  • Blocked — selectors from the host page. .wv-toolbar { display: none !important } aimed at our own class name does nothing.
  • NOT blockedinherited properties. font-family, font-size, color, line-height, letter-spacing, text-align, visibility and cursor cross the boundary from the host element. A site with html { font-size: 62.5% } — an extremely common reset — shrinks every em inside the viewer, and no shadow root prevents it.

The fix is :host { all: initial }, which resets inherited properties too, then re-declares what the viewer needs. It has a sharp edge: all: initial makes the host display: inline, so display: block must be restated or the viewer has no box at all.

Two isolation bugs the browser test caught, both real:

  • box-sizing: inherit on descendants imported the host page's value. The host element is the one part of the viewer that lives in the customer's document, so their * { box-sizing: content-box !important } matches it and beats our :host rule — and inherit then carried that inside. Descendants now state border-box explicitly, which is unreachable from outside.
  • :host { --wv-accent: … } broke external theming. Declaring defaults under the same name the customer sets means the :host declaration overrides the inherited value, so setting --wv-accent on the container silently did nothing. Defaults are now --wv-*-default, consumed as var(--wv-accent, var(--wv-accent-default)).

Custom properties are the one channel that is supposed to cross, so container.style.setProperty("--wv-accent", "#16a34a") themes the viewer without piercing the boundary.

Also handled, because each is a real gotcha: events are dispatched with composed: true (without it they stop dead at the boundary, silently); @font-face must be injected at document level because font faces resolve against the document, not the shadow tree (injectDocumentFontFaces exists for that, and the default theme uses the system stack to avoid needing it); and shadowRoot.getSelection() is Chromium-only — Firefox and Safari expose no way to read a selection inside a shadow root, so selection-based highlighting falls back to hit-testing glyph boxes there.

Licensing

Keys are ov1.<payload>.<signature> — JWS-like but not JWS, because JWS's negotiable alg header is the origin of the classic alg: none forgery. The algorithm is fixed by the ov1 prefix and cannot be influenced by input.

Being blunt about what this can do: it runs on the customer's machine, in JavaScript the end user can edit. It cannot stop someone determined from deleting the check, and any vendor claiming otherwise is selling you something. What it does do properly is make keys unforgeable — each is signed with ECDSA P-256, so a customer cannot mint themselves a longer expiry or an extra feature tier, and a leaked key is traceable to the account it was issued to.

Enforcement is soft by default: an expired key watermarks and warns rather than breaking a paying customer's production site over a renewal. licenseMode: "strict" is available for those who want a hard failure.

node tools/mint-license.mjs keygen                        # once, on the licence server
node tools/mint-license.mjs sign --sub acme --org "Acme Corp" \
     --days 365 --domains "*.acme.com,acme.io" --features pdf,docx

Deliberate product decisions, all tested: *.acme.com matches subdomains at any depth and the apex (nobody licensing a wildcard means to exclude their own homepage); localhost, .local, .localhost and the private IPv4 ranges are always allowed so development never needs a key; and expiry has a one day clock-skew grace, because device clocks are wrong far more often than licences are forged and a false "expired" is the more expensive failure.

Verification

npm run test:sdk — 183 assertions in Node. The licensing tests generate a real ECDSA P-256 keypair per run and mint real keys through the SDK's own WebCrypto path; a forgery test that only defeats a mock proves nothing. Covered: a key from a second authority is rejected; extending the expiry or adding feat: ["*"] to a genuine payload breaks the signature; a single flipped signature bit fails; a rejected signature exposes no payload (it is attacker-controlled data); and *.acme.com matches neither evilacme.com nor acme.com.evil.net.

npm run sdk-browser — 58 assertions in headless Chrome against a genuinely hostile page. The suite first proves the host CSS does apply outside the viewer (otherwise the isolation assertions prove nothing), then that none of it applies inside; that the canvas has real ink on it (45,000 non-white pixels); that theming crosses; that events escape; that engines are code-split; and that destroy() empties the container.

Two bugs found by that suite are listed above. A third worth recording is a testing trap rather than a product one: the page is written inside a Node template literal, which processes escape sequences — so a regex /\/engines\// arrived in the browser as //engines/, a line comment, and the whole page silently stopped executing. Template literals eat backslashes as well as backticks.

Not verified: real bundler output. The code-splitting claim is verified by observing what the browser fetches over native ES modules, which is the same graph a bundler splits on — but no Rollup or esbuild build was run here, and no package was published.

examples/sdk.html is a runnable demo whose host page is deliberately hostile, so the isolation is visible rather than asserted.

Activation service (@ourcompany/webviewer-licence)

A Node service that verifies licence keys online, checks domain whitelisting, and returns a short-lived signed JWT the SDK verifies before running.

import express from "express";
import { ActivationService, MemoryStore, createExpressMiddleware,
         importServiceKeys } from "@ourcompany/webviewer-licence";

const service = new ActivationService({
  keys: await importServiceKeys(JSON.parse(process.env.ACTIVATION_KEYS)),
  store: new MemoryStore(licences),
  issuer: "https://licence.ourcompany.com",
});

express().use("/licence", createExpressMiddleware(service)).listen(4000);

Client side, it is one option:

await WebViewer({ container: "#v", licenseKey, activationUrl: "https://licence.ourcompany.com" });

A framework-free node:http server is included (listen(service), node dist/server/serve.js), and the core is a plain service.handle(request) → response, so Fastify/Koa/Hono/Lambda need a ten-line adapter rather than a rewrite. Express is not a dependency — its types here are structural, for the same reason the React Native ones are.

The correction this design makes to the brief

The brief has the client send domainOrigin in its payload. That value cannot be enforced against. It is a string chosen by code on the customer's machine; two lines of JavaScript make it say anything. A licence check that trusts it is decorative.

What page JavaScript cannot forge is the browser-set Origin header on a cross-origin request. So:

| | Source | Role | |---|---|---| | Origin header | the browser | enforced | | domainOrigin in the payload | the client | diagnostic only — a mismatch is reported, because it usually means a proxy is rewriting Origin |

The honest limit: a non-browser client (curl, a script) sends whatever Origin it likes, and no API can tell the difference. Domai