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

docxodus

v12.6.5

Published

Render, edit, diff and project Word .docx files in the browser — DOCX to HTML, a block editor, Word-grade redlines with tracked changes, and anchor-addressed markdown for LLM agents. WebAssembly, no server.

Readme

Docxodus

View, edit, and diff Word .docx files in the browser. No server, no upload, no conversion round-trip — a real OOXML engine compiled to WebAssembly, wrapped in TypeScript.

npm install docxodus

Diff two documents into a redline Word will open

compareDocuments() returns a .docx carrying native Word tracked changes — w:ins, w:del, and true move markup — not a text diff with highlighting. Open it in Word and accept/reject as usual.

A redlined venture financing agreement

Real output. One frame: a struck definition, an inserted definition, word-level substitutions inside an untouched sentence, and a clause moved — struck in purple at the bottom, re-inserted at the top, linked as one operation instead of reported as an unrelated delete and insert.

getRevisions() gives you the same changes as structured data — typed, with move pairing and stable kind:scope:unid anchors — so you can drive a review UI without parsing OOXML.

For audit and verification workflows, docxDiffGetSemanticChanges(left, right) returns the stable docxodus.semantic-changes v1 object. It distinguishes content, formatting, structure, table, story/review, relationship/media, and opaque-package families, with typed before/after values and deterministic ids. An open session exposes the same opening-to-current comparison through session.getSemanticChanges(); it requires the default captureInitialProjection: true setting. See the semantic diff contract.

Render a document faithfully

convertDocxToHtml() keeps what naive converters drop: justification, style inheritance, legal numbering, tables, images, comments, headers and footers, and real footnotes with back-references.

The NVCA model charter rendered to HTML

Paginated mode flows content into real page boxes with per-page numbers and page-anchored footnotes — a print-accurate preview in the browser.

Supply an exact layout token to materialize portable page citations from that layout:

const result = paginateHtml(html, viewer, {
  layoutToken: { documentVersion: session.getVersion(), rendererFingerprint: 'chromium-layout-v1' },
});
session.registerPageMap(result.pageMap!, 'chromium-layout-v1');
const citation = session.getPageCitation(anchorId, {
  documentVersion: session.getVersion(),
  rendererFingerprint: 'chromium-layout-v1',
});
navigateToPageCitation(viewer, citation);

Continuous renderers return explicit unavailability; Docxodus never estimates a citation page. See the PageMap contract.

Edit it in place

DocxEditor is a framework-agnostic block editor over the live document. Edits go through a DocxSession, and only the changed block re-renders, so structural operations cost ~90–360 ms on a 346-block, 94-footnote filing template. save() returns lossless bytes.

The in-browser DOCX editor

Two layers, so you can take as much UI as you want:

// The engine, with no chrome — bring your own UI.
import { DocxEditor } from 'docxodus';
const editor = DocxEditor.open(container, docxBytes, exports);

// Long document? Mount it in windows instead, yielding to the event loop between them, so the
// tab stays responsive and you can show progress. The result is the same editor and the same DOM.
const editor = await DocxEditor.openAsync(container, docxBytes, exports, {
  windowSize: 24,
  onProgress: (mounted, total) => console.log(`${mounted}/${total} blocks`),
});

// Or the whole surface: Word's tabbed ribbon (fonts, colour, styles, find & replace, links,
// pictures, tables, page setup, tracked changes), Word-style comment bubbles beside the page,
// in-place header/footer editing, a status bar with zoom, and the loading overlay.
import { mountRibbon } from 'docxodus';
const ribbon = mountRibbon(container, { exports });
ribbon.open(docxBytes, 'contract.docx');

The ribbon picks its layout from the width of the element you give it, so the same call serves a full-page editor and a narrow embedded panel. examples/editor.html is a host for it in ~60 lines.

Embed it with one script tag

The embed entry packages the viewer and editor behind one-call factories, with the WASM runtime location auto-detected — no build step, no configuration:

<div id="doc" style="height: 100dvh"></div>
<script type="module">
  import { createRibbonEditor } from 'https://cdn.jsdelivr.net/npm/[email protected]/dist/embed.bundle.js';
  const ribbon = await createRibbonEditor('#doc', './contract.docx'); // or bytes / Blob / File
  // ...later: const editedBytes = ribbon.save();
</script>

That gives you the full editor UI. createEditor('#doc', source, options?) is the same document without the chrome, and createViewer('#doc', source, options?) is the read-only counterpart. Bundler users get the same API from docxodus/embed; classic-script pages can load dist/embed.iife.js for a global Docxodus. See Embedding via CDN below.

Project it for an LLM

convertWmlToMarkdown() renders the document as markdown where every block carries a stable id, so an agent can point at a clause and edit it — and openDocxSession() writes back to that same id.

Multi-step plans are atomic by default. Each callback may call any synchronous session mutation; success is one version/undo unit, while a failure or throw restores the complete package and history checkpoint:

const result = session.executeBatch([
  { tool: 'docx_edit', action: 'replace_text',
    mutation: () => session.replaceText(firstAnchor, 'Replacement text') },
  { tool: 'docx_create', action: 'set_header_text',
    mutation: () => session.setHeaderText(firstAnchor, 'default', 'Confidential') },
]);

if (!result.success) console.error(result.failure);

Pass 'best_effort' explicitly only when partial successes should be retained.

A retry after a lost response must not apply the edit twice. Give the batch a transaction id and a serializable description of what it does; an identical retry returns the original result without executing again, and reusing the id for a different request fails with transaction_conflict:

const result = session.executeBatch(steps, 'atomic', {
  transactionId: 'plan-42-step-3',
  request: { replace: firstAnchor, header: 'Confidential' },
});
result.transaction; // { schemaVersion: 1, transactionId, requestFingerprint }

previewBatch answers "what would this do?" without touching the live session. It runs the same steps against a complete isolated clone — each callback is handed the shadow session to mutate — and returns the same receipt plus optional predicted HTML:

const preview = session.previewBatch([
  { tool: 'docx_edit', action: 'replace_text',
    mutation: shadow => shadow.replaceText(firstAnchor, 'Proposed replacement') },
], 'atomic', { html: 'full' });

console.log(preview.html, preview.revisionChanges.added, preview.warnings);

The live document's bytes, version and undo/redo history are unchanged either way. Preview HTML shows tracked changes, comments, annotations, notes and headers/footers — the document the batch would produce, matching what the Python and MCP clients render for the same batch. packageHash is null when it could not be computed, so never assert replay equality without checking for it.

A preview predicts generated values (new anchor ids, comment ids, revision timestamps), but a later executeBatch runs afresh and may generate them differently. retain: true keeps the successful preview's exact result package, and commitPreview makes it the live document as previewed — one undo step, same ids, same packageHash:

const preview = session.previewBatch(steps, 'atomic', { html: 'full', retain: true });
showToReviewer(preview.html, preview.revisionChanges);
const commit = session.commitPreview(preview.retention!.previewId, {
  transactionId: 'plan-42-commit', request: { commit: preview.retention!.previewId },
});
commit.packageHash === preview.packageHash; // true

The commit refuses with preview_stale, editing nothing, if the session's version, package content, tracked-changes mode or revision author moved since the preview, and with preview_not_found once the preview expired, was evicted (8 previews, 64 MiB, 15 minutes per session) or was already committed.

Delivery receipts from captured evidence

A session opened with captureDeliveryEvidence: true records the evidence a delivery change receipt needs as its edits execute — the exact package before and after every mutation, each executeBatch step's tool/action/args, transaction ids, and undo/redo lineage — and buildDeliveryReceipt mints a receipt the portable verifier accepts:

const session = openDocxSession(bytes, { captureDeliveryEvidence: true });
session.executeBatch([
  { tool: 'docx_edit', action: 'replace_text', args: { anchorId: firstAnchor, markdown: 'Final wording' },
    mutation: () => session.replaceText(firstAnchor, 'Final wording') },
], 'atomic', { transactionId: 'plan-42', request: { replace: firstAnchor } });

const bundle = session.buildDeliveryReceipt({ privacyProfile: 'hashAndSummary' });
const receipt = bundle.artifacts.find(a => a.artifactId === 'change-receipt')!;
const artifacts = Object.fromEntries(bundle.artifacts
  .filter(a => a.bytes && a.artifactId !== 'change-receipt')
  .map(a => [a.artifactId, a.bytes!]));
await verifyDeliveryReceipt(new TextDecoder().decode(receipt.bytes!), artifacts); // isValid

Deliver the bundle's final-docx artifact: it is the bytes the receipt attests. A direct call outside executeBatch is still captured exactly but recorded as an unlabeled mutation; getDeliveryEvidenceStatus() counts those and names the first reason a receipt cannot be minted (capture off, retention of 256 states / 512 MiB exceeded), in which case the bundle is incomplete and the receipt artifact carries the reason instead of claiming a history.

Markdown projection beside the rendered document

Native links and bookmarks use the same stable anchors and exact character spans:

const session = openDocxSession(docxBytes, { persistAnchorIds: true });
const paragraph = Object.keys(session.project().anchorIndex)
  .find(id => id.startsWith('p:body:'))!;
session.addBookmark('Definitions', {
  startAnchorId: paragraph, startOffset: 0,
  endAnchorId: paragraph, endOffset: 11,
});
session.addHyperlink(paragraph, { start: 0, length: 11 }, 'internal', 'Definitions');
const links = session.listHyperlinks(); // ids feed updateHyperlink/removeHyperlink
const bookmarks = session.listBookmarks(); // exact endpoint range + per-paragraph segments

External links own their relationship in the actual body/header/footer/footnote/endnote part; internal links are relationship-free bookmark targets. Bookmark rename retargets inbound links atomically, and unsafe removal or cross-part ranges return typed EditResult errors.

Native images are occurrence-addressed too, with bytes kept explicit at the API boundary:

const capabilities = session.getImageCapabilities();
const inserted = session.insertImage(paragraph, 0, pngBytes, {
  widthPoints: 144,
  altText: 'Revenue by quarter',
});
const image = session.listImages().find(value => value.id === inserted.imageId)!;
session.setImageDimensions(image.id, { widthPoints: 108 });

PNG/JPEG/GIF/BMP/TIFF are writable. Existing WebP, external links, legacy VML, and unsupported DrawingML remain enumerable with canMutate: false. Dimensions use points; floating offsets use exact EMUs, and omitted insert dimensions use 96 DPI. The browser API accepts Uint8Array and does not fetch image URLs or read paths.


Everything else

  • Move detection — relocated content is identified as a move, not a delete plus an insert
  • Format change detection — formatting-only changes (bold, italic, font size, …)
  • Comment rendering — endnote-style, inline, or margin
  • Document metadata — fast extraction for lazy loading and pagination
  • OpenContracts export — for NLP and document-analysis pipelines
  • External annotations — overlay annotations without modifying the DOCX
  • Web Worker support — non-blocking WASM execution off the main thread
  • React hooks — useDocxodus, useConversion, useComparison
  • TypeScript — full type definitions included

Quick Start

Basic Usage

import { initialize, convertDocxToHtml, compareDocuments } from 'docxodus';

// Initialize the WASM runtime (call once at app startup)
await initialize('/path/to/wasm/');

// Convert DOCX to HTML
const html = await convertDocxToHtml(docxFile);

// Compare two documents
const redlinedDocx = await compareDocuments(originalFile, modifiedFile, {
  authorName: 'Reviewer'
});

React Usage

import { useDocxodus, useConversion, useComparison } from 'docxodus/react';

function DocumentViewer() {
  const { isReady, isLoading, error, convertToHtml } = useDocxodus('/wasm/');
  const [html, setHtml] = useState('');

  const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file && isReady) {
      const result = await convertToHtml(file);
      setHtml(result);
    }
  };

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <input type="file" accept=".docx" onChange={handleFile} />
      <div dangerouslySetInnerHTML={{ __html: html }} />
    </div>
  );
}

Using the Comparison Hook

import { useComparison } from 'docxodus/react';

function DocumentComparer() {
  const {
    html,
    isComparing,
    error,
    compareToHtml,
    downloadResult
  } = useComparison('/wasm/');

  const handleCompare = async (original: File, modified: File) => {
    await compareToHtml(original, modified, { authorName: 'Legal Team' });
  };

  return (
    <div>
      {isComparing && <p>Comparing...</p>}
      {error && <p>Error: {error.message}</p>}
      {html && <div dangerouslySetInnerHTML={{ __html: html }} />}
      <button onClick={() => downloadResult('comparison.docx')}>
        Download Redlined DOCX
      </button>
    </div>
  );
}

Node.js (ES modules)

Import the engine from docxodus/core for server-side conversion, comparison, annotations, and document sessions. It loads without a bundler or a custom Node loader:

import { readFile, writeFile } from 'node:fs/promises';
import { initialize, convertDocxToHtml, compareDocuments } from 'docxodus/core';

await initialize(); // Finds the WASM runtime included in the installed package.
const original = await readFile('original.docx');
const revised = await readFile('revised.docx');
await writeFile('original.html', await convertDocxToHtml(original));
await writeFile('redline.docx', await compareDocuments(original, revised));

docxodus/core exposes the engine functions, types, enums, annotation helpers, and history APIs from docxodus, excluding DocxEditor, CommentGutter, mountRibbon, and their editor types. The existing docxodus entry includes those browser editor APIs and requires a browser bundler; use docxodus/core in plain Node ESM. Both entries share the same engine state. Browser helpers retained in core, such as pagination, the document viewport, IndexedDB storage, and history controls, still require their browser APIs when called.

API Reference

Package verification

generatePackageManifest(document) inspects supplied bytes without opening an editable package; session.getPackageManifest() describes the current logical session checkpoint, including unsaved edits, without advancing its version. Both return the same schema-v1 PackageManifest with exact package, ordered OPC-content, and normalized-semantic SHA-256 identities plus entries, relationships, facts, and structured findings. Worker users can call worker.generatePackageManifest(document) or await workerSession.getPackageManifest(). See package_manifests.md for the normalization boundary.

Stateful inspection and editing

openDocxSession(bytes) exposes the live document rather than a one-shot conversion. Inspect the source before writing it: listStyles() returns the document's actual paragraph/character/table styles, getFormatting(anchorId) keeps directParagraph separate from effectiveParagraph, and listInlineSpans(anchorId) returns anchorId + span pairs accepted unchanged by applyFormat.

const session = openDocxSession(bytes);
try {
  const anchorId = Object.keys(session.project().anchorIndex)[0];
  const style = session.listStyles().find(s => s.name === "Strong Custom");
  const run = session.listInlineSpans(anchorId).find(s => s.text === "Defined Term");
  if (style && run) {
    session.applyFormat(run.anchorId, run.span, { runStyle: style.id });
  }
} finally {
  session.close();
}

An omitted property in a direct record means "not written at this layer"; it must not be treated as false or zero. The matching effective record resolves document defaults and the full style chain. getListMembership and getSectionInfo likewise return their query anchorId so callers do not have to translate between inspection and mutation coordinate systems.

effective is deliberately a shorter cascade than the renderer's: it excludes the numbering level's own paragraph properties and the table style's conditional formatting, so a list item indented only by its numbering definition reports leftIndentTwips: 0 and a run bolded by a firstRow table style reports bold: false. Use getListMembership for the real numbering indentation. See docs/architecture/docx_mutation_api.md for the exact layer list.

Core Functions

initialize(basePath?: string): Promise<void>

Initialize the WASM runtime. Must be called before using any other functions.

convertDocxToHtml(document: File | Uint8Array, options?: ConversionOptions): Promise<string>

Convert a DOCX document to HTML.

import { CommentRenderMode, PaginationMode, AnnotationLabelMode } from 'docxodus';

interface ConversionOptions {
  pageTitle?: string;           // HTML document title
  cssPrefix?: string;           // CSS class prefix (default: "docx-")
  fabricateClasses?: boolean;   // Generate CSS classes (default: true)
  additionalCss?: string;       // Extra CSS to include
  commentRenderMode?: CommentRenderMode;  // How to render comments (default: Disabled)
  commentCssClassPrefix?: string;         // CSS prefix for comments
  paginationMode?: PaginationMode;        // None (0) or Paginated (1)
  paginationScale?: number;               // Scale factor for pages (default: 1.0)
  renderAnnotations?: boolean;            // Render custom annotations
  annotationLabelMode?: AnnotationLabelMode;  // Above, Inline, Tooltip, or None
  renderFootnotesAndEndnotes?: boolean;   // Include footnotes/endnotes sections
  renderHeadersAndFooters?: boolean;      // Include headers and footers
  renderTrackedChanges?: boolean;         // Show insertions/deletions visually
}
Comment Render Modes

Control how Word document comments are rendered in HTML output:

import { convertDocxToHtml, CommentRenderMode } from 'docxodus';

// Don't render comments (default)
const html = await convertDocxToHtml(docxFile, {
  commentRenderMode: CommentRenderMode.Disabled
});

// Render as footnotes with bidirectional links
const htmlEndnote = await convertDocxToHtml(docxFile, {
  commentRenderMode: CommentRenderMode.EndnoteStyle
});

// Render as inline tooltips (title attribute + data attributes)
const htmlInline = await convertDocxToHtml(docxFile, {
  commentRenderMode: CommentRenderMode.Inline
});

// Render in a side margin column (CSS flexbox layout)
const htmlMargin = await convertDocxToHtml(docxFile, {
  commentRenderMode: CommentRenderMode.Margin
});

| Mode | Value | Description | |------|-------|-------------| | Disabled | -1 | Don't render comments (default) | | EndnoteStyle | 0 | Comments at document end with [1] style links | | Inline | 1 | Tooltips via title and data-comment attributes | | Margin | 2 | Side column using CSS flexbox |

compareDocuments(original, modified, options?): Promise<Uint8Array>

Compare two DOCX documents and return a redlined DOCX with tracked changes.

interface CompareOptions {
  authorName?: string;     // Author name for revisions (default: "Docxodus")
  detailThreshold?: number; // 0.0-1.0, lower = more detailed (default: 0.15)
  caseInsensitive?: boolean; // Case-insensitive comparison (default: false)
}

compareDocumentsToHtml(original, modified, options?): Promise<string>

Compare documents and return the result as HTML.

getRevisions(document: File | Uint8Array, options?): Promise<Revision[]>

Extract revision information from a compared document.

import {
  getRevisions,
  RevisionType,
  isInsertion,
  isDeletion,
  isMove,
  isMoveSource,
  isFormatChange,
  findMovePair
} from 'docxodus';
import type { Revision, GetRevisionsOptions } from 'docxodus';

// RevisionType enum
enum RevisionType {
  Inserted = "Inserted",      // Text or content that was added
  Deleted = "Deleted",        // Text or content that was removed
  Moved = "Moved",            // Text relocated within the document
  FormatChanged = "FormatChanged"  // Formatting-only change
}

// Revision interface with full documentation
interface Revision {
  author: string;
  date: string;
  revisionType: RevisionType | string;
  text: string;
  moveGroupId?: number;      // Links move source/destination pairs
  isMoveSource?: boolean;    // true = moved FROM here, false = moved TO here
  formatChange?: {           // Details for FormatChanged revisions
    oldProperties?: Record<string, string>;
    newProperties?: Record<string, string>;
    changedPropertyNames?: string[];
  };
}

// Get revisions with options
const revisions = await getRevisions(comparedDoc, {
  detectMoves: true,              // Enable move detection (default: true)
  moveSimilarityThreshold: 0.8,   // Jaccard similarity for moves (default: 0.8)
  moveMinimumWordCount: 3,        // Minimum words for move (default: 3)
  caseInsensitive: false          // Case-insensitive matching (default: false)
});

// Filter by type using helper functions
const insertions = revisions.filter(isInsertion);
const deletions = revisions.filter(isDeletion);
const moves = revisions.filter(isMove);
const formatChanges = revisions.filter(isFormatChange);

// Find move pairs
for (const rev of moves.filter(isMoveSource)) {
  const destination = findMovePair(rev, revisions);
  console.log(`"${rev.text}" moved to "${destination?.text}"`);
}

// Check format changes
for (const rev of formatChanges) {
  console.log(`Format changed: ${rev.formatChange?.changedPropertyNames?.join(', ')}`);
}

getDocumentMetadata(document: File | Uint8Array): Promise<DocumentMetadata>

Get document metadata for lazy loading and pagination without full HTML rendering.

const metadata = await getDocumentMetadata(docxFile);

console.log(`Sections: ${metadata.sections.length}`);
console.log(`Total paragraphs: ${metadata.totalParagraphs}`);
console.log(`Estimated pages: ${metadata.estimatedPageCount}`);
console.log(`Has comments: ${metadata.hasComments}`);
console.log(`Has tracked changes: ${metadata.hasTrackedChanges}`);

// Section dimensions (in points, 1pt = 1/72 inch)
const section = metadata.sections[0];
console.log(`Page size: ${section.pageWidthPt} x ${section.pageHeightPt} pt`);

exportToOpenContract(document: File | Uint8Array): Promise<OpenContractDocExport>

Export document to OpenContracts format for NLP/document analysis.

const export = await exportToOpenContract(docxFile);
console.log(`Title: ${export.title}`);
console.log(`Content: ${export.content.length} characters`);
console.log(`Pages: ${export.pageCount}`);
console.log(`Structural annotations: ${export.labelledText.length}`);

Web Worker API

For non-blocking WASM execution, use the worker-based API:

import { createWorkerDocxodus } from 'docxodus/worker';

// Create a worker instance
const docxodus = await createWorkerDocxodus({ wasmBasePath: '/wasm/' });

// All operations run in a Web Worker - main thread stays responsive
const html = await docxodus.convertDocxToHtml(docxFile, options);
const redlined = await docxodus.compareDocuments(original, modified, options);
const revisions = await docxodus.getRevisions(docxFile);
const comments = await docxodus.getComments(docxFile);
const metadata = await docxodus.getDocumentMetadata(docxFile);

// The external annotation family runs there too, so a read-only viewer that renders
// through the worker can annotate without booting a second runtime on the main thread.
const set = await docxodus.createExternalAnnotationSet(docxFile, 'doc-1');
const annotated = await docxodus.projectAnnotationsOntoHtml(html, set);
const validation = await docxodus.validateExternalAnnotations(docxFile, set);
const exported = await docxodus.exportToOpenContract(docxFile);

// Terminate when done
docxodus.terminate();

projectAnnotationsOntoHtml parses its input as XML: hand it the converter's output (or another well-formed serialization), not a live DOM's innerHTML, which leaves <br> and <img> unclosed. DocxEditor.open() stays main-thread only — it writes into a live container.

First-call warmup

createWorkerDocxodus() warms the .NET WASM runtime, but the comparison code path is not exercised until your first compareDocuments(). That first call pays a one-time warmup cost (comparison-assembly initialization + JIT of the diff/XML engine) — roughly 2× the latency of every subsequent compare.

prepare() is an optional method that pays this cost up front. Call it once after creating the worker — during app boot, or while the user is still picking files — so the first user-triggered comparison is already hot. It does not run automatically; if you skip it, the first compare simply absorbs the warmup as before.

const docxodus = await createWorkerDocxodus({ wasmBasePath: '/wasm/' });

// Optional: warm the comparison path ahead of the first user action.
await docxodus.prepare();

// Now hot — the first real compare runs at steady-state speed and triggers
// no further .wasm fetches.
const redlined = await docxodus.compareDocuments(original, modified);

prepare() is idempotent (repeated calls share one in-flight warmup and resolve immediately once complete), needs no input documents or seed files of your own (it builds tiny seed documents inside the worker), and is concurrent-safe — issuing a compareDocuments() while a prepare() is still in flight will not double-load assemblies.

React Hooks

useDocxodus(wasmBasePath?: string)

Main hook providing all Docxodus functionality.

Returns:

  • isReady: boolean - Whether WASM is loaded
  • isLoading: boolean - Whether WASM is loading
  • error: Error | null - Initialization error
  • convertToHtml() - Convert DOCX to HTML
  • compare() - Compare documents
  • compareToHtml() - Compare and get HTML
  • getRevisions() - Get revision list
  • getDocumentMetadata() - Get document metadata

useConversion(wasmBasePath?: string)

Simplified hook for DOCX to HTML conversion with state management.

useComparison(wasmBasePath?: string)

Simplified hook for document comparison with state management.

useAnnotations(wasmBasePath?: string)

Hook for managing custom annotations on documents.

useDocumentStructure(wasmBasePath?: string)

Hook for document structure analysis and element-based targeting.

Embedding via CDN

Everything in the published package — the JS wrappers AND the WASM runtime — is served by npm CDNs (jsDelivr, unpkg) with Access-Control-Allow-Origin: * and correct application/wasm MIME types, so a page can embed a full viewer or editor without hosting anything itself.

One-tag viewer / editor (recommended)

<div id="viewer"></div>
<div id="editor"></div>
<div id="app" style="height: 100dvh"></div>
<script type="module">
  import { createViewer, createEditor, createRibbonEditor }
    from 'https://cdn.jsdelivr.net/npm/[email protected]/dist/embed.bundle.js';

  // Choose a factory, or render several into separate containers as shown.
  // Source may be a URL, Uint8Array, ArrayBuffer, Blob, or File.
  const viewer = await createViewer('#viewer', './contract.docx');

  // No source opens a blank "New document"; returns a DocxEditor.
  const editor = await createEditor('#editor', './contract.docx', { paginated: false });
  const bytes = editor.save(); // lossless DOCX bytes

  // The whole editor UI in one call — ribbon, rail, table picker, loading overlay.
  // Density follows the CONTAINER's width, so this is also the mobile answer.
  const ribbon = await createRibbonEditor('#app', './contract.docx', {
    chrome: 'auto',       // 'full' | 'compact' | 'auto' (default)
    rail: true,           // the live kind:scope:unid / session / last-op readout
    hint: true,           // the editing hint above the document
    loader: true,         // the staged loading overlay; false removes it
  });
</script>

dist/embed.bundle.js is a self-contained ESM bundle re-exporting the entire main API (convert, compare, diff, sessions, annotations) plus createViewer/createEditor/createRibbonEditor/mountRibbon/DocxEditor, so it also works as a no-build way to use any other function.

For pages that can't use modules, dist/embed.iife.js exposes the same surface as a global:

<div id="doc"></div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/embed.iife.js"></script>
<script>
  Docxodus.createRibbonEditor('#doc').then((ribbon) => { /* ... */ });
</script>

How WASM resolution works

The ~18 MB runtime request set in dist/wasm/ loads lazily from the same directory the bundle was loaded from (import.meta.url for module scripts, document.currentScript for the IIFE): first <bundle dir>/wasm/, then <bundle dir>/ as a fallback. On a CDN that resolves to .../dist/wasm/_framework/... automatically. To serve the WASM assets from somewhere else, pass { wasmBasePath: 'https://your.host/wasm/' } to either factory (or call initialize(path) first).

CDN caveats

  • Pin an exact version in production ([email protected], not @latest) — CDN responses are cached as immutable, and the wire shapes between the JS wrappers and the WASM assemblies must come from the same release.
  • The build patches the .NET loader to fetch with credentials: "omit" — required because the CDN's Access-Control-Allow-Origin: * cannot be combined with credentialed requests.
  • The Web Worker entry (docxodus/worker) is not CDN-loadable cross-origin (browsers require same-origin worker scripts); embed runs the engine on the main thread. Self-host the package if you need the worker.
  • Converter CSS is scoped to a private inner mount so body, span, and document-class rules cannot restyle the host page. Document-global @import and @page rules are omitted; use the full-document conversion/print path rather than an embed factory if those rules are required.
  • First load fetches ~18 MB uncompressed across about 49 requests; subsequent loads hit the browser cache.

Standalone paginated HTML

docxodus/export-browser turns a DOCX into a complete offline HTML document whose body contains the finalized fixed page boxes—not the hidden measurement tree. It returns the HTML together with the exact PageMap, renderer fingerprint, warnings, and a schema-v2 render report from that same sanitized tree.

import { convertDocxToPaginatedHtml } from 'docxodus/export-browser';

const source = new Uint8Array(await file.arrayBuffer());
const result = await convertDocxToPaginatedHtml(source, {
  reviewProfile: 'final',
  commentProfile: 'endnotes',
  documentVersion: 12,
  expectedSourceDigest: verifiedSha256,
});

download(new Blob([result.html], { type: 'text/html' }));
console.log(result.pageCount, result.pageMap, result.renderReport);

The exporter copies caller bytes before its first asynchronous boundary, verifies them with the package manifest API, performs layout in an attached script-disabled frame, embeds or removes every automatic resource, and reopens the serialized result to verify page count and physical geometry. External HTTPS/mail/tel links remain user-activated links and are inventoried in the report; the exporter never follows them. unsupportedContent: 'strict' rejects visible placeholders or omitted resources instead of returning a nominally complete artifact.

All three revision profiles (final, original, markup) are supported; final and original derive their projection out-of-place and never mutate the caller's bytes. strictFonts: true turns unresolved or unverified font families into failures; supplying a fontResolver (in Node, fontDirectories on @docxodus/export) is what makes families verifiable. A font the browser supplied on its own is labelled browserObserved; it is not presented as a verified host-font environment.

By default the worker loads from dist/wasm/ beside the package entry point. A deployment that hosts those files elsewhere may pass wasmBasePath. docxodus/export-assets.json is the closed, SHA-256-addressed runtime asset graph, and docxodus/render-report.schema.json is the report schema.

Hosting WASM Files

The WASM files need to be served from your web server. After building:

  1. Copy the contents of dist/wasm/ to your public directory
  2. Pass the path to initialize() or the React hooks

Example directory structure:

public/
  wasm/
    _framework/
      dotnet.js
      dotnet.native.wasm
      ... (other framework files)

Package and Runtime Size

| Component | Approx. uncompressed size | |-----------|---------------------------:| | dotnet.native.wasm | 1.5 MB | | Managed/runtime WASM modules | 15.5 MB | | WASM payload total | 17.0 MB | | Published package (all bundles, types, maps, and WASM) | 20.0 MB |

Only the runtime request set (~18 MB including loader JavaScript) is fetched during first initialization; source maps, declarations, and unrelated entry bundles are not. Runtime files are loaded on demand and cached by the browser.

Browser Support

  • Chrome 89+
  • Firefox 89+
  • Safari 15+
  • Edge 89+

Requires WebAssembly SIMD support.

Portable document history

See the concise history-controls API guide for latest/version loading, comparison, checkpoint/restore and .docxhistory import/export over host-owned storage. mountHistoryControls adds an accessible panel with separate previews, exact checkpoint retries and explicit history sharing. openIndexedDbHistoryStore provides optional browser persistence. These controls require structuredClone and crypto.randomUUID; the optional store also requires IndexedDB. The shared editor example includes an optional Version history drawer. One npm run build produces the library, local editors and deployable site; run npm run demo:serve and open http://localhost:8088/editor.html. In an embed, pass history: true to createRibbonEditor, or { history: { workspaceId: "my-document" } } to resume saved work across reloads.

License

MIT

Credits

Built on Docxodus, a .NET library for document manipulation based on OpenXML-PowerTools.