A framework-agnostic, universal document renderer with optional chunked loading polyrender.wisp.place/
at main 461 lines 14 kB view raw view rendered
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