A framework-agnostic, universal document renderer with optional chunked loading
polyrender.wisp.place/
1# PolyRender
2
3A framework-agnostic, universal document renderer for the browser. Render PDFs, EPUBs, DOCX files, CSVs, source code, and plain text — with optional support for pre-rendered page images and chunked streaming for large documents.
4
5**Core** (`@polyrender/core`) is a vanilla TypeScript library with zero framework dependencies. **React** (`@polyrender/react`) provides a thin wrapper component and hook. Both are designed for drop-in use in any web project.
6
7## Features
8
9- **Multi-format rendering** — PDF, EPUB, DOCX, ODT, ODS, CSV/TSV, source code (100+ languages), plain text, and comic book archives (.cbz, .cbr, .cb7, .cbt)
10- **Chunked loading** — Stream large documents via pre-rendered page images or split PDF chunks
11- **Fetch adapters** — Pass data directly or provide a lazy-loading callback for on-demand fetching
12- **CSS variable theming** — Dark and light themes built in, fully customizable via `--dv-*` variables
13- **Framework-agnostic** — Use vanilla JS, React, or build your own wrapper
14- **Lazy peer dependencies** — Only loads renderer libraries (pdfjs, epubjs, etc.) when that format is actually used
15- **Custom renderers** — Register your own renderer for any format via the plugin registry
16- **Word wrap / fit toggle** — Toolbar button to toggle word wrap on code/text files and fit-to-width on comic pages
17- **TypeScript-first** — Complete type definitions for all APIs
18
19## Installation
20
21```bash
22# Core (vanilla JS)
23npm install @polyrender/core
24
25# React wrapper
26npm install @polyrender/react
27
28# Install peer dependencies for the formats you need:
29npm install pdfjs-dist # PDF
30npm install epubjs # EPUB
31npm install docx-preview # DOCX
32npm install papaparse # CSV/TSV
33npm install highlight.js # Code syntax highlighting
34npm install jszip # ODT, CBZ comic archives
35npm install xlsx # ODS
36npm install papaparse # CSV/TSV
37npm install highlight.js # Code syntax highlighting
38
39# Comic book archives — additional optional backends:
40npm install node-unrar-js # CBR (.cbr, RAR-compressed comics)
41npm install 7z-wasm # CB7 (.cb7, 7-Zip-compressed comics)
42
43# Comic book archives — optional exotic image format decoders:
44npm install @jsquash/jxl # JPEG XL images inside archives
45npm install utif # TIFF images inside archives
46```
47
48You only need to install peer dependencies for the formats you plan to render. Unused formats won't add to your bundle.
49
50## Quick Start
51
52### Vanilla JS
53
54```typescript
55import { PolyRender } from '@polyrender/core'
56import '@polyrender/core/styles.css'
57
58const viewer = new PolyRender(document.getElementById('viewer')!, {
59 source: { type: 'url', url: '/document.pdf' },
60 theme: 'dark',
61 toolbar: true,
62 onReady: (info) => {
63 console.log(`Loaded: ${info.pageCount} pages`)
64 },
65 onPageChange: (page, total) => {
66 console.log(`Page ${page} of ${total}`)
67 },
68})
69
70// Imperative control
71viewer.goToPage(5)
72viewer.setZoom('fit-width')
73
74// Clean up
75viewer.destroy()
76```
77
78### React
79
80```tsx
81import { DocumentViewer } from '@polyrender/react'
82import '@polyrender/core/styles.css'
83
84function App() {
85 return (
86 <DocumentViewer
87 source={{ type: 'url', url: '/report.pdf' }}
88 theme="dark"
89 style={{ width: '100%', height: '80vh' }}
90 onReady={(info) => console.log(`${info.pageCount} pages`)}
91 onPageChange={(page, total) => console.log(`${page}/${total}`)}
92 />
93 )
94}
95```
96
97### React with Ref
98
99```tsx
100import { useRef } from 'react'
101import { DocumentViewer, type DocumentViewerRef } from '@polyrender/react'
102import '@polyrender/core/styles.css'
103
104function App() {
105 const viewerRef = useRef<DocumentViewerRef>(null)
106
107 return (
108 <>
109 <DocumentViewer
110 ref={viewerRef}
111 source={{ type: 'url', url: '/report.pdf' }}
112 style={{ width: '100%', height: '80vh' }}
113 />
114 <button onClick={() => viewerRef.current?.goToPage(1)}>
115 Go to first page
116 </button>
117 </>
118 )
119}
120```
121
122### React Hook (headless)
123
124```tsx
125import { useDocumentRenderer } from '@polyrender/react'
126import '@polyrender/core/styles.css'
127
128function CustomViewer({ url }: { url: string }) {
129 const { containerRef, state, goToPage, setZoom } = useDocumentRenderer({
130 source: { type: 'url', url },
131 theme: 'dark',
132 toolbar: false, // Hide built-in toolbar, build your own
133 })
134
135 return (
136 <div>
137 <div ref={containerRef} style={{ width: '100%', height: '600px' }} />
138 <div>
139 <button onClick={() => goToPage(state.currentPage - 1)}>Prev</button>
140 <span>{state.currentPage} / {state.totalPages}</span>
141 <button onClick={() => goToPage(state.currentPage + 1)}>Next</button>
142 <button onClick={() => setZoom(state.zoom * 1.2)}>Zoom In</button>
143 </div>
144 </div>
145 )
146}
147```
148
149## Document Sources
150
151PolyRender accepts four types of document sources:
152
153### File (binary data)
154
155```typescript
156// From a File input
157const file = inputElement.files[0]
158source = { type: 'file', data: file, filename: file.name }
159
160// From an ArrayBuffer
161source = { type: 'file', data: arrayBuffer, mimeType: 'application/pdf' }
162
163// From a Uint8Array
164source = { type: 'file', data: uint8Array, filename: 'doc.pdf' }
165```
166
167### URL
168
169```typescript
170source = { type: 'url', url: 'https://example.com/doc.pdf' }
171
172// With custom headers (e.g., auth)
173source = {
174 type: 'url',
175 url: '/api/documents/123.pdf',
176 fetchOptions: { headers: { Authorization: 'Bearer ...' } },
177}
178```
179
180### Pre-rendered Pages (for browsing without the original document)
181
182```typescript
183// Direct data
184source = {
185 type: 'pages',
186 pages: [
187 { pageNumber: 1, imageUrl: '/pages/1.webp', width: 1654, height: 2339 },
188 { pageNumber: 2, imageUrl: '/pages/2.webp', width: 1654, height: 2339 },
189 ],
190}
191
192// Lazy fetch adapter (loads pages on demand as user scrolls)
193source = {
194 type: 'pages',
195 pages: {
196 totalPages: 500,
197 fetchPage: async (pageNumber) => ({
198 pageNumber,
199 imageUrl: `/api/pages/${pageNumber}.webp`,
200 width: 1654,
201 height: 2339,
202 }),
203 },
204}
205```
206
207### Chunked PDF (streaming large documents)
208
209```typescript
210source = {
211 type: 'chunked',
212 totalPages: 500,
213 // PDF chunks for full-fidelity rendering
214 chunks: {
215 totalChunks: 10,
216 totalPages: 500,
217 fetchChunk: async (index) => {
218 const res = await fetch(`/api/chunks/${index}.pdf`)
219 return {
220 data: await res.arrayBuffer(),
221 pageStart: index * 50 + 1,
222 pageEnd: Math.min((index + 1) * 50, 500),
223 }
224 },
225 getChunkIndexForPage: (page) => Math.floor((page - 1) / 50),
226 },
227 // Optional: fast browse images while chunks load
228 browsePages: {
229 totalPages: 500,
230 fetchPage: async (pageNumber) => ({
231 pageNumber,
232 imageUrl: `/api/browse/${pageNumber}.webp`,
233 width: 1654,
234 height: 2339,
235 }),
236 },
237}
238```
239
240## Theming
241
242PolyRender uses CSS custom properties for all visual styling. Override any `--dv-*` variable to customize:
243
244```css
245/* Custom theme */
246.my-viewer .polyrender {
247 --dv-bg: #1e1e2e;
248 --dv-surface: #2a2a3e;
249 --dv-text: #cdd6f4;
250 --dv-accent: #89b4fa;
251 --dv-border: #45475a;
252 --dv-page-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
253 --dv-font-sans: 'JetBrains Mono', monospace;
254}
255```
256
257Built-in themes: `dark` (default) and `light`. Set via the `theme` prop/option, or `'system'` to auto-detect from `prefers-color-scheme`.
258
259### Key CSS Variables
260
261| Variable | Description |
262|----------|-------------|
263| `--dv-bg` | Background color |
264| `--dv-surface` | Toolbar and panel backgrounds |
265| `--dv-text` | Primary text color |
266| `--dv-text-secondary` | Secondary/muted text |
267| `--dv-accent` | Accent color (links, focus rings) |
268| `--dv-border` | Border color |
269| `--dv-page-bg` | Document page background |
270| `--dv-page-shadow` | Document page drop shadow |
271| `--dv-font-sans` | Sans-serif font stack |
272| `--dv-font-mono` | Monospace font stack |
273| `--dv-radius` | Border radius |
274| `--dv-toolbar-height` | Toolbar height |
275
276See `styles.css` for the complete list.
277
278## Format-Specific Options
279
280### PDF
281
282```typescript
283{
284 pdf: {
285 workerSrc: '/pdf.worker.min.js', // pdf.js worker URL
286 cMapUrl: '/cmaps/', // Character map directory
287 textLayer: true, // Enable text selection (default true)
288 annotationLayer: false, // Show PDF annotations
289 }
290}
291```
292
293### Code
294
295```typescript
296{
297 code: {
298 language: 'typescript', // Force language (auto-detected from extension)
299 lineNumbers: true, // Show line numbers (default true)
300 wordWrap: false, // Enable word wrapping (default false)
301 tabSize: 2, // Tab width in spaces (default 2)
302 }
303}
304```
305
306### CSV
307
308```typescript
309{
310 csv: {
311 delimiter: ',', // Field delimiter (auto-detected)
312 header: true, // First row is header (default true)
313 maxRows: 10000, // Max rows to render (default 10000)
314 sortable: true, // Enable column sorting (default true)
315 }
316}
317```
318
319### EPUB
320
321```typescript
322{
323 epub: {
324 flow: 'paginated', // 'paginated' or 'scrolled' (default 'paginated')
325 fontSize: 16, // Font size in pixels (default 16)
326 fontFamily: 'Georgia', // Font override
327 }
328}
329```
330
331### ODT
332
333```typescript
334{
335 odt: {
336 fontSize: 16, // Base font size in pixels (default 16)
337 fontFamily: 'Georgia', // Font override
338 }
339}
340```
341
342### ODS
343
344```typescript
345{
346 ods: {
347 maxRows: 10000, // Max rows to render per sheet (default 10000)
348 sortable: true, // Enable column sorting (default true)
349 header: true, // First row is header (default true)
350 }
351}
352```
353
354## Custom Renderers
355
356Register a renderer for any format:
357
358```typescript
359import { PolyRender, BaseRenderer, type PolyRenderOptions, type DocumentFormat } from '@polyrender/core'
360
361class MarkdownRenderer extends BaseRenderer {
362 readonly format: DocumentFormat = 'custom-markdown'
363
364 protected async onMount(viewport: HTMLElement, options: PolyRenderOptions) {
365 // Your rendering logic here
366 const text = await this.loadText(options.source)
367 const html = myMarkdownLib.render(text)
368 viewport.innerHTML = html
369 this.setReady({ format: 'custom-markdown', pageCount: 1 })
370 }
371
372 protected onDestroy() {}
373}
374
375// Register globally
376PolyRender.registerRenderer('custom-markdown', () => new MarkdownRenderer())
377
378// Use it
379new PolyRender(container, {
380 source: { type: 'url', url: '/readme.md' },
381 format: 'custom-markdown',
382})
383```
384
385## Supported Formats
386
387| Format | Peer Dependency | Auto-detected Extensions |
388|--------|----------------|-------------------------|
389| PDF | `pdfjs-dist` | `.pdf` |
390| EPUB | `epubjs` | `.epub` |
391| DOCX | `docx-preview` | `.docx`, `.doc` |
392| ODT | `jszip` | `.odt` |
393| ODS | `xlsx` | `.ods` |
394| CSV/TSV | `papaparse` | `.csv`, `.tsv` |
395| Code | `highlight.js` | `.js`, `.ts`, `.py`, `.rs`, `.go`, `.java`, `.c`, `.cpp`, +80 more |
396| Text | _(none)_ | `.txt` |
397| Markdown | `highlight.js` | `.md` (rendered as syntax-highlighted code) |
398| JSON | `highlight.js` | `.json` |
399| XML/HTML | `highlight.js` | `.xml`, `.html`, `.svg` |
400| Pages | _(none)_ | N/A (explicit `type: 'pages'`) |
401| Chunked PDF | `pdfjs-dist` | N/A (explicit `type: 'chunked'`) |
402| Comic — CBZ | `jszip` | `.cbz` |
403| Comic — CBR | `node-unrar-js` _(optional)_ | `.cbr` |
404| Comic — CB7 | `7z-wasm` _(optional)_ | `.cb7` |
405| Comic — CBT | _(none, built-in TAR reader)_ | `.cbt` |
406| Comic — CBA | ❌ not supported | `.cba` |
407
408## Browser Support
409
410- Chrome/Edge 88+
411- Firefox 78+
412- Safari 15.4+ (OffscreenCanvas support for Web Worker rendering)
413
414## Project Structure
415
416```
417packages/
418├── core/ @polyrender/core — Framework-agnostic TypeScript core
419│ ├── src/
420│ │ ├── types.ts # All interfaces and types
421│ │ ├── polyrender.ts # Main PolyRender class
422│ │ ├── renderer.ts # Abstract base renderer
423│ │ ├── registry.ts # Format → renderer factory mapping
424│ │ ├── toolbar.ts # Built-in toolbar DOM builder
425│ │ ├── utils.ts # Format detection, data conversion, DOM helpers
426│ │ ├── styles.css # CSS variables theme system
427│ │ └── renderers/
428│ │ ├── pdf.ts # PDF (pdfjs-dist)
429│ │ ├── browse-pages.ts # Pre-rendered page images
430│ │ ├── chunked-pdf.ts # Chunked PDF streaming
431│ │ ├── epub.ts # EPUB (epubjs)
432│ │ ├── docx.ts # DOCX (docx-preview)
433│ │ ├── odt.ts # ODT (jszip)
434│ │ ├── ods.ts # ODS (xlsx)
435│ │ ├── csv.ts # CSV/TSV (papaparse)
436│ │ ├── code.ts # Code (highlight.js)
437│ │ ├── text.ts # Plain text
438│ │ └── comic.ts # Comic book archives (jszip / node-unrar-js / 7z-wasm)
439│ └── package.json
440└── react/ @polyrender/react — React wrapper
441 ├── src/
442 │ ├── DocumentViewer.tsx # Drop-in component
443 │ ├── useDocumentRenderer.ts # Headless hook
444 │ └── index.ts
445 └── package.json
446```
447
448## Live Demo
449
450A hosted version of the vanilla example is available at **https://polyrender.wisp.place/**.
451
452## Repository
453
454The source code is hosted in two locations:
455
456- **Tangled** (primary): https://tangled.org/aria.pds.witchcraft.systems/polyrender
457- **GitHub** (mirror): https://github.com/BuyMyMojo/polyrender
458
459## License
460
461Zlib