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().
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.
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.
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().
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} />;
} <script setup lang="ts">
import { onMounted, ref } from 'vue';
import { DocxViewer } from '@silurus/ooxml/docx';
const props = defineProps<{ src: string }>();
const canvas = ref<HTMLCanvasElement>();
let viewer: DocxViewer | undefined;
onMounted(() => {
viewer = new DocxViewer(canvas.value as HTMLCanvasElement, { width: 820 });
void viewer.load(props.src);
});
// DocxViewer needs no explicit teardown.
</script>
<template>
<canvas ref="canvas" />
</template> <script lang="ts">
import { onMount } from 'svelte';
import { DocxViewer } from '@silurus/ooxml/docx';
export let src: string;
let canvas: HTMLCanvasElement;
onMount(() => {
const viewer = new DocxViewer(canvas, { width: 820 });
void viewer.load(src);
});
</script>
<canvas bind:this={canvas}></canvas> import { DocxViewer } from '@silurus/ooxml/docx';
const canvas = document.getElementById('viewer') as HTMLCanvasElement;
const viewer = new DocxViewer(canvas, { width: 820 });
await viewer.load('/sample.docx'); 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
| Option | Type | Default | Description |
|---|---|---|---|
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
| Option | Type | Default | Description |
|---|---|---|---|
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
| Option | Type | Default | Description |
|---|---|---|---|
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. |