udoc-viewer
Universal document viewer for the web. Open-source, framework-agnostic viewer powered by a built-from-scratch WebAssembly engine for high-fidelity rendering across PDF, DOCX, PPTX, XLSX, CSV, SVG, and images.
- Truly universal — PDF, Word, PowerPoint, Excel, CSV, SVG, and images in a single viewer
- High fidelity — powered by a custom Rust/WebAssembly rendering engine, not PDF.js
- Client-side only — everything runs in the browser, no server round-trips
- Framework agnostic — works with React, Vue, Angular, Svelte, or plain HTML
- Free for commercial use — MIT-licensed wrapper, free WASM engine
Live Demo
This demo uses the UDocClient and UDocViewer API directly, exactly as documented below. For a full-featured demo with all viewer capabilities, see the full scale demo.
Installation
Install the package using your preferred package manager:
npm install @docmentis/udoc-viewerOr with yarn/pnpm:
yarn add @docmentis/udoc-viewerpnpm add @docmentis/udoc-viewerQuick Start
Get up and running with the viewer in just a few lines of code:
import { UDocClient } from '@docmentis/udoc-viewer';
// Create a client (loads the WASM engine)
const client = await UDocClient.create();
// Create a viewer attached to a container element
const viewer = await client.createViewer({
container: '#viewer'
});
// Load a document
await viewer.load('https://example.com/document.pdf');
// Clean up when done
viewer.destroy();
client.destroy();HTML Setup: Make sure you have a container element in your HTML:
<div id="viewer" style="width: 100%; height: 600px;"></div>Framework Examples
HTML
<div id="viewer" style="width: 100%; height: 600px;"></div>
<script type="module">
import { UDocClient } from "@docmentis/udoc-viewer";
const client = await UDocClient.create();
const viewer = await client.createViewer({ container: "#viewer" });
await viewer.load("/path/to/document.pdf");
</script>React
import { useEffect, useRef } from "react";
import { UDocClient } from "@docmentis/udoc-viewer";
function DocumentViewer({ src }) {
const containerRef = useRef(null);
useEffect(() => {
let client, viewer;
(async () => {
client = await UDocClient.create();
viewer = await client.createViewer({
container: containerRef.current,
});
await viewer.load(src);
})();
return () => {
viewer?.destroy();
client?.destroy();
};
}, [src]);
return <div ref={containerRef} style={{ width: "100%", height: "600px" }} />;
}More Examples
Full working examples for every major framework are available in the examples directory:
| Example | Stack |
|---|---|
| vanilla | TypeScript + Vite |
| react-vite | React + Vite |
| vue-vite | Vue + Vite |
| svelte-vite | Svelte 5 + Vite |
| angular | Angular 19 |
| nextjs-webpack | Next.js + Webpack |
| nextjs-turbopack | Next.js + Turbopack |
| nuxt | Nuxt 3 |
Client API
The UDocClient is the entry point that manages the WASM engine and creates viewers.
Creating a Client
const client = await UDocClient.create({
// License key for commercial use (optional)
license: 'eyJ2Ijox...',
// Custom base URL for worker and WASM files (optional)
// Expected files: {baseUrl}/worker.js and {baseUrl}/udoc_bg.wasm
baseUrl: 'https://cdn.example.com/udoc/',
// Locale for UI strings (optional, default: 'en')
locale: 'en',
// Disable anonymous telemetry (default: false)
// Requires a license with the 'no_telemetry' feature
disableTelemetry: false,
// Skip the npm registry version check on startup (default: false)
disableUpdateCheck: false,
// Enable Google Fonts fallback for missing fonts (default: true)
googleFonts: true,
// Register custom fonts for on-demand fetching during layout (optional)
// Supports OTF, TTF, WOFF, and WOFF2. Take priority over Google Fonts.
fonts: [
{ typeface: 'Roboto', bold: false, italic: false, url: 'https://cdn.example.com/Roboto-Regular.woff2' },
{ typeface: 'Roboto', bold: true, italic: false, url: 'https://cdn.example.com/Roboto-Bold.woff2' },
],
});Client Options
| Option | Type | Default | Description |
|---|---|---|---|
| license | string | — | License key for commercial use. Runs in free mode if not provided |
| baseUrl | string | — | Custom base URL for worker and WASM files. Expected files: {baseUrl}/worker.js and {baseUrl}/udoc_bg.wasm |
| locale | string | en | UI locale. Built-in: 'en', 'zh-CN', 'zh-TW', 'ja', 'ko', 'es', 'fr', 'de', 'pt-BR', 'ar', 'ru'. Falls back to English for unrecognized locales |
| disableTelemetry | boolean | false | Disable anonymous telemetry reporting. Requires a valid license with the 'no_telemetry' feature |
| disableUpdateCheck | boolean | false | Disable checking npm registry for a newer version on startup. Never blocks initialization |
| googleFonts | boolean | true | Enable automatic font fetching from Google Fonts for fonts not embedded in the document |
| fonts | FontEntry[] | — | Register custom font URLs for on-demand fetching during layout. Supports OTF, TTF, WOFF, and WOFF2. Registered fonts take priority over Google Fonts |
Viewer Options
Configure the viewer with various display and interaction options:
const viewer = await client.createViewer({
// Container element or CSS selector (required for UI mode)
container: '#viewer',
// --- View modes ---
scrollMode: 'continuous', // 'continuous' or 'spread'
layoutMode: 'single-page', // 'single-page', 'double-page', etc.
pageRotation: 0, // 0, 90, 180, or 270
spacingMode: 'all', // 'all', 'none', 'spread-only', 'page-only'
// --- Zoom ---
zoomMode: 'fit-spread-width',
zoom: 1,
minZoom: 0.1,
maxZoom: 5,
// --- Theme ---
theme: 'light', // 'light', 'dark', or 'system'
// --- Panels ---
activePanel: null, // 'thumbnail', 'outline', 'search', etc.
disableSearch: false,
disableThumbnails: false,
// --- UI visibility ---
hideToolbar: false,
hideFloatingToolbar: false,
disableFullscreen: false,
hideAttribution: false, // requires license with 'no_attribution'
});All Viewer Options
| Option | Type | Default | Description |
|---|---|---|---|
| container | string | HTMLElement | — | Container element or CSS selector (required for UI mode, omit for headless) |
| scrollMode | ScrollMode | continuous | Scroll mode: 'continuous' or 'spread' |
| layoutMode | LayoutMode | single-page | Layout mode: 'single-page', 'double-page', 'double-page-odd-right', 'double-page-odd-left' |
| pageRotation | number | 0 | Initial page rotation: 0, 90, 180, or 270 |
| spacingMode | SpacingMode | all | Spacing mode: 'all', 'none', 'spread-only', 'page-only' |
| zoomMode | ZoomMode | fit-spread-width | Zoom mode: 'fit-spread-width', 'fit-spread-width-max', 'fit-spread-height', 'fit-spread', 'custom' |
| zoom | number | 1 | Initial zoom level (when zoomMode is 'custom') |
| zoomSteps | number[] | [0.1, 0.25, 0.5, ...] | Custom zoom steps for zoom in/out. Default: [0.1, 0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4, 5] |
| minZoom | number | 0.1 | Minimum zoom level |
| maxZoom | number | 5 | Maximum zoom level |
| pageSpacing | number | 10 | Spacing between pages in pixels |
| spreadSpacing | number | 20 | Spacing between spreads in pixels |
| thumbnailWidth | number | 150 | Width of thumbnail images in pixels. Height is derived from page aspect ratio |
| dpi | number | 96 | Target display DPI |
| navigationScrollAlignment | ScrollAlignment | top | Default scroll alignment for navigation (outline, links, goToDestination): 'top', 'center', 'bottom', or 'nearest' |
| searchScrollAlignment | ScrollAlignment | center | Default scroll alignment for search result navigation: 'top', 'center', 'bottom', or 'nearest' |
| theme | string | light | Color theme: 'light', 'dark', or 'system' |
| disableThemeSwitching | boolean | false | Hide the theme toggle button |
| disableTextSelection | boolean | false | Disable text selection and copying |
| activePanel | string | null | null | Initially active panel. Left: 'thumbnail', 'outline', 'bookmarks', 'layers', 'attachments'. Right: 'search', 'comments'. Or null |
| disableLeftPanel | boolean | false | Disable the entire left panel area |
| disableRightPanel | boolean | false | Disable the entire right panel area |
| disableThumbnails | boolean | false | Disable the thumbnails tab |
| disableOutline | boolean | false | Disable the outline tab |
| disableBookmarks | boolean | false | Disable the bookmarks tab |
| disableLayers | boolean | false | Disable the layers tab |
| disableAttachments | boolean | false | Disable the attachments tab |
| disableSearch | boolean | false | Disable the search panel |
| disableComments | boolean | false | Disable the comments panel |
| hideToolbar | boolean | false | Hide the top toolbar |
| hideFloatingToolbar | boolean | false | Hide the floating toolbar (page nav, zoom, view mode) |
| disableFullscreen | boolean | false | Remove the fullscreen button |
| hideAttribution | boolean | false | Hide the 'Powered by docMentis' attribution link. Requires a valid license with the 'no_attribution' feature |
| hideLoadingOverlay | boolean | false | Hide the loading overlay shown during document download and processing. Requires a valid license with the 'no_attribution' feature |
| enablePerformanceCounter | boolean | false | Enable performance tracking |
| onPerformanceLog | function | — | Callback for performance log entries (called when enablePerformanceCounter is true) |
| customPageOverlay | function | — | Render host UI (buttons, toolbars, badges) on top of each page. Called once per page slot as (pageIndex, container, scale); may return a cleanup function. See Custom Page Overlay |
UI Mode vs Headless Mode: When you provide a container, the viewer renders with full UI (toolbar, thumbnails, etc.). Without a container, it runs in headless mode for programmatic use.
Loading Documents
The viewer accepts multiple document sources:
// From URL
await viewer.load('https://example.com/document.pdf');
// From File object (e.g., from file input)
await viewer.load(file);
// From raw bytes
await viewer.load(new Uint8Array(buffer));
// Close current document
viewer.close();Password Protection
When a password-protected document is loaded in UI mode, the viewer automatically prompts the user to enter the password. For headless mode, handle it programmatically:
await viewer.load(source);
if (await viewer.needsPassword()) {
const success = await viewer.authenticate('my-password');
if (!success) {
console.error('Incorrect password');
}
}Document Information
Access document metadata and structure:
// Check if document is loaded
if (viewer.isLoaded) {
// Get page count
const total = viewer.pageCount;
// Get document metadata
const meta = viewer.metadata;
console.log(`Title: ${meta?.title}, Author: ${meta?.author}`);
// Get page dimensions (0-based index)
const info = await viewer.getPageInfo(0);
console.log(`Page 1: ${info.width} x ${info.height} points`);
// Get document outline (table of contents)
const outline = await viewer.getOutline();
// Get annotations on a page (0-based index)
const annotations = await viewer.getPageAnnotations(0);
// Get plain text of a page (matches what search indexes)
const text = await viewer.getPageText(0);
}Extracting Page Text
getPageText(page) returns the flat text of a page exactly as the search engine sees it. Glyph runs are concatenated in visual order; spaces and tabs become " ", line breaks and paragraph ends become "\n", and inline drawings become "\uFFFC" (object replacement character). Works uniformly across PDF, DOCX, PPTX, and XLSX.
// Extract text from a single page (0-based index)
const pageText = await viewer.getPageText(0);
// Extract the full document
const pages: string[] = [];
for (let i = 0; i < viewer.pageCount; i++) {
pages.push(await viewer.getPageText(i));
}
const fullText = pages.join('\n\n');Annotations
Read, create, modify, and delete annotations on PDF documents. Each annotation has a stable name (the PDF NM identifier) that survives save/reload, so you can use it as a foreign key from your own data store.
Read
getPageAnnotationsreturns every annotation on a page — including in-memory edits and ephemeral overlays — so a single call always reflects what the user is currently seeing.
const annotations = await viewer.getPageAnnotations(0); // 0-based page index
for (const a of annotations) {
console.log(a.type, a.name, a.bounds, a.metadata?.author);
}Add
Pass any Annotation shape (highlight, underline, ink, freeText, square, etc.) along with a bounds rectangle in PDF points. If name is omitted the viewer assigns a UUID and returns it on the resolved annotation.
const created = await viewer.addPageAnnotation(0, {
type: 'highlight',
bounds: { x: 100, y: 700, width: 200, height: 20 },
quads: [{ points: [
{ x: 100, y: 700 }, { x: 300, y: 700 },
{ x: 300, y: 720 }, { x: 100, y: 720 },
] }],
color: { r: 1, g: 1, b: 0 },
metadata: { author: 'Alice', contents: 'Important' },
});
console.log(created.name); // generated UUID, stable across save/reload
// Insert many on a single page in one store update
const inserted = await viewer.addPageAnnotations(0, [
{ type: 'highlight', bounds: { x: 100, y: 700, width: 200, height: 20 }, color: { r: 1, g: 1, b: 0 } },
{ type: 'underline', bounds: { x: 100, y: 680, width: 200, height: 2 }, color: { r: 0, g: 0, b: 1 } },
]);Update, Patch & Remove
All keyed by name. updatePageAnnotation replaces the whole annotation (preserving name), while patchPageAnnotationmerges only the fields you pass — type/name are ignored, metadata is shallow-merged, and undefined values are skipped.
// Replace the whole annotation
await viewer.updatePageAnnotation(0, created.name, {
...created,
color: { r: 0, g: 1, b: 0 },
metadata: { ...created.metadata, contents: 'Reviewed' },
});
// Patch only a few fields
await viewer.patchPageAnnotation(0, created.name, {
color: { r: 0, g: 1, b: 0 },
metadata: { contents: 'Reviewed' }, // keeps existing author/subject
});
// Batch patch/update — one store update, validation is atomic
await viewer.patchPageAnnotations(0, [
{ name: a.name, patch: { color: { r: 1, g: 0, b: 0 } } },
{ name: b.name, patch: { opacity: 0.5 } },
]);
// Remove
await viewer.removePageAnnotation(0, created.name);Save & Ephemeral Annotations
PDF write-back happens automatically on toBytes() and download()whenever there are pending edits — there is no separate save call. Pass ephemeral: true to create a viewer-only annotation that renders on the canvas but is excluded from saved bytes and print output (live cursors, preview shapes, transient markers).
const bytes = await viewer.toBytes(); // pending edits flushed into the returned PDF
await viewer.download('annotated.pdf');
// Ephemeral: not written on save, not printed
const cursor = await viewer.addPageAnnotation(0, {
type: 'square',
bounds: { x: 50, y: 50, width: 30, height: 30 },
color: { r: 1, g: 0, b: 0 },
ephemeral: true,
});
// Promote an ephemeral preview into a saved annotation
await viewer.updatePageAnnotation(0, cursor.name, { ...cursor, ephemeral: false });Annotation Events
The lifecycle events (add / update / remove / select) fire for both UI-driven and API-driven changes, so a single listener covers both. hover and click are pointer-driven and fire in any tool mode, including normal viewing.
viewer.on('annotation:add', ({ pageIndex, annotation }) => {
if (annotation.ephemeral) return;
syncToBackend({ kind: 'create', pageIndex, annotation });
});
viewer.on('annotation:update', ({ pageIndex, annotation }) => { /* ... */ });
viewer.on('annotation:remove', ({ pageIndex, annotation }) => { /* ... */ });
viewer.on('annotation:select', (selection) => {
if (!selection) return; // null = deselect
showInspector(selection.annotation);
});
// Pointer hover — clientX/clientY are viewport coords for tooltip placement
viewer.on('annotation:hover', (hover) => {
if (!hover) return hideTooltip();
showTooltip(hover.annotation, { x: hover.clientX, y: hover.clientY });
});
// Pointer click — fires before built-in handling (link nav, sticky-note popups)
viewer.on('annotation:click', ({ pageIndex, annotation, clientX, clientY }) => {
console.log('clicked', annotation.subtype, 'on page', pageIndex);
});Annotation editing currently requires UI mode (a container was passed to createViewer) and is supported on PDF documents only.
Programmatic Control
Control zoom, view modes, and fullscreen programmatically — useful when toolbars are hidden:
// Zoom
viewer.zoomIn();
viewer.zoomOut();
viewer.setZoom(1.5); // 150%
viewer.setZoomMode('fit-spread-width');
viewer.setMinZoom(0.5); // clamp minimum to 50%
viewer.setMaxZoom(3); // clamp maximum to 300%
console.log(viewer.zoom); // current zoom level
console.log(viewer.zoomMode); // current zoom mode
// View modes
viewer.setScrollMode('continuous'); // 'continuous' | 'spread'
viewer.setLayoutMode('double-page'); // 'single-page' | 'double-page' | ...
viewer.setPageRotation(90); // 0 | 90 | 180 | 270
viewer.setSpacingMode('none'); // 'all' | 'none' | 'spread-only' | 'page-only'
// Theme
viewer.setTheme('dark'); // 'light' | 'dark' | 'system'
viewer.setThemeSwitchingEnabled(false); // hide theme toggle button
console.log(viewer.theme); // current theme
// Text selection
viewer.setTextSelectionEnabled(false); // disable text selection
// Fullscreen
viewer.setFullscreen(true);
console.log(viewer.isFullscreen);Tools
Switch the active tool programmatically. The active tool is a tagged union: simple tools (pointer, hand, zoom) carry no sub-tool, while tool sets (annotate, markup) carry a sub for the active sub-tool.
// Simple tools
viewer.setActiveTool({ kind: 'pointer' });
viewer.setActiveTool({ kind: 'hand' });
viewer.setActiveTool({ kind: 'zoom' });
// Tool sets — sub-tool is required
viewer.setActiveTool({ kind: 'annotate', sub: 'freehand' });
viewer.setActiveTool({ kind: 'annotate', sub: 'rectangle' });
viewer.setActiveTool({ kind: 'markup', sub: 'highlight' });
// Calling with the same tool-set kind that's already active toggles back to pointer
// Read current tool
const t = viewer.activeTool;
if (t.kind === 'annotate') {
console.log('annotating with', t.sub); // 'freehand' | 'rectangle' | ...
}AnnotateSubTool: select, freehand, line, arrow, rectangle, ellipse, polygon, polyline. MarkupSubTool: select, highlight, underline, strikethrough, squiggly.
UI Visibility
Show, hide, or disable UI components at runtime:
// Toolbar visibility
viewer.setToolbarVisible(false);
viewer.setFloatingToolbarVisible(false);
// Fullscreen button
viewer.setFullscreenEnabled(false);
// Disable entire panel areas
viewer.setLeftPanelEnabled(false);
viewer.setRightPanelEnabled(false);
// Disable individual panel tabs
// Panels: 'thumbnail', 'outline', 'bookmarks', 'layers',
// 'attachments', 'search', 'comments'
viewer.setPanelEnabled('thumbnail', false);
viewer.setPanelEnabled('search', false);
// Open/close panels programmatically
viewer.openPanel('outline');
viewer.closePanel();Search
Search document text programmatically. Works with or without the built-in search panel — ideal for building custom search UIs. Highlight overlays are rendered automatically on matching pages.
// Search and await results (returns Promise<SearchMatch[]>)
const matches = await viewer.search('hello world');
console.log(`Found ${matches.length} matches`);
// Search with options
const caseMatches = await viewer.search('hello', { caseSensitive: true });
// Fuzzy matching — strips all whitespace, control characters, pipes (|),
// and zero-width characters from both the query and document text before
// comparison. Designed for AI-generated citations where spacing or
// separators may differ from the original document text.
const fuzzy = await viewer.search('cell A | cell B', { fuzzy: true });
// Restrict search to a page range (inclusive, 0-based).
// Both text loading and match collection are limited to the range —
// useful for large documents when only a subset is of interest.
const ranged = await viewer.search('invoice', { pageRange: [10, 20] });
// Each search() call is self-contained: the range is reset unless
// explicitly provided, so a prior scoped search can't leak into a
// later broad one.
await viewer.search('invoice', { pageRange: [10, 20] }); // pages 10-20
await viewer.search('payment'); // whole doc
// Note: the built-in search panel always searches the entire document.
// pageRange is only honored when calling the API directly.
// Each match contains location, highlight rects, and context snippet
for (const match of matches) {
console.log(`Page ${match.pageIndex + 1}: "${match.context[1]}"`);
console.log(` Before: "${match.context[0]}", After: "${match.context[2]}"`);
console.log(' Rects:', match.rects); // bounding boxes in PDF points
}
// Navigate between matches (scrolls viewport to match)
viewer.searchNext();
viewer.searchPrev();
// Navigate with a per-call scroll alignment override
viewer.searchNext({ scrollAlignment: 'center' });
viewer.searchPrev({ scrollAlignment: 'top' });
// Jump to a specific match by index
viewer.setSearchActiveIndex(5);
viewer.setSearchActiveIndex(5, { scrollAlignment: 'center' });
// Read current state
console.log(viewer.searchMatches); // SearchMatch[]
console.log(viewer.searchActiveIndex); // number (-1 if none)
// Listen for incremental updates (fires as pages load and on navigation)
viewer.on('search:change', ({ matches, activeIndex }) => {
updateMySearchUI(matches, activeIndex);
});
// Clear search
viewer.clearSearch();Custom Search UI Example
Disable the built-in search panel and build your own:
const viewer = await client.createViewer({
container: '#viewer',
disableSearch: true, // hide built-in search panel
});
await viewer.load('document.pdf');
// Wire up your own search input
searchInput.addEventListener('input', async () => {
const matches = await viewer.search(searchInput.value);
renderResultsList(matches); // your custom UI
});
// Wire up next/prev buttons
nextBtn.addEventListener('click', () => viewer.searchNext());
prevBtn.addEventListener('click', () => viewer.searchPrev());Export & Download
Export document data or trigger a download:
// Export document as raw bytes
const bytes = await viewer.toBytes();
// Download document to user's device
await viewer.download('document.pdf');Page Rendering (Headless)
Render pages to images without UI, useful for generating thumbnails or image exports:
// Create headless viewer (no container)
const viewer = await client.createViewer();
await viewer.load(pdfBytes);
// Render page to ImageData (0-based page index)
const imageData = await viewer.renderPage(0, { scale: 2 });
// Render to Blob
const blob = await viewer.renderPage(0, {
format: 'blob',
imageType: 'image/png'
});
// Render to data URL
const dataUrl = await viewer.renderPage(0, {
format: 'data-url',
imageType: 'image/jpeg',
quality: 0.9
});
// Render a thumbnail
const thumb = await viewer.renderThumbnail(0, { scale: 1 });
// Render a sub-region of a page.
// rect is in page points (1pt = 1/72 inch), top-left origin — same coordinate
// space as getPageInfo(page).width / .height. Output pixel size is
// rect.width * scale x rect.height * scale.
const { width, height } = await viewer.getPageInfo(0);
const regionImageData = await viewer.renderRegion(
0,
{ x: 0, y: 0, width: width / 2, height: height / 2 },
{ scale: 2 }
);
// Same crop as a PNG blob
const regionBlob = await viewer.renderRegion(
0,
{ x: 100, y: 100, width: 200, height: 150 },
{ format: 'blob', imageType: 'image/png', scale: 2 }
);Render Options
| Option | Type | Default | Description |
|---|---|---|---|
| scale | number | 1 | Scale factor for rendering |
| format | string | image-data | Output format: 'image-data', 'image-bitmap', 'blob', 'data-url' |
| imageType | string | image/png | Image MIME type for blob/data-url output |
| quality | number | 0.92 | Image quality for JPEG output (0-1) |
Document Composition
Compose new documents by cherry-picking and rotating pages from existing documents:
// Create a new document from pages of existing documents
const [newDoc] = await client.compose([
[
{ doc: viewerA, pages: "1-3" },
{ doc: viewerB, pages: "5", rotation: 90 }
]
]);
// Export the composed document
const bytes = await newDoc.toBytes();
await newDoc.download('composed.pdf');Tip: The pages parameter accepts page ranges like "1-3", single pages like "5", or combinations like "1,3,5-7". Pages are 1-indexed.
Split & Extract
Split documents by outline and extract embedded resources:
Split by Outline
// Split a document by its top-level bookmarks
const result = await client.splitByOutline(viewer);
console.log(`Split into ${result.viewers.length} documents`);
result.sections.forEach((section, i) => {
console.log(`Document ${i}: ${section.title}`);
});
// Split with options
const result = await client.splitByOutline(viewer, {
maxLevel: 2, // Include up to 2 levels of bookmarks
splitMidPage: true, // Filter content when sections share a page
});Extract Images
// Extract all embedded images from a document
const images = await client.extractImages(viewer);
for (const image of images) {
console.log(`${image.name}: ${image.format} (${image.width}x${image.height})`);
// image.data contains the raw image bytes
}
// Convert raw images to PNG for easier viewing
const pngImages = await client.extractImages(viewer, {
convertRawToPng: true,
});Extract Fonts
// Extract all embedded fonts from a document
const fonts = await client.extractFonts(viewer);
for (const font of fonts) {
console.log(`${font.name}: ${font.fontType} (.${font.extension})`);
// font.data contains the raw font bytes
}Compress & Decompress
Optimize document file size or prepare documents for inspection:
// Compress a document (FlateDecode, object streams, compressed xref)
const compressedBytes = await client.compress(viewer);
// Decompress a document (remove all filter encodings)
const decompressedBytes = await client.decompress(viewer);
// Save the result
const blob = new Blob([compressedBytes], { type: 'application/pdf' });CSS Customization
The viewer uses CSS custom properties for all colors, shadows, and borders. Since no Shadow DOM is used, you can override any variable from your own stylesheet:
/* Override the primary color */
.udoc-viewer-root {
--udoc-primary: #e91e63;
--udoc-primary-hover: #c2185b;
}
/* Override dark theme colors */
.udoc-viewer-root.udoc-viewer-dark {
--udoc-primary: #f48fb1;
--udoc-primary-hover: #f06292;
}Available CSS Variables
| Variable | Description | Light | Dark |
|---|---|---|---|
| Backgrounds | |||
| --udoc-bg-viewport | Viewport background | #e0e0e0 | #1a1a1a |
| --udoc-bg-surface | Page / card surface | #fff | #2d2d2d |
| --udoc-bg-panel | Side panel background | #f5f5f5 | #252525 |
| --udoc-bg-panel-tabs | Panel tab bar | #e8e8e8 | #1e1e1e |
| --udoc-bg-input | Input fields | #fff | #3a3a3a |
| --udoc-bg-overlay | Modal overlay | rgba(0,0,0,0.5) | rgba(0,0,0,0.7) |
| Text | |||
| --udoc-text-primary | Primary text | rgba(0,0,0,0.8) | rgba(255,255,255,0.87) |
| --udoc-text-secondary | Secondary text | rgba(0,0,0,0.7) | rgba(255,255,255,0.7) |
| --udoc-text-muted | Muted text | rgba(0,0,0,0.5) | rgba(255,255,255,0.5) |
| --udoc-text-disabled | Disabled text | rgba(0,0,0,0.25) | rgba(255,255,255,0.25) |
| --udoc-text-placeholder | Placeholder text | #999 | #777 |
| --udoc-text-on-primary | Text on primary color | #fff | #fff |
| Primary color | |||
| --udoc-primary | Primary / accent color | #0066cc | #4da6ff |
| --udoc-primary-hover | Primary hover state | #0052a3 | #80bfff |
| --udoc-primary-focus-ring | Focus ring color | rgba(0,102,204,0.2) | rgba(77,166,255,0.25) |
| Borders | |||
| --udoc-border | Default border | #ddd | #444 |
| --udoc-border-input | Input border | #ccc | #555 |
| --udoc-border-light | Light border | #eee | #3a3a3a |
| Shadows | |||
| --udoc-shadow-page | Page shadow | 0 2px 8px ... | 0 2px 8px ... |
| --udoc-shadow-toolbar | Toolbar shadow | 0 2px 12px ... | 0 2px 12px ... |
| --udoc-shadow-dropdown | Dropdown shadow | 0 4px 16px ... | 0 4px 16px ... |
| Search | |||
| --udoc-search-highlight | Search match | rgba(255,200,0,0.35) | rgba(255,200,0,0.4) |
| --udoc-search-highlight-active | Active match | rgba(255,140,0,0.6) | rgba(255,140,0,0.65) |
| Selection | |||
| --udoc-text-selection | Text selection | rgba(0,120,215,0.3) | rgba(77,166,255,0.35) |
| Scrollbar | |||
| --udoc-scrollbar-thumb | Scrollbar thumb | rgba(0,0,0,0.3) | rgba(255,255,255,0.3) |
| --udoc-scrollbar-thumb-hover | Scrollbar thumb hover | rgba(0,0,0,0.5) | rgba(255,255,255,0.5) |
| Errors | |||
| --udoc-error-bg | Error background | #fef2f2 | #3a1c1c |
| --udoc-error-border | Error border | #fecaca | #6b2c2c |
| --udoc-error-text | Error text | #dc2626 | #f87171 |
| Progress | |||
| --udoc-progress-track | Progress bar track | #e5e7eb | #404040 |
| --udoc-progress-fill | Progress bar fill | #0066cc | #4da6ff |
All viewer styles are scoped under .udoc-viewer-root, so your overrides won't leak into the rest of the page.
Custom Page Overlay
The viewer renders each page with a stack of layers: page canvas → text → annotations → search highlights. The Custom Page Overlay sits on top of all of them, giving you a dedicated, page-aligned surface for your own UI — comment buttons, side toolbars, status badges, signature placeholders, anything that should follow the page as it scrolls, zooms, and rotates.
Provide a customPageOverlay renderer when creating the viewer. It is invoked once per page slot when the slot mounts (lazily, only for pages near the viewport in continuous mode) and may return a cleanup function that runs when the slot is destroyed.
const viewer = await client.createViewer({
container: '#viewer',
customPageOverlay: (pageIndex, container, scale) => {
const btn = document.createElement('button');
btn.textContent = '💬';
btn.style.cssText =
'position:absolute;top:8px;right:-40px;pointer-events:auto;cursor:pointer;';
btn.onclick = () => openCommentDialog(pageIndex);
container.appendChild(btn);
// Optional: cleanup runs when the page scrolls out of view
return () => btn.remove();
},
});Arguments
| Argument | Type | Description |
|---|---|---|
| pageIndex | number | 0-based page index (matches getPageAnnotations, getPageText, etc.) |
| container | HTMLElement | The overlay layer. Sized to page bounds, rotated with the page, positioned absolutely inside the page slot. Append your DOM here |
| scale | number | CSS pixels per PDF point at the current zoom. Use it to place elements at PDF-point coordinates: pixelX = pointX * scale |
- Return value — return a cleanup function (or
void). The cleanup runs when the slot is destroyed (page scrolls far out of view, document closed, or viewer destroyed). - Pointer events — the container is
pointer-events: noneby default so it never blocks text selection or annotation clicks beneath it. Opt individual elements in withpointer-events: auto. - Lifecycle— the renderer is invoked once per slot mount. Zoom/rotation/scroll updates do not re-invoke it; the layer's CSS transform handles those automatically. Attach a
ResizeObservertocontainerif you need to react to zoom. - Styling — the layer carries the class
udoc-spread__custom-page-overlay-layer. Elements placed outside the container's bounds are clipped by the page slot'soverflow: hidden.
Font Management
udoc-viewer automatically handles fonts for document rendering. By default, missing fonts are fetched from Google Fonts on-demand. You can also register custom fonts for full control.
Font resolution order: Custom registered fonts are resolved first, then Google Fonts (if enabled). Supported font formats: OTF, TTF, WOFF, and WOFF2.
// Option 1: Register fonts declaratively at client creation
const client = await UDocClient.create({
googleFonts: true, // default, enable Google Fonts fallback
fonts: [
{ typeface: 'CustomFont', bold: false, italic: false, url: 'https://cdn.example.com/CustomFont-Regular.woff2' },
{ typeface: 'CustomFont', bold: true, italic: false, url: 'https://cdn.example.com/CustomFont-Bold.woff2' },
],
});
// Option 2: Register fonts programmatically (before loading documents)
await client.registerFonts([
{ typeface: 'CustomFont', bold: false, italic: false, url: 'https://cdn.example.com/CustomFont-Regular.woff2' },
]);
// Option 3: Disable Google Fonts entirely (only use registered fonts)
const headlessClient = await UDocClient.create({ googleFonts: false });Parsing Font Info
When users upload custom font files, you typically don't know the typeface name or whether a file is the bold/italic variant. parseFontInfo extracts that metadata from the raw font binary, so you can store it in your own database alongside the font file. Later, when initializing the viewer, you retrieve that metadata and pass it to registerFonts.
Step 1: At upload time— parse the font and store the metadata + file in your database/storage.
// User uploads a font file
const fontBytes = new Uint8Array(await fontFile.arrayBuffer());
const info = await client.parseFontInfo(fontBytes);
// info = { typeface: "Roboto", bold: true, italic: false }
// Store font file to your storage (e.g., S3, GCS) and save metadata
await storage.upload(`fonts/${fontFile.name}`, fontBytes);
await db.fonts.insert({
typeface: info.typeface,
bold: info.bold,
italic: info.italic,
url: `https://cdn.example.com/fonts/${fontFile.name}`,
});Step 2: At viewing time— retrieve stored metadata and register fonts before loading a document.
// Fetch font entries from your database
const fonts = await db.fonts.list();
// Register all fonts with the viewer
await client.registerFonts(
fonts.map((f) => ({ typeface: f.typeface, bold: f.bold, italic: f.italic, url: f.url }))
);
// Now load the document — registered fonts will be used during rendering
await viewer.load(documentSource);Font Usage
After rendering, you can inspect how each font request in the document was resolved — which font was matched, its source, and any glyph-fallback fonts used during text shaping.
// Query font usage after at least one page has been rendered
const fontUsage = await viewer.getFontUsage();
for (const entry of fontUsage) {
// What the document requested
const spec = 'typeface' in entry.spec
? `${entry.spec.typeface} (bold=${entry.spec.bold}, italic=${entry.spec.italic})`
: `fontId=${entry.spec.fontId}`;
// How it was resolved
const resolved = entry.resolved;
console.log(`${spec} → ${resolved.familyName} [${resolved.source}]`);
// Any additional fonts used via glyph fallback
for (const fb of entry.fallbacks) {
console.log(` fallback: ${fb.familyName} [${fb.source}]`);
}
}
// Listen for incremental updates as pages render
viewer.on('font:usageChange', ({ entries }) => {
console.log(`Font usage updated: ${entries.length} font specs resolved`);
});The source field on each resolved font indicates where it came from: "embedded" (bundled in the document), "standard" (built-in standard font), "googleFonts", "url", "local", or { custom: string } for registered custom fonts.
Events
Subscribe to viewer events to react to document state changes:
// Document loaded
const unsubscribe = viewer.on('document:load', ({ pageCount }) => {
console.log(`Loaded ${pageCount} pages`);
});
// Document closed
viewer.on('document:close', () => {
console.log('Document closed');
});
// Page changed
viewer.on('page:change', ({ page, previousPage }) => {
console.log(`Page ${previousPage} -> ${page}`);
});
// Viewport changed (scroll, zoom, layout, or scroll-mode change).
// Throttled to one fire per animation frame and de-duped. Page indices 0-based.
viewer.on('viewport:change', ({ firstVisiblePage, lastVisiblePage, zoom, scrollTop }) => {
console.log(`Visible: ${firstVisiblePage}-${lastVisiblePage} @ ${zoom}x`);
});
// Panel opened/closed
viewer.on('panel:change', ({ panel, previousPanel }) => {
console.log(`Panel: ${previousPanel} -> ${panel}`);
});
// UI component visibility changed
viewer.on('ui:visibilityChange', ({ component, visible }) => {
console.log(`${component} is now ${visible ? 'visible' : 'hidden'}`);
});
// Document loading progress (fetching/parsing the document for display)
viewer.on('document:loading', ({ loaded, total, percent }) => {
console.log(`Loaded ${loaded}/${total} bytes (${percent}%)`);
});
// Search results changed (matches found or active match navigated)
viewer.on('search:change', ({ matches, activeIndex }) => {
console.log(`${matches.length} matches, active: ${activeIndex}`);
});
// Annotation lifecycle (fires for both UI- and API-driven changes;
// see the Annotations section above for full payloads)
viewer.on('annotation:add', ({ pageIndex, annotation }) => {});
viewer.on('annotation:update', ({ pageIndex, annotation }) => {});
viewer.on('annotation:remove', ({ pageIndex, annotation }) => {});
viewer.on('annotation:select', (selection) => {}); // null when cleared
// Error occurred. phase is 'fetch' | 'parse' | 'render' | 'permit'.
// 'permit' = free-tier verification was blocked or unavailable.
viewer.on('error', ({ error, phase }) => {
console.error(`Error during ${phase}:`, error);
});
// Unsubscribe
unsubscribe();| Event | Payload | Description |
|---|---|---|
| document:load | { pageCount } | Fired when a document is successfully loaded |
| document:close | — | Fired when the current document is closed |
| page:change | { page, previousPage } | Fired when the current page changes |
| viewport:change | { firstVisiblePage, lastVisiblePage, zoom, scrollTop } | Fired on scroll, zoom, layout, or scroll-mode change (throttled per frame) |
| panel:change | { panel, previousPanel } | Fired when a panel is opened or closed |
| ui:visibilityChange | { component, visible } | Fired when a UI component's visibility changes |
| document:loading | { loaded, total, percent } | Fired during document download/parse with progress info |
| search:change | { matches, activeIndex } | Fired when search results change or the active match is navigated |
| font:usageChange | { entries } | Fired when font usage information is updated (after page renders) |
| annotation:add / update / remove | { pageIndex, annotation } | Fired for UI- and API-driven annotation lifecycle changes |
| annotation:select | selection | null | Fired when an annotation is selected; null when cleared |
| annotation:hover / click | { pageIndex, annotation, clientX, clientY } | Pointer-driven; fire in any tool mode (hover payload is null on leave) |
| error | { error, phase } | Fired when an error occurs (fetch, parse, render, or permit) |
Cleanup
Properly dispose of resources when done to prevent memory leaks:
// Destroy the viewer first
viewer.destroy();
// Then destroy the client
client.destroy();Important: Always destroy the viewer before destroying the client. The client manages the WASM engine which the viewer depends on.
Browser Support
Requires WebAssembly support.
| Browser | Supported |
|---|---|
| Chrome / Edge | 80+ |
| Firefox | 80+ |
| Safari | 15+ |
Free-tier Usage & Verification
udoc-viewer is free to use, but free/unlicensed usage requires online verification. Before opening each document, the WebAssembly runtime requests a short-lived, signed permit from the docMentis permit service and verifies it cryptographically — inside WASM — before rendering. This caps free usage at a set number of document opens per domain per rolling 24 hours.
A commercial license removes this entirely. Licensed usage skips the permit check, never contacts docMentis servers, and renders fully offline with no usage limit.
// Free tier: each document open is verified online (limited per domain)
const client = await UDocClient.create();
// Licensed: unlimited, fully offline, no permit check
const client = await UDocClient.create({ license: 'eyJ2Ijox...' });- Network is required. If the permit service can't be reached, free/unlicensed documents won't render. (Licensed usage is always offline.)
- Local development is unmetered.
localhost,*.localhost, and loopback/private IP ranges are not counted against the limit. - No document data is sent. The permit request contains only a random per-open nonce, the embedding hostname, and the SDK version — never document content, filenames, or URLs.
- The decision is enforced in WASM, not JavaScript, so it cannot be bypassed by editing client code.
When the limit is exceeded — or a permit can't be obtained — the document does not render: the viewer shows an in-area notice, and an error event fires with phase: "permit":
viewer.on('error', ({ error, phase }) => {
if (phase === 'permit') {
// Free-tier limit reached, or verification was unavailable.
// Prompt the user to retry, or upgrade to a commercial license.
}
});Telemetry
udoc-viewer collects anonymous, non-personally-identifiable usage data to help us understand SDK adoption and prioritize format support. Telemetry fires once per document open.
What we collect
| Field | Description | Example |
|---|---|---|
| domain | Hostname of the embedding website | example.com |
| format | Document format | |
| size_bucket | File size in units of 100 KB (floor(bytes / 100000)) | 3 |
| viewer_version | SDK version string | 0.7.4 |
| license_hash | SHA-256 hash of the license key (empty if none) | a1b2c3... |
| license_info | Non-sensitive license metadata (identifier, validity) | — |
| distinct_id | Anonymous random UUID stored in localStorage | f47ac10b-... |
What we do NOT collect
- Document content, filenames, or URLs
- User identity, cookies, or session data (the UUID above is random and not linked to any account)
- IP addresses (disabled at the collection endpoint)
- Any other personally identifiable information
Data is sent via navigator.sendBeacon (with fetch fallback). No third-party SDK is loaded.
Opting Out
Telemetry can be disabled with a license that includes the no_telemetry feature:
const client = await UDocClient.create({
license: 'eyJ2Ijox...',
disableTelemetry: true,
});Without a qualifying license the flag is silently ignored and a warning is logged to the console. To obtain a license, contact licensing@docmentis.com.
Licensing
udoc-viewer is free to use, including in commercial applications. A “Powered by docMentis” attribution link is shown by default.
The JavaScript/TypeScript source code is licensed under the MIT License. The WebAssembly binary is distributed under the docMentis WASM Runtime License— free to use with the docMentis Viewer in commercial and non-commercial applications.
To remove the attribution, contact licensing@docmentis.com to purchase a license:
const client = await UDocClient.create({
license: 'eyJ2Ijox...',
});
const viewer = await client.createViewer({
container: '#viewer',
hideAttribution: true,
});
// Check license status
console.log(client.license);
// { valid: true, tier: "licensed", features: ["no_attribution"], ... }
console.log(client.hasFeature('no_attribution')); // trueThe hideAttribution option is only honored when the license includes the no_attribution feature. Without a valid license, the attribution link will remain visible.