← All formats

DOCX

Render .docx documents to Canvas, your way.

Every demo below is the real library rendering a sample Word document (.docx) live in your browser — and the exact code that produces it. The same patterns power the Storybook stories.

Story · Demo

Single viewer with navigation

Hand DocxViewer a canvas and it manages parsing, page layout and the current page. Step through with the built-in nextPage() / prevPage().

sample-1.docx live · WASM
import { DocxViewer } from '@silurus/ooxml/docx';

// The built-in viewer tracks the current page for you.
const viewer = new DocxViewer(canvas, { width: 960, useGoogleFonts: true });
await viewer.load('/sample.docx');

nextBtn.addEventListener('click', () => viewer.nextPage());
prevBtn.addEventListener('click', () => viewer.prevPage());

Story · ScrollView

Scroll through every page

Drive the headless DocxDocument engine to render each page into its own canvas, stacked in a scroll container — the natural way to read a long document.

sample-1.docx live · WASM
import { DocxDocument } from '@silurus/ooxml/docx';

// Headless engine — render every page into a canvas you control.
const doc = await DocxDocument.load('/sample.docx');

for (let i = 0; i < doc.pageCount; i++) {
  const canvas = document.createElement('canvas');
  scroller.appendChild(canvas);
  await doc.renderPage(canvas, i, { width: 1100 });
}

Story · ThumbnailGrid

Page thumbnails

The same engine renders pages at any size. Lay them out in a grid at thumbnail width for a quick overview, with click-to-navigate.

sample-1.docx live · WASM
import { DocxDocument } from '@silurus/ooxml/docx';

// Render each page small, wire up navigation.
const doc = await DocxDocument.load('/sample.docx');

for (let i = 0; i < doc.pageCount; i++) {
  const thumb = document.createElement('canvas');
  thumb.addEventListener('click', () => open(i));
  grid.appendChild(thumb);
  await doc.renderPage(thumb, i, { width: 320 });
}

Story · MasterDetail

Thumbnail rail + large preview

Combine both: a DocxDocument for the thumbnail rail and a DocxViewer for the detail pane. Click a thumbnail to jump the preview with goToPage().

sample-1.docx live · WASM
import { DocxDocument, DocxViewer } from '@silurus/ooxml/docx';

// A large preview viewer on the right…
const viewer = new DocxViewer(detailCanvas, { width: 960, enableTextSelection: true });

// …and a thumbnail rail on the left, sharing the same file.
const [doc] = await Promise.all([
  DocxDocument.load('/sample.docx'),
  viewer.load('/sample.docx'),
]);

for (let i = 0; i < doc.pageCount; i++) {
  const thumb = document.createElement('canvas');
  thumb.addEventListener('click', () => viewer.goToPage(i));  // jump the preview
  rail.appendChild(thumb);
  await doc.renderPage(thumb, i, { width: 200 });
}

In your framework

Mount it in a canvas

The viewer is a plain TypeScript class — no framework runtime, no peer deps. Create it on mount, call load(), and let it go on unmount.

import { useEffect, useRef } from 'react';
import { DocxViewer } from '@silurus/ooxml/docx';

export function Viewer({ src }: { src: string }) {
  const ref = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = ref.current;
    if (!canvas) return;
    const viewer = new DocxViewer(canvas, { width: 820 });
    void viewer.load(src);
    // DocxViewer renders into the canvas you own — nothing to tear down.
  }, [src]);

  return <canvas ref={ref} />;
}

API reference

Options & methods

Every public option and method, straight from the source. Types omitted for brevity are exported from the package — your editor will autocomplete the rest.

DocxViewer

Single-canvas viewer that paginates the document and tracks the current page.

new DocxViewer(canvas: HTMLCanvasElement, options?: DocxViewerOptions)

Options

OptionTypeDefaultDescription
width number Canvas CSS width in px; height is auto-computed from the page aspect ratio.
dpr number devicePixelRatio Device pixel ratio for the backing store (crispness on HiDPI).
useGoogleFonts boolean false Load metric-compatible webfonts and non-Latin script fallbacks (Noto Arabic / CJK KR·SC·TC·JP / Cyrillic / Hebrew / Thai / Devanagari) from Google Fonts so layout matches Office and non-Latin text never falls back to tofu. Off by default for privacy.
enableTextSelection boolean false Overlay a transparent text layer for native selection & copy.
showTrackChanges boolean Render tracked insertions/deletions with author colours.
maxZipEntryBytes number 512 MiB Per-entry ZIP decompression cap (zip-bomb guard). Lower it for untrusted input. Zero / negative values fall back to the default.
math MathRenderer undefined Opt-in OMML equation engine (MathJax + STIX Two Math, ~3 MB). Import it from the separate @silurus/ooxml/math entry — `import { math } from "@silurus/ooxml/math"` — and pass it to render equations. Omit it and equations are skipped, and the engine is left out of your build. When passed, the engine ships as a standalone asset fetched lazily the first time a document contains an equation.
mode 'main' | 'worker' 'main' 'main' renders on the main thread (default). 'worker' renders the whole viewer off the main thread — every frame is produced in a Web Worker and painted via a `bitmaprenderer` context — so document rendering never blocks the UI. Scroll, sheet tabs, zoom and (xlsx) cell selection are unchanged. Requires Worker + OffscreenCanvas. The pptx/docx text-selection overlay and in-document find work in 'worker' mode too (per-run geometry crosses the worker boundary); equations still require 'main'. Trade-off: each frame crosses the worker boundary as an ImageBitmap, so an individual render can be marginally slower than 'main' — the win is a responsive main thread, not raw render speed.
zoomMin / zoomMax number 0.1 / 4 Zoom factor bounds for setScale / fitWidth / fitPage (10%–400%).
onScaleChange (scale: number) => void Called when the zoom factor changes (setScale / fitWidth / fitPage / zoomIn / zoomOut), with the clamped factor (1 = 100%).
onHyperlinkClick (target: HyperlinkTarget) => void Called when a hyperlink is clicked. `target` is `{ kind: 'external', url }` or `{ kind: 'internal', ref, slideIndex? }`. When supplied, the callback fully owns the click (the default external-open / internal-navigation is not run). External URLs are scheme-sanitized (http / https / mailto / tel only); internal targets resolve to a docx bookmark / pptx slide jump / xlsx defined name or cell.
enableHyperlinks boolean true Master switch for hyperlink interactivity. Set `false` to disable it entirely: no hit-testing, no pointer cursor over links, no default navigation, and `onHyperlinkClick` is never called. Links still render exactly as authored but are inert, like plain text.
onPageChange (index: number, total: number) => void Called after a page finishes rendering.
onError (err: Error) => void Called on parse or render errors.

Methods

load(source: string | ArrayBuffer): Promise<void> Load from a URL or ArrayBuffer and render the first page.
goToPage(index: number): Promise<void> Render a specific page (0-indexed, clamped).
nextPage(): Promise<void> Advance one page.
prevPage(): Promise<void> Go back one page.
getScale(): number The current zoom factor (1 = 100%).
setScale(scale: number): Promise<void> Set the absolute zoom factor (1 = 100%), clamped to [zoomMin, zoomMax]; re-renders at the new size and fires onScaleChange when it changes. View-only.
fitWidth(): Promise<void> Fit the content WIDTH to the host container and re-render (routes through setScale). Defers when nothing is loaded or the container is unlaid-out.
fitPage(): Promise<void> Fit the WHOLE content (width and height) inside the container so it is visible without scrolling — takes the tighter of the two fits. Defers when unloaded / unlaid-out.
findText(query: string, opts?: { caseSensitive?: boolean }): Promise<FindMatch<DocxMatchLocation>[]> Full-text search across the whole document; highlights every hit and returns them in document order. Each match carries `matchIndex`, the matched `text`, and its `location`. Case-insensitive by default.
findNext(): Promise<FindMatch<DocxMatchLocation> | null> Move to the next match (wrap-around), navigate to it if needed, and draw it in the active-match colour. Returns the now-active match, or null when there are none. Call findText first.
findPrev(): Promise<FindMatch<DocxMatchLocation> | null> Move to the previous match (wrap-around from first to last).
clearFind(): void Clear all highlights and reset the find state.
get pageCount(): number Total pages (0 until loaded).
get currentPage(): number Current page index.
get canvasElement(): HTMLCanvasElement The underlying canvas.
destroy(): void Tear down the worker and release resources.

DocxDocument

Headless engine — render any page into any canvas you supply.

await DocxDocument.load(source, options?)

Options

OptionTypeDefaultDescription
useGoogleFonts boolean false Load metric-compatible webfonts and non-Latin script fallbacks (Noto Arabic / CJK KR·SC·TC·JP / Cyrillic / Hebrew / Thai / Devanagari) from Google Fonts so layout matches Office and non-Latin text never falls back to tofu. Off by default for privacy.
wasmUrl string | URL bundled asset Override the URL the parser worker fetches the WebAssembly module from. By default each format resolves the `*_parser_bg.wasm` asset that ships next to its bundle (relative to the module URL); set this to serve it from a CDN or a self-hosted path instead (a relative value resolves against the document URL). Pointing it at a mismatched or missing file makes load() reject when the worker instantiates it.
maxZipEntryBytes number 512 MiB Per-entry ZIP decompression cap (zip-bomb guard). Lower it for untrusted input. Zero / negative values fall back to the default.
workerTimeoutMs number unlimited Reject the parse if the worker does not answer within this many ms — an opt-in safety net for a wedged / crashed worker that would otherwise leave load() pending forever. Unlimited by default (a large document with heavy media can legitimately take tens of seconds). A worker that throws or fails to load already rejects immediately regardless; this only covers the "silent, never-responds" case.
math MathRenderer undefined Opt-in OMML equation engine (MathJax + STIX Two Math, ~3 MB). Import it from the separate @silurus/ooxml/math entry — `import { math } from "@silurus/ooxml/math"` — and pass it to render equations. Omit it and equations are skipped, and the engine is left out of your build. When passed, the engine ships as a standalone asset fetched lazily the first time a document contains an equation.
mode 'main' | 'worker' 'main' 'main' parses in a worker and renders on the main thread (default). 'worker' parses AND renders entirely inside the worker; the main thread only paints the ImageBitmap returned by the render*ToBitmap method via a `bitmaprenderer` context. Requires Worker + OffscreenCanvas. The canvas-target render methods are unavailable in 'worker' mode, and equations require 'main'. Trade-off: each frame is transferred from the worker as an ImageBitmap, so a single render can be marginally slower than 'main' — the win is that the main thread never blocks.

Methods

static load(source, options?): Promise<DocxDocument> Parse a document from a URL or ArrayBuffer.
get pageCount(): number Total pages.
pageSize(pageIndex: number): { widthPt, heightPt } Page size in pt for a page (ECMA-376 §17.6.13 / §17.6.11 — per section, so a mixed portrait/landscape document returns different sizes per page). Available in both modes; index is clamped. `{ 0, 0 }` means "not loaded". Returns a fresh object per call.
get mode(): "main" | "worker" The render mode this engine was loaded with. An injected engine’s mode decides whether pages render via renderPage (main) or renderPageToBitmap (worker).
renderPage(canvas, index, opts?: { width?, dpr?, showTrackChanges?, onTextRun? }): Promise<void> Render one page into the given canvas. `onTextRun` receives each segment as `DocxTextRunInfo`, including the authored `w14:paraId` as `paragraphId` when present. Unavailable in `mode: "worker"` — use renderPageToBitmap.
renderPageToBitmap(index, opts?: { width?, dpr?, showTrackChanges?, onTextRun? }): Promise<ImageBitmap> Render one page and return it as an ImageBitmap (both modes; in worker mode the render runs off the main thread and returns the same text-run stream beside the bitmap). Equations are skipped in `mode: "worker"` (they require `mode: "main"`). The bitmap is caller-owned: pass it to `transferFromImageBitmap` (which consumes it) or call `bitmap.close()`.

DocxScrollViewer

Container-owning continuous-scroll viewer. Takes a <div> (not a canvas) and renders the whole document as one vertically-scrolling, virtualized surface (only the visible window + overscan is mounted). Zoom is view-only.

new DocxScrollViewer(container: HTMLElement, options?: DocxScrollViewerOptions)

Options

OptionTypeDefaultDescription
width number container width Base fit width in CSS px. Default: the container width at first non-zero layout.
gap number 16 Vertical gap (px) between consecutive pages.
paddingTop / paddingBottom number gap Desk padding (px) above the first page / below the last. Pass 0 for a flush edge.
paddingLeft / paddingRight number gap Horizontal desk gutters (px); also shrink the container-derived fit width so a page sits inside them at 100%. Pass 0 for a flush edge.
overscan number 1 Pages kept mounted beyond the viewport on each side.
background string undefined CSS background for the scroll surface (the desk behind/between pages). Default transparent (the container shows through).
pageShadow string | false '0 1px 3px rgba(0,0,0,0.2)' CSS box-shadow painted on every page canvas. A spread-only ring (e.g. `0 0 0 1px #c8ccd0`) gives a crisp 1px border look. `false` disables it (flat pages).
enableZoom boolean true Enable Ctrl/⌘ + wheel (and trackpad pinch) zoom. View-only.
zoomMin / zoomMax number 0.1 / 4 Absolute zoom scale bounds (10%–400%).
enableTextSelection boolean false Overlay a transparent, selectable text layer per page for native copy. `mode: "main"` only — in worker mode the overlay stays empty and the viewer warns once.
onHyperlinkClick (target: HyperlinkTarget) => void Called when a hyperlink is clicked. `target` is `{ kind: 'external', url }` or `{ kind: 'internal', ref, slideIndex? }`. When supplied, the callback fully owns the click (the default external-open / internal-navigation is not run). External URLs are scheme-sanitized (http / https / mailto / tel only); internal targets resolve to a docx bookmark / pptx slide jump / xlsx defined name or cell.
enableHyperlinks boolean true Master switch for hyperlink interactivity. Set `false` to disable it entirely: no hit-testing, no pointer cursor over links, no default navigation, and `onHyperlinkClick` is never called. Links still render exactly as authored but are inert, like plain text.
showTrackChanges boolean Render tracked insertions/deletions with author colours (forwarded to each page render).
document DocxDocument undefined Inject an already-loaded engine to share one parse across panes. When set, load() is unsupported, the engine’s own mode wins, and destroy() does NOT destroy it (the caller owns its lifecycle).
useGoogleFonts boolean false Load metric-compatible webfonts and non-Latin script fallbacks (Noto Arabic / CJK KR·SC·TC·JP / Cyrillic / Hebrew / Thai / Devanagari) from Google Fonts so layout matches Office and non-Latin text never falls back to tofu. Off by default for privacy.
maxZipEntryBytes number 512 MiB Per-entry ZIP decompression cap (zip-bomb guard). Lower it for untrusted input. Zero / negative values fall back to the default.
math MathRenderer undefined Opt-in OMML equation engine (MathJax + STIX Two Math, ~3 MB). Import it from the separate @silurus/ooxml/math entry — `import { math } from "@silurus/ooxml/math"` — and pass it to render equations. Omit it and equations are skipped, and the engine is left out of your build. When passed, the engine ships as a standalone asset fetched lazily the first time a document contains an equation.
dpr number devicePixelRatio Device pixel ratio for the backing store (crispness on HiDPI).
mode 'main' | 'worker' 'main' 'main' parses in a worker and renders on the main thread (default). 'worker' parses AND renders entirely inside the worker; the main thread only paints the ImageBitmap returned by the render*ToBitmap method via a `bitmaprenderer` context. Requires Worker + OffscreenCanvas. The canvas-target render methods are unavailable in 'worker' mode, and equations require 'main'. Trade-off: each frame is transferred from the worker as an ImageBitmap, so a single render can be marginally slower than 'main' — the win is that the main thread never blocks.
onVisiblePageChange (topIndex: number, total: number) => void Fires when the top-most visible page changes.
onError (err: Error) => void Called on load errors and async per-page render failures (a failed page is left blank rather than crashing the scroll loop).

Methods

load(source: string | ArrayBuffer): Promise<void> Load a document from a URL or ArrayBuffer and render the first window. Throws when an engine was injected via `document`.
scrollToPage(index: number, opts?: { behavior?: "auto" | "smooth" }): void Scroll so page index’s top edge sits at the viewport top (index clamped).
setScale(scale: number): void Set the absolute zoom scale at runtime (clamped to zoomMin/zoomMax). Flicker-free. View-only.
relayout(): void Force a re-fit + re-mount of the visible window. Called automatically after load / resize / zoom; use it when the container resizes in a way a ResizeObserver cannot observe (e.g. a late web-font load). Idempotent.
get pageCount(): number Total pages (0 until loaded).
get topVisiblePage(): number Index of the top-most visible page.
destroy(): void Tear down the DOM subtree. Destroys a self-loaded engine; an injected one is left intact.