web-annotation-renderer 
A framework-agnostic PDF annotation renderer with timeline synchronization for educational content, interactive presentations, and annotated documents.
This library renders structured annotation data (highlights, text boxes, drawings) on top of PDF documents, synchronized with audio or video timelines. Built on pdf.js with a clean, modern API.
Features - π PDF Rendering - Built on pdf.js for reliable PDF display - β±οΈ Timeline Synchronization - Sync annotations with audio/video playback or manual controls - π¨ Multiple Annotation Types - Highlights, text, underlines, arrows, and circles - π Multilingual Text - Full support for Latin, Korean (11,172 syllables), and CJK characters - βοΈ Framework Agnostic - Core engine works with any framework - βοΈ React Adapter - Ready-to-use React component included - π― Progressive Animations - Smooth reveal animations based on timeline - π¬ Continuous Sync - Built-in support for real-time audio/video synchronization - π¦ Simple Setup - One-line worker configuration - π² Tree-shakeable - Import only what you need - β‘ Performance Optimized - Efficient canvas-based rendering with StrokeRenderer
Installation ``bash npm install web-annotation-renderer`
Requirements:
- Node.js >= 18 - React 18 or 19 (only if using React adapter)
Worker Configuration Important: Before using the library, configure the PDF.js worker in your application:
`javascript import * as pdfjsLib from "pdfjs-dist";
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( "pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url ).toString(); `
This must be done once at application startup, before using any PDF functionality.
Quick Start - Vanilla JavaScript `javascript import { AnnotationRenderer } from "web-annotation-renderer"; import * as pdfjsLib from "pdfjs-dist";
// Configure PDF.js worker (call once at app startup) pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( "pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url ).toString();
// Create renderer instance const renderer = new AnnotationRenderer({ container: document.getElementById("annotation-container"), canvasElement: document.getElementById("pdf-canvas"), });
// Load PDF const result = await renderer.loadPDF("/path/to/document.pdf"); if (result.success) { console.log(PDF loaded with ${result.pageCount} pages); }
// Set annotations renderer.setAnnotations([ { id: "1", type: "highlight", page: 1, start: 0, end: 5, mode: "quads", quads: [{ x: 0.1, y: 0.2, w: 0.3, h: 0.05 }], style: { color: "rgba(255, 255, 0, 0.3)" }, }, ]);
// Set initial page and scale await renderer.setPage(1); await renderer.setScale(1.0);
// Update timeline position renderer.setTime(2.5); // seconds `
Quick Start - React `javascript import { useState } from "react"; import { AnnotPdf } from "web-annotation-renderer"; import * as pdfjsLib from "pdfjs-dist";
// Configure PDF.js worker (call once at app startup) pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( "pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url ).toString();
function App() { const [currentTime, setCurrentTime] = useState(0); const [page, setPage] = useState(1); const [totalPages, setTotalPages] = useState(1);
const annotations = [ { id: "1", type: "highlight", page: 1, start: 0, end: 5, mode: "quads", quads: [{ x: 0.1, y: 0.2, w: 0.3, h: 0.05 }], style: { color: "rgba(255, 255, 0, 0.3)" }, }, ];
const handleLoad = (pdfDocument) => { setTotalPages(pdfDocument.pageCount); console.log(PDF loaded with ${pdfDocument.pageCount} pages); };
const handleError = (error) => { console.error("PDF Error:", error); };
return ( pdfUrl="/path/to/document.pdf" annotations={annotations} currentTime={currentTime} page={page} scale={1.5} onLoad={handleLoad} onError={handleError} onPageChange={setPage} /> ); } `
Audio/Video Synchronization For smooth, real-time synchronization with audio or video playback, use the continuous sync feature:
$3 `javascript const renderer = new AnnotationRenderer({ container: document.getElementById("annotation-container"), canvasElement: document.getElementById("pdf-canvas"), });
await renderer.loadPDF("/document.pdf"); await renderer.setPage(1);
// Get reference to audio/video element const audioElement = document.getElementById("lecture-audio");
// Start continuous sync when playback begins audioElement.addEventListener("play", () => { renderer.timelineSync.startContinuousSync(() => audioElement.currentTime); });
// Stop continuous sync when playback pauses audioElement.addEventListener("pause", () => { renderer.timelineSync.stopContinuousSync(); });
// Clean up on page unload window.addEventListener("beforeunload", () => { renderer.timelineSync.stopContinuousSync(); renderer.destroy(); }); `
$3 `javascript import { useRef, useEffect } from "react"; import { AnnotPdf } from "web-annotation-renderer";
function VideoSyncViewer() { const audioRef = useRef(null); const engineRef = useRef(null);
// Access the internal engine through the component useEffect(() => { const audio = audioRef.current; if (!audio) return;
const handlePlay = () => { // Start continuous sync at 60fps if (engineRef.current) { engineRef.current.timelineSync.startContinuousSync( () => audio.currentTime ); } };
const handlePause = () => { // Stop continuous sync if (engineRef.current) { engineRef.current.timelineSync.stopContinuousSync(); } };
audio.addEventListener("play", handlePlay); audio.addEventListener("pause", handlePause);
return () => { audio.removeEventListener("play", handlePlay); audio.removeEventListener("pause", handlePause); engineRef.current?.timelineSync.stopContinuousSync(); }; }, []);
return (
ref={engineRef}
pdfUrl="/lecture.pdf"
annotations={annotations}
onLoad={(doc) => console.log("PDF loaded")}
/>
);
}
`
How it works: -
startContinuousSync()
creates a 60fps requestAnimationFrame loop - Each frame reads the current time from your callback function - Annotations update smoothly in sync with playback - stopContinuousSync()
stops the loop to save resources when pausedFor manual controls (sliders, buttons), simply use
renderer.setTime()
or the currentTime
prop - continuous sync is not needed.
Multilingual Text Support The library supports hand-written style text rendering for multiple languages using single-stroke fonts.
$3 | Language | Unicode Range | Characters | Loading | Status | |----------|---------------|------------|---------|--------| | Latin |
U+0000-007F
| 78 (A-Z, a-z, 0-9, punctuation) | Bundled | β
Ready | | Korean | U+AC00-D7A3
| 11,172 (κ°-ν£, all syllables) | Bundled | β
Ready | | CJK (Chinese/Japanese Kanji) | U+4E00-9FFF
| 20,976 | Dynamic | β οΈ Requires preload |
$3 | Configuration | Size (gzip) | |---------------|-------------| | Core + Latin | ~50 KB | | Core + Latin + Korean | ~639 KB | | CJK data (separate) | ~2 MB |
$3 CJK character data is not bundled by default due to its large size (~10MB uncompressed). You must preload it before rendering CJK text.
#### Step 1: Serve the CJK data file
Copy
node_modules/web-annotation-renderer/public/cjk.json
to your public assets folder.#### Step 2: Preload before rendering
`
javascript import { preloadCJK } from "web-annotation-renderer";// Option 1: Default path (/cjk.json) await preloadCJK();
// Option 2: Custom path await preloadCJK("/assets/fonts/cjk.json");
// Now CJK characters will render correctly renderer.setAnnotations([ { id: "chinese-text", type: "text", page: 1, start: 0, end: 5, content: "δΈζζε", // Chinese text x: 0.1, y: 0.2, fontSize: 24, }, ]);
`
#### Important Notes
- Without preloading: CJK characters will silently fail to render (no error, just blank) - Preload timing: Call
preloadCJK()
early in your app initialization - Caching: The library caches CJK data after first load; subsequent calls are no-ops - Mixed text: Latin and Korean render immediately; only CJK requires preloading
$3 The following character ranges are not currently supported :
| Type | Examples | Workaround | |------|----------|------------| | Extended Latin | Γ©, Γ±, ΓΌ, ΓΈ | Use ASCII equivalents | | Korean Jamo | γ±, γ
, γ΄ | Use complete syllables (κ°, λ) | | Japanese Hiragana/Katakana | γ, γ’ | Not supported | | Emoji | π, π | Not supported |
Unsupported characters are skipped during rendering with default spacing.
API Reference
$3 #### Constructor
`
javascript const renderer = new AnnotationRenderer(config);`
Configuration Options:
| Property | Type | Required | Default | Description | | --------------- | ----------------- | -------- | ------- | -------------------------------------- | |
container
| HTMLElement | β
Yes | - | DOM element for annotation layers | | canvasElement
| HTMLCanvasElement | β
Yes | - | Canvas element for PDF rendering | | pdfUrl
| string | No | null
| PDF URL to auto-load on initialization | | initialPage
| number | No | 1
| Initial page number to display | | initialScale
| number | No | 1.0
| Initial zoom/scale factor | | annotations
| Array | No | []
| Initial annotation data |Example:
`
javascript const renderer = new AnnotationRenderer({ container: document.getElementById("annotation-container"), canvasElement: document.getElementById("pdf-canvas"), pdfUrl: "/document.pdf", // Optional: auto-load initialPage: 1, initialScale: 1.5, annotations: [], });`
#### Methods
#####
loadPDF(url)
Load a PDF document from URL.
`
javascript const result = await renderer.loadPDF("/path/to/document.pdf");`
Parameters:
-
url
(string): URL or path to PDF fileReturns:
Promise
`javascript { success: boolean, // Whether loading succeeded pageCount?: number, // Number of pages (if successful) error?: string // Error message (if failed) }`#####
setPage(pageNum)Navigate to a specific page.
`javascript const result = await renderer.setPage(2);`Parameters:
-
pageNum (number): Page number (1-indexed)Returns:
Promise
`javascript { success: boolean, viewport?: Object, // Viewport dimensions (if successful) error?: string // Error message (if failed) }`#####
setScale(scale)Change the zoom level.
`javascript const result = await renderer.setScale(1.5);`Parameters:
-
scale (number): Scale factor (e.g., 0.5, 1.0, 1.5, 2.0)Returns:
Promise - Same structure as setPage()#####
setAnnotations(annotations)Update the annotation data.
`javascript renderer.setAnnotations(annotationsArray);`Parameters:
-
annotations (Array): Array of annotation objectsReturns:
void#####
setTime(timestamp)Update the timeline position for animation synchronization.
`javascript renderer.setTime(5.2); // 5.2 seconds`Parameters:
-
timestamp (number): Current timeline position in secondsReturns:
void#####
getState()Get the current renderer state.
`javascript const state = renderer.getState();`Returns:
Object
`javascript { page: number, // Current page number scale: number, // Current scale factor annotations: Array, // Current annotations array pageCount: number, // Total page count time: number, // Current timeline position viewport: Object|null, // Current viewport dimensions pdfUrl: string|null // Current PDF URL }`#####
destroy()Clean up all resources and subsystems.
`javascript renderer.destroy();`Returns:
voidImportant: Call this before removing the renderer instance to prevent memory leaks.
---
$3 The
AnnotationRenderer exposes a timelineSync property for advanced timeline control.####
timelineSync.startContinuousSync(getTimeFunction)Start continuous timeline synchronization with audio/video.
`javascript renderer.timelineSync.startContinuousSync(() => audioElement.currentTime);`Parameters:
-
getTimeFunction (Function): Callback that returns current time in secondsReturns:
voidDetails: Creates a 60fps requestAnimationFrame loop that continuously reads time from the callback and updates annotations. Use this for smooth audio/video synchronization.
####
timelineSync.stopContinuousSync()Stop continuous timeline synchronization.
`javascript renderer.timelineSync.stopContinuousSync();`Returns:
voidImportant: Always call this when audio/video pauses or when cleaning up to prevent unnecessary rendering.
####
timelineSync.getCurrentTime()Get the current timeline position.
`javascript const currentTime = renderer.timelineSync.getCurrentTime();`Returns:
number - Current timeline position in seconds####
timelineSync.subscribe(callback)Subscribe to timeline updates.
`javascript const unsubscribe = renderer.timelineSync.subscribe((time) => { console.log("Timeline updated:", time); });// Later: unsubscribe unsubscribe();
`Parameters:
-
callback (Function): Called when timeline updates with current timeReturns:
Function - Unsubscribe function---
$3 #### Props
`javascript pdfUrl={string} page={number} scale={number} annotations={array} currentTime={number} onLoad={function} onError={function} onPageChange={function} className={string} style={object} canvasStyle={object} />`Prop Reference:
| Prop | Type | Required | Default | Description | | -------------- | -------- | -------- | ------- | -------------------------------------------- | |
pdfUrl | string | β
Yes | - | URL or path to PDF file | | page | number | No | 1 | Current page number (1-indexed) | | scale | number | No | 1.5 | Zoom level / scale factor | | annotations | Array | No | [] | Array of annotation objects | | currentTime | number | No | 0 | Current timeline position in seconds | | onLoad | function | No | - | Callback when PDF loads: (doc) => void | | onError | function | No | - | Callback on error: (error) => void | | onPageChange | function | No | - | Callback on page change: (pageNum) => void | | className | string | No | - | CSS class for container div | | style | object | No | {} | Inline styles for container div | | canvasStyle | object | No | {} | Inline styles for canvas element |Note: The component auto-sizes based on PDF dimensions and scale. There are no
width or height props.Example with all props:
`javascript pdfUrl="/document.pdf" page={currentPage} scale={1.5} annotations={annotations} currentTime={audioTime} onLoad={(doc) => setTotalPages(doc.pageCount)} onError={(err) => console.error(err)} onPageChange={(num) => setCurrentPage(num)} className="pdf-viewer" style={{ border: "1px solid #ccc" }} canvasStyle={{ boxShadow: "0 2px 8px rgba(0,0,0,0.1)" }} />`
Annotation Data Format All annotations use normalized coordinates (0-1 range) for positioning, making them resolution-independent.
$3 All annotation types share these base fields:
| Field | Type | Required | Description | | ------- | ------ | -------- | ------------------------------------------------------------------------ | |
id | string | β
Yes | Unique identifier for the annotation | | type | string | β
Yes | Annotation type: "highlight", "text", "underline", "arrow", or "circle" | | page | number | β
Yes | Page number (1-indexed) | | start | number | β
Yes | Timeline start time in seconds | | end | number | β
Yes | Timeline end time in seconds |---
$3 Highlights rectangular regions on the PDF with progressive reveal animation.
Type:
"highlight"Structure:
`javascript { id: "highlight-1", type: "highlight", page: 1, start: 0, end: 5, mode: "quads", // β
REQUIRED - must be "quads" quads: [ { x: 0.1, y: 0.2, w: 0.3, h: 0.05 }, // First quad { x: 0.1, y: 0.25, w: 0.35, h: 0.05 } // Second quad (optional) ], style: { color: "rgba(255, 255, 0, 0.3)" // Highlight color } }`Fields:
| Field | Type | Required | Description | | ------------- | ------ | -------- | -------------------------------------------------- | |
mode | string | β
Yes | Must be "quads" | | quads | Array | β
Yes | Array of quad objects defining highlighted regions | | quads[].x | number | β
Yes | Left position (0-1, normalized) | | quads[].y | number | β
Yes | Top position (0-1, normalized) | | quads[].w | number | β
Yes | Width (0-1, normalized) | | quads[].h | number | β
Yes | Height (0-1, normalized) | | style.color | string | β
Yes | CSS color for highlight |Animation: Highlights reveal progressively from left to right across all quads during the
start to end timeline.Example:
`javascript { id: "hl-1", type: "highlight", page: 1, start: 2.0, end: 7.0, mode: "quads", quads: [ { x: 0.1, y: 0.3, w: 0.4, h: 0.05 } ], style: { color: "rgba(255, 255, 0, 0.4)" } }`---
$3 Display hand-written style text with progressive character-by-character animation using single-stroke fonts. Supports Latin , Korean (κ°-ν£), and CJK characters (see Multilingual Text Support ).
Type:
"text"Structure:
`javascript { id: "text-1", type: "text", page: 1, start: 3, end: 8, content: "This is the annotation text", x: 0.5, // Left position (normalized) y: 0.2, // Top position (normalized) fontSize: 16, // Font size in pixels style: { color: "rgba(220, 20, 60, 1.0)" // Text color } }`Fields:
| Field | Type | Required | Default | Description | | ------------- | ------ | -------- | ---------------------------- | ------------------------------- | |
content | string | β
Yes | - | Text to display | | x | number | β
Yes | - | Left position (0-1, normalized) | | y | number | β
Yes | - | Top position (0-1, normalized) | | fontSize | number | No | 16 | Font size in pixels | | style.color | string | No | "rgba(220, 20, 60, 1.0)" | Text stroke color |Animation: Text is drawn character-by-character using single-stroke font glyphs, creating a hand-written writing effect.
Example:
`javascript { id: "txt-1", type: "text", page: 1, start: 5.0, end: 12.0, content: "Important concept", x: 0.6, y: 0.4, fontSize: 18, style: { color: "rgba(0, 100, 200, 1.0)" } }`---
$3 Draw underlines beneath text regions with progressive reveal animation.
Type:
"underline"Structure:
`javascript { id: "underline-1", type: "underline", page: 1, start: 0, end: 2, quads: [ { x: 0.1, y: 0.2, w: 0.8, h: 0.05 } ], style: { color: "rgba(0, 0, 255, 0.8)" } }`Fields:
| Field | Type | Required | Default | Description | | ------------- | ------ | -------- | ------------------------ | ------------------------------- | |
quads | Array | β
Yes | - | Array of quad objects | | quads[].x | number | β
Yes | - | Left position (0-1, normalized) | | quads[].y | number | β
Yes | - | Top position (0-1, normalized) | | quads[].w | number | β
Yes | - | Width (0-1, normalized) | | quads[].h | number | β
Yes | - | Height (0-1, normalized) | | style.color | string | No | "rgba(0, 0, 255, 0.8)" | Underline color |Animation: Underlines draw progressively at the bottom edge of each quad from left to right.
---
$3 Draw arrows pointing from source to target with progressive reveal animation.
Type:
"arrow"Structure:
`javascript { id: "arrow-1", type: "arrow", page: 1, start: 0, end: 2, from_x: 0.2, from_y: 0.3, to_x: 0.8, to_y: 0.7, style: { color: "rgba(255, 0, 0, 0.8)" } }`Fields:
| Field | Type | Required | Default | Description | | ------------- | ------ | -------- | ------------------------ | --------------------------------- | |
from_x | number | β
Yes | - | Source x position (0-1) | | from_y | number | β
Yes | - | Source y position (0-1) | | to_x | number | β
Yes | - | Target x position (0-1) | | to_y | number | β
Yes | - | Target y position (0-1) | | style.color | string | No | "rgba(255, 0, 0, 0.8)" | Arrow color |Animation: Line draws first (70% of duration), then arrowhead wings draw (30% of duration).
---
$3 Draw circles/ellipses with progressive reveal animation around the perimeter.
Type:
"circle"Structure:
`javascript { id: "circle-1", type: "circle", page: 1, start: 0, end: 2, cx: 0.5, cy: 0.5, rx: 0.1, ry: 0.1, style: { color: "rgba(255, 165, 0, 0.8)" } }`Fields:
| Field | Type | Required | Default | Description | | ------------- | ------ | -------- | -------------------------- | ----------------------------- | |
cx | number | β
Yes | - | Center x position (0-1) | | cy | number | β
Yes | - | Center y position (0-1) | | rx | number | β
Yes | - | Horizontal radius (0-1) | | ry | number | β
Yes | - | Vertical radius (0-1) | | style.color | string | No | "rgba(255, 165, 0, 0.8)" | Circle color |Animation: Circle draws progressively around the perimeter from 0Β° to 360Β°.
---
$3 All position and size values use normalized coordinates (0-1 range):
-
0 = left edge / top edge - 1 = right edge / bottom edge - 0.5 = centerExample:
x: 0.1, y: 0.2, w: 0.3, h: 0.05 means:- Starts at 10% from left, 20% from top - Width is 30% of page width - Height is 5% of page height
This makes annotations resolution-independent and responsive to different screen sizes.
---
$3
`javascript const annotations = [ // Highlight { id: "h1", type: "highlight", page: 1, start: 0, end: 5, mode: "quads", quads: [{ x: 0.1, y: 0.2, w: 0.3, h: 0.05 }], style: { color: "rgba(255, 255, 0, 0.4)" }, }, // Text (hand-written style) { id: "t1", type: "text", page: 1, start: 3, end: 8, content: "Key concept here", x: 0.6, y: 0.2, fontSize: 16, style: { color: "rgba(220, 20, 60, 1.0)" }, },
// Underline { id: "u1", type: "underline", page: 1, start: 5, end: 7, quads: [{ x: 0.1, y: 0.3, w: 0.4, h: 0.03 }], style: { color: "rgba(0, 0, 255, 0.8)" }, },
// Arrow { id: "a1", type: "arrow", page: 1, start: 7, end: 10, from_x: 0.2, from_y: 0.5, to_x: 0.6, to_y: 0.6, style: { color: "rgba(255, 0, 0, 0.8)" }, },
// Circle { id: "c1", type: "circle", page: 1, start: 10, end: 13, cx: 0.75, cy: 0.4, rx: 0.08, ry: 0.06, style: { color: "rgba(255, 165, 0, 0.8)" }, }, ];
`
Browser Compatibility Works in all modern browsers:
- Chrome/Edge (latest) - Firefox (latest) - Safari (latest)
Requires:
- ES6+ support - Canvas API - Web Workers
Troubleshooting
$3 Symptoms: PDF renders but annotations don't show when moving timeline
Solutions:
1. Check that highlight annotations include
mode: "quads" field 2. Verify annotation type is one of: "highlight", "text", "underline", "arrow", "circle" 3. Check browser console for validation warnings (unknown type warnings) 4. Verify timeline position is within annotation start/end range 5. Ensure coordinates are normalized (0-1 range)
$3 Symptoms: "Setting up fake worker" or worker-related errors
Solution: Ensure PDF.js worker is configured before using the library:
`javascript import * as pdfjsLib from "pdfjs-dist";pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( "pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url ).toString();
`
$3 Symptoms: TypeScript errors about missing props
Solution: Install React type definitions:
`bash npm install --save-dev @types/react @types/react-dom`
$3 Symptoms: Annotations don't update smoothly when dragging timeline slider
Solution: - For manual controls (sliders, buttons): Use
renderer.setTime() or the currentTime prop directly - the system is optimized for discrete updates - For audio/video: Use timelineSync.startContinuousSync() for smooth 60fps synchronization
$3 Symptoms: Chinese or Japanese kanji characters appear blank while Latin and Korean text renders correctly
Solution: CJK data must be preloaded before rendering:
`javascript import { preloadCJK } from "web-annotation-renderer";// Call early in your app initialization await preloadCJK("/path/to/cjk.json");
`See Multilingual Text Support for details.
Migration Guide If you're upgrading from documentation examples that used the old format:
$3 1. Package name:
@ai-annotator/renderer β web-annotation-renderer 2. Method name: renderer.updateTimeline() β renderer.setTime() 3. Highlight annotations: Must include mode: "quads" field 4. Text annotations: Now render as hand-written style using single-stroke font; use fontSize instead of w/h 5. Ink annotations: Removed in v0.5.0; use arrow, underline, or circle instead 6. New annotation types: underline, arrow, circle added in v0.5.0
$3
`javascript // OLD (won't work) import { AnnotationRenderer } from "@ai-annotator/renderer"; renderer.updateTimeline(5.0);// NEW (correct) import { AnnotationRenderer } from "web-annotation-renderer"; renderer.setTime(5.0);
`
Additional Resources - GitHub Repository - Issue Tracker - Changelog
Examples Check out working examples in the test projects:
- Vanilla JavaScript: See
examples/vanilla-js/ for a complete implementation with manual timeline control - React: See examples/react-basic/ for React component usage with slider controlsBoth examples include:
- PDF loading and rendering - Page navigation and zoom controls - Timeline slider with annotation synchronization - All five annotation types (highlight, text, underline, arrow, circle) - Optimized canvas-based rendering for smooth, flicker-free updates
Use Cases: - Manual timeline control (sliders, buttons): Use
setTime() for discrete updates - Audio/video sync : Use timelineSync.startContinuousSync()` for continuous 60fps updates
License MIT Β© [jhl72e]