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
Maintainers
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-engineOne 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 orderPass { 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 —
/Encodingbase +/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/Vand embedded CMaps, CIDs via/W+/DW, text via/ToUnicode. - Type3 — glyph space taken from
/FontMatrixrather 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)--> deviceMetrics. 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 ink — pen 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 pageWriting: 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>
%%EOFThis 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. /Prevmust be the previousstartxrefvalue 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, datesA .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 formattingFor 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:insw:txbxContent— text boxes anchored in shapes. In converted documents this can be most of the page.mc:AlternateContent— which holds the same content twice, inmc:Choiceandmc: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
- 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.
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 —offsetHeightexcludes 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 textEach 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 range — cells[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.
45000is a quantity or 15 March 2023 depending entirely on the number format attached to its style. So date detection means resolvings→cellXfs→numFmtId→ 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
fillStyleper 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^2is 4, not -4. Every C-family language does the opposite. %is postfix and binds tightest.50%is 0.5 and50%^2is 0.25.
And the semantics that separate an evaluator from a spreadsheet:
- Errors propagate, first one wins.
#N/A + 1/0is#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", butSUM("2",3)is 5 andSUM("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.
IFis lazy —IF(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:A100must recompute whenA50is first written — andA50has no entry to hang a dependency on until then. - Cycles are detected, not recursed.
A1: =A1yields#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.toSkiaMatrixtransposes 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. setTransformvsconcat. 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 insave/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
drawPagepinsdpr: 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 appendAnnotations — NativePdf 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 restTo 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.
OneEuroFiltersmooths 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.
shouldRejectAsPalmis a single worklet-safe function used by both the UI-thread path and the JS-sidePointerArbiter, 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, andonEnddiscards the stroke onsuccess: 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 AppStatebackgroundhandler. 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;
useSyncExternalStorereads 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;
pinchis frame-count independent, so the same physical gesture lands identically on a slow device. - The Skia adapter, driven by the real
CanvasTextRendererrendering a real parsed PDF into a recording mockSkCanvas. 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
viewToPagePointover 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:Bufferover 500 random buffers. - The import graph, proving
docedit/native,ink.tsandannotation-store.tsreach 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:
softClampused 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.decodeBase64sized 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
betadefaulted 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 exposesSkia.Path.Make()— a namespace with a factory. Both shapes are now accepted, because the failure mode is aTypeErrorat 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 foundOr 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 blocked — inherited properties.
font-family,font-size,color,line-height,letter-spacing,text-align,visibilityandcursorcross the boundary from the host element. A site withhtml { font-size: 62.5% }— an extremely common reset — shrinks everyeminside 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: inheriton 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:hostrule — andinheritthen carried that inside. Descendants now stateborder-boxexplicitly, which is unreachable from outside.:host { --wv-accent: … }broke external theming. Declaring defaults under the same name the customer sets means the:hostdeclaration overrides the inherited value, so setting--wv-accenton the container silently did nothing. Defaults are now--wv-*-default, consumed asvar(--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,docxDeliberate 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
