A framework-agnostic, universal document renderer with optional chunked loading polyrender.wisp.place/
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add CBZ support

aria 0932cad6 62612681

+1073 -18
+20 -3
README.md
··· 6 6 7 7 ## Features 8 8 9 - - **Multi-format rendering** — PDF, EPUB, DOCX, CSV/TSV, source code (100+ languages), plain text 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 10 - **Chunked loading** — Stream large documents via pre-rendered page images or split PDF chunks 11 11 - **Fetch adapters** — Pass data directly or provide a lazy-loading callback for on-demand fetching 12 12 - **CSS variable theming** — Dark and light themes built in, fully customizable via `--dv-*` variables 13 13 - **Framework-agnostic** — Use vanilla JS, React, or build your own wrapper 14 14 - **Lazy peer dependencies** — Only loads renderer libraries (pdfjs, epubjs, etc.) when that format is actually used 15 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 16 17 - **TypeScript-first** — Complete type definitions for all APIs 17 18 18 19 ## Installation ··· 30 31 npm install docx-preview # DOCX 31 32 npm install papaparse # CSV/TSV 32 33 npm install highlight.js # Code syntax highlighting 33 - npm install jszip # ODT 34 + npm install jszip # ODT, CBZ comic archives 34 35 npm install xlsx # ODS 36 + npm install papaparse # CSV/TSV 37 + npm install highlight.js # Code syntax highlighting 38 + 39 + # Comic book archives — additional optional backends: 40 + npm install node-unrar-js # CBR (.cbr, RAR-compressed comics) 41 + npm install 7z-wasm # CB7 (.cb7, 7-Zip-compressed comics) 42 + 43 + # Comic book archives — optional exotic image format decoders: 44 + npm install @jsquash/jxl # JPEG XL images inside archives 45 + npm install utif # TIFF images inside archives 35 46 ``` 36 47 37 48 You only need to install peer dependencies for the formats you plan to render. Unused formats won't add to your bundle. ··· 388 399 | XML/HTML | `highlight.js` | `.xml`, `.html`, `.svg` | 389 400 | Pages | _(none)_ | N/A (explicit `type: 'pages'`) | 390 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` | 391 407 392 408 ## Browser Support 393 409 ··· 418 434 │ │ ├── ods.ts # ODS (xlsx) 419 435 │ │ ├── csv.ts # CSV/TSV (papaparse) 420 436 │ │ ├── code.ts # Code (highlight.js) 421 - │ │ └── text.ts # Plain text 437 + │ │ ├── text.ts # Plain text 438 + │ │ └── comic.ts # Comic book archives (jszip / node-unrar-js / 7z-wasm) 422 439 │ └── package.json 423 440 └── react/ @polyrender/react — React wrapper 424 441 ├── src/
+2
examples/basic/README.md
··· 28 28 - Auto-detecting the document format from the filename 29 29 - Rendering with the built-in dark-themed toolbar 30 30 - Configuring the `pdfjs-dist` worker URL via Vite's `?url` import 31 + - Comic book archives (`.cbz`, `.cbr`, `.cb7`, `.cbt`) with JPEG XL and TIFF support enabled via `@jsquash/jxl` and `utif` 32 + - Word wrap / fit-to-width toolbar toggle (active for code, text, and comic files)
+1 -1
examples/basic/index.html
··· 78 78 <input 79 79 type="file" 80 80 id="file-input" 81 - accept=".pdf,.epub,.docx,.odt,.ods,.csv,.tsv,.txt,.md,.json,.js,.ts,.py,.rs,.go,.java,.c,.cpp,.html,.xml,.svg" 81 + accept=".pdf,.epub,.docx,.odt,.ods,.csv,.tsv,.txt,.md,.json,.js,.ts,.py,.rs,.go,.java,.c,.cpp,.html,.xml,.svg,.cbz,.cbr,.cb7,.cbt" 82 82 /> 83 83 </label> 84 84 </div>
+5 -1
examples/basic/package.json
··· 16 16 "papaparse": ">=5.0.0", 17 17 "highlight.js": ">=11.0.0", 18 18 "jszip": ">=3.0.0", 19 - "xlsx": ">=0.18.0" 19 + "xlsx": ">=0.18.0", 20 + "node-unrar-js": ">=2.0.0", 21 + "7z-wasm": ">=1.0.0", 22 + "@jsquash/jxl": ">=1.0.0", 23 + "utif": ">=3.0.0" 20 24 }, 21 25 "devDependencies": { 22 26 "typescript": "^5.5.0",
+5
examples/basic/src/main.ts
··· 29 29 pdf: { 30 30 workerSrc: pdfjsWorker, 31 31 }, 32 + // Comic book archives (.cbz, .cbr, .cb7, .cbt) 33 + comic: { 34 + jxlFallback: true, // JPEG XL decoding via @jsquash/jxl 35 + tiffSupport: true, // TIFF decoding via utif 36 + }, 32 37 onReady: (info) => { 33 38 console.log(`Loaded "${file.name}" — ${info.pageCount} page(s), format: ${info.format}`) 34 39 },
+19
examples/basic/vite.config.ts
··· 17 17 'highlight.js', 18 18 'jszip', 19 19 'xlsx', 20 + 'node-unrar-js', 21 + '@jsquash/jxl', 22 + 'utif', 20 23 ] 21 24 22 25 // Build a code snippet that maps module names → static imports. ··· 25 28 .map((d) => ` case '${d}': return import('${d}').then(m => m.default || m);`) 26 29 .join('\n') 27 30 31 + // 7z-wasm's default ESM build imports Node's 'module' built-in, so we 32 + // redirect to its UMD build which is browser-safe. 33 + const sevenZipCase = ` case '7z-wasm': return import('7z-wasm/7zz.umd.js').then(m => m.default || m);` 34 + 28 35 const replacement = [ 29 36 '(async (name) => { switch(name) {', 30 37 cases, 38 + sevenZipCase, 31 39 ' default: throw new Error(`Unknown peer dep: ${name}`);', 32 40 ' }})(moduleName)', 33 41 ].join('\n') ··· 53 61 54 62 export default defineConfig({ 55 63 plugins: [resolvePeerDeps()], 64 + // @jsquash/jxl ships a Web Worker whose internal format is "iife". 65 + // Forcing workers to ES module format prevents the Rollup error: 66 + // "Invalid value 'iife' for option 'worker.format' — UMD and IIFE output 67 + // formats are not supported for code-splitting builds." 68 + worker: { 69 + format: 'es', 70 + }, 56 71 optimizeDeps: { 57 72 include: [ 58 73 'pdfjs-dist', ··· 62 77 'highlight.js', 63 78 'jszip', 64 79 'xlsx', 80 + 'node-unrar-js', 81 + '7z-wasm/7zz.umd.js', 82 + '@jsquash/jxl', 83 + 'utif', 65 84 ], 66 85 }, 67 86 })
+8
examples/vanilla/README.md
··· 20 20 ```bash 21 21 pnpm dev 22 22 ``` 23 + 24 + ## What it shows 25 + 26 + - Opening a local file via a file input (PDF, EPUB, DOCX, CSV, code, comic archives, and more) 27 + - Auto-detecting the document format from the filename 28 + - Rendering with the built-in dark-themed toolbar 29 + - Comic book archives (`.cbz`, `.cbr`, `.cb7`, `.cbt`) with JPEG XL and TIFF support enabled via `@jsquash/jxl` and `utif` 30 + - Word wrap / fit-to-width toolbar toggle (active for code, text, and comic files)
+31 -1
examples/vanilla/build.js
··· 16 16 const __dirname = dirname(fileURLToPath(import.meta.url)); 17 17 const isWatch = process.argv.includes("--watch"); 18 18 19 + /** 20 + * Plugin that provides empty shims for Node.js built-in modules when bundling 21 + * for the browser. Some WASM-based packages (e.g. 7z-wasm) include conditional 22 + * Node code paths that are never reached in a browser, but esbuild still tries 23 + * to resolve the `require("fs")` / `require("crypto")` calls at bundle time. 24 + */ 25 + const shimNodeBuiltins = { 26 + name: "shim-node-builtins", 27 + setup(build) { 28 + const builtins = 29 + /^(fs|crypto|path|os|module|stream|util|events|buffer|assert|http|https|net|tls|url|zlib|readline|child_process|worker_threads|perf_hooks)$/; 30 + build.onResolve({ filter: builtins }, (args) => ({ 31 + path: args.path, 32 + namespace: "node-builtin-shim", 33 + })); 34 + build.onLoad({ filter: /.*/, namespace: "node-builtin-shim" }, () => ({ 35 + contents: "module.exports = {}", 36 + loader: "js", 37 + })); 38 + }, 39 + }; 40 + 19 41 /** Plugin to resolve @polyrender/core's dynamic peer-dep imports. */ 20 42 const resolvePeerDeps = { 21 43 name: "resolve-peer-deps", ··· 37 59 "highlight.js", 38 60 "jszip", 39 61 "xlsx", 62 + "node-unrar-js", 63 + "@jsquash/jxl", 64 + "utif", 40 65 ]; 66 + // 7z-wasm's default ESM build imports Node's 'module' built-in, 67 + // so we redirect to its UMD build which is browser-safe. 68 + const sevenZipCase = ` case '7z-wasm': return import('7z-wasm/7zz.umd.js').then(m => m.default || m);`; 41 69 const cases = peerDeps 42 70 .map( 43 71 (d) => ··· 48 76 const replacement = [ 49 77 "(async (name) => { switch(name) {", 50 78 cases, 79 + sevenZipCase, 51 80 " default: throw new Error(`Unknown peer dep: ${name}`);", 52 81 " }})(moduleName)", 53 82 ].join("\n"); ··· 100 129 entryPoints: [resolve(__dirname, "src/main.ts")], 101 130 bundle: true, 102 131 format: "esm", 132 + platform: "browser", 103 133 outdir: distDir, 104 134 sourcemap: true, 105 135 target: "es2022", 106 - plugins: [resolvePeerDeps], 136 + plugins: [shimNodeBuiltins, resolvePeerDeps], 107 137 logLevel: "info", 108 138 }; 109 139
+1 -1
examples/vanilla/index.html
··· 79 79 <input 80 80 type="file" 81 81 id="file-input" 82 - accept=".pdf,.epub,.docx,.odt,.ods,.csv,.tsv,.txt,.md,.json,.js,.ts,.py,.rs,.go,.java,.c,.cpp,.html,.xml,.svg" 82 + accept=".pdf,.epub,.docx,.odt,.ods,.csv,.tsv,.txt,.md,.json,.js,.ts,.py,.rs,.go,.java,.c,.cpp,.html,.xml,.svg,.cbz,.cbr,.cb7,.cbt" 83 83 /> 84 84 </label> 85 85 </div>
+5 -1
examples/vanilla/package.json
··· 16 16 "papaparse": ">=5.0.0", 17 17 "highlight.js": ">=11.0.0", 18 18 "jszip": ">=3.0.0", 19 - "xlsx": ">=0.18.0" 19 + "xlsx": ">=0.18.0", 20 + "node-unrar-js": ">=2.0.0", 21 + "7z-wasm": ">=1.0.0", 22 + "@jsquash/jxl": ">=1.0.0", 23 + "utif": ">=3.0.0" 20 24 }, 21 25 "devDependencies": { 22 26 "typescript": "^5.5.0",
+5
examples/vanilla/src/main.ts
··· 28 28 // In the bundled output, the worker is a sibling file in dist/ 29 29 workerSrc: './pdf.worker.min.mjs', 30 30 }, 31 + // Comic book archives (.cbz, .cbr, .cb7, .cbt) 32 + comic: { 33 + jxlFallback: true, // JPEG XL decoding via @jsquash/jxl 34 + tiffSupport: true, // TIFF decoding via utif 35 + }, 31 36 onReady: (info) => { 32 37 console.log(`Loaded "${file.name}" — ${info.pageCount} page(s), format: ${info.format}`) 33 38 },
+45 -2
packages/core/README.md
··· 1 1 # @polyrender/core 2 2 3 - Framework-agnostic TypeScript library for rendering documents in the browser. Supports PDF, EPUB, DOCX, ODT, ODS, CSV/TSV, source code, and plain text with a unified API. 3 + Framework-agnostic TypeScript library for rendering documents in the browser. Supports PDF, EPUB, DOCX, ODT, ODS, CSV/TSV, source code, plain text, and comic book archives with a unified API. 4 4 5 5 For React support, see [`@polyrender/react`](https://www.npmjs.com/package/@polyrender/react). 6 6 ··· 16 16 npm install pdfjs-dist # PDF 17 17 npm install epubjs # EPUB 18 18 npm install docx-preview # DOCX 19 - npm install jszip # ODT 19 + npm install jszip # ODT, CBZ comic archives 20 20 npm install xlsx # ODS 21 21 npm install papaparse # CSV/TSV 22 22 npm install highlight.js # Code, Markdown, JSON, XML/HTML 23 + 24 + # Comic book archives — additional optional backends: 25 + npm install node-unrar-js # CBR (.cbr, RAR-compressed comics) 26 + npm install 7z-wasm # CB7 (.cb7, 7-Zip-compressed comics) 27 + 28 + # Comic book archives — optional exotic image format decoders: 29 + npm install @jsquash/jxl # JPEG XL images inside archives 30 + npm install utif # TIFF images inside archives 23 31 ``` 24 32 25 33 ## Usage ··· 141 149 initialPage?: number, // Starting page (default: 1) 142 150 zoom?: number | 'fit-width' | 'fit-page' | 'auto', 143 151 toolbar?: boolean | ToolbarConfig, 152 + // ToolbarConfig fields: 153 + // navigation?: boolean Show page nav controls (default true) 154 + // zoom?: boolean Show zoom controls (default true) 155 + // wrapToggle?: boolean Show word-wrap/fit toggle (auto for code, text, comic) 156 + // fullscreen?: boolean Show fullscreen button (default true) 157 + // info?: boolean Show filename label (default true) 158 + // download?: boolean Show download button (default false) 159 + // position?: 'top'|'bottom' 144 160 145 161 // Callbacks 146 162 onReady?: (info: DocumentInfo) => void, ··· 156 172 csv?: CsvOptions, 157 173 odt?: OdtOptions, 158 174 ods?: OdsOptions, 175 + comic?: ComicOptions, 159 176 }) 160 177 ``` 161 178 ··· 217 234 } 218 235 ``` 219 236 237 + **Comic book archives** 238 + ```typescript 239 + comic: { 240 + // Image formats to extract from the archive. 241 + // Defaults to all natively supported browser formats. 242 + // Add 'jxl' + jxlFallback: true to enable JPEG XL decoding. 243 + // Add 'tiff' + tiffSupport: true to enable TIFF decoding. 244 + imageFormats?: Array<'png' | 'jpg' | 'gif' | 'bmp' | 'webp' | 'avif' | 'tiff' | 'jxl'>, 245 + 246 + // Enable JPEG XL fallback decoding via @jsquash/jxl. 247 + // Requires: npm install @jsquash/jxl 248 + jxlFallback?: boolean, 249 + 250 + // Enable TIFF image decoding via utif. 251 + // Requires: npm install utif 252 + tiffSupport?: boolean, 253 + } 254 + ``` 255 + 220 256 ## Events 221 257 222 258 Subscribe to events using `.on()` (returns an unsubscribe function): ··· 286 322 | XML/HTML | `highlight.js` | `.xml`, `.html`, `.svg` | 287 323 | Pages | _(none)_ | N/A (explicit `type: 'pages'`) | 288 324 | Chunked PDF | `pdfjs-dist` | N/A (explicit `type: 'chunked'`) | 325 + | Comic — CBZ | `jszip` | `.cbz` | 326 + | Comic — CBR | `node-unrar-js` _(optional)_ | `.cbr` | 327 + | Comic — CB7 | `7z-wasm` _(optional)_ | `.cb7` | 328 + | Comic — CBT | _(none, built-in TAR reader)_ | `.cbt` | 329 + | Comic — CBA | ❌ not supported | `.cba` | 330 + 331 + Comic archives support images in PNG, JPEG, GIF, BMP, WebP, and AVIF natively. TIFF and JPEG XL require additional opt-in peer dependencies (see `ComicOptions` above). 289 332 290 333 ## Live Demo 291 334
+19 -3
packages/core/package.json
··· 21 21 "types": "./dist/index.d.ts", 22 22 "exports": { 23 23 ".": { 24 + "types": "./dist/index.d.ts", 24 25 "import": "./dist/index.js", 25 - "require": "./dist/index.cjs", 26 - "types": "./dist/index.d.ts" 26 + "require": "./dist/index.cjs" 27 27 }, 28 28 "./styles.css": "./dist/styles.css" 29 29 }, ··· 52 52 "papaparse": ">=5.0.0", 53 53 "highlight.js": ">=11.0.0", 54 54 "jszip": ">=3.0.0", 55 - "xlsx": ">=0.18.0" 55 + "xlsx": ">=0.18.0", 56 + "node-unrar-js": ">=2.0.0", 57 + "7z-wasm": ">=1.0.0", 58 + "utif": ">=3.0.0", 59 + "@jsquash/jxl": ">=1.0.0" 56 60 }, 57 61 "peerDependenciesMeta": { 58 62 "pdfjs-dist": { ··· 74 78 "optional": true 75 79 }, 76 80 "xlsx": { 81 + "optional": true 82 + }, 83 + "node-unrar-js": { 84 + "optional": true 85 + }, 86 + "7z-wasm": { 87 + "optional": true 88 + }, 89 + "utif": { 90 + "optional": true 91 + }, 92 + "@jsquash/jxl": { 77 93 "optional": true 78 94 } 79 95 },
+2
packages/core/src/index.ts
··· 29 29 EpubOptions, 30 30 OdtOptions, 31 31 OdsOptions, 32 + ComicOptions, 32 33 ToolbarConfig, 33 34 34 35 // State & Info ··· 68 69 CsvRenderer, 69 70 CodeRenderer, 70 71 TextRenderer, 72 + ComicRenderer, 71 73 } from './renderers/index.js' 72 74 73 75 // Utilities (for custom renderer authors)
+21
packages/core/src/polyrender.ts
··· 55 55 private root: HTMLElement 56 56 private listeners = new Map<string, Set<(data: unknown) => void>>() 57 57 private destroyed = false 58 + private wrapActive = false 58 59 59 60 constructor(container: HTMLElement, options: PolyRenderOptions) { 60 61 ensureRegistered() ··· 243 244 this.emit('error', err) 244 245 } 245 246 247 + // Formats whose renderers support the wrap/fit toggle 248 + const supportsWrap = 249 + rendererFormat === 'code' || 250 + rendererFormat === 'text' || 251 + rendererFormat === 'comic' 252 + 253 + // Text renderer starts with wrap on (pre-wrap by default in CSS) 254 + this.wrapActive = rendererFormat === 'text' 255 + 246 256 // Create toolbar (before renderer mount, so it appears above the viewport) 247 257 const toolbarOpt = this.options.toolbar 248 258 if (toolbarOpt !== false) { ··· 258 268 onZoomOut: () => this.setZoom(this.getZoom() / 1.2), 259 269 onFitWidth: () => this.setZoom('fit-width'), 260 270 onFullscreen: () => this.toggleFullscreen(), 271 + onWrapToggle: supportsWrap ? () => this.doWrapToggle() : undefined, 261 272 }, this.getState()) 262 273 274 + if (supportsWrap) { 275 + this.toolbar.setWrapActive(this.wrapActive) 276 + } 277 + 263 278 if (config.position === 'bottom') { 264 279 this.root.appendChild(this.toolbar.element) 265 280 } else { ··· 287 302 288 303 private updateToolbar(): void { 289 304 this.toolbar?.updateState(this.getState()) 305 + } 306 + 307 + private doWrapToggle(): void { 308 + if (!this.renderer?.toggleWrap) return 309 + this.wrapActive = this.renderer.toggleWrap() 310 + this.toolbar?.setWrapActive(this.wrapActive) 290 311 } 291 312 292 313 private toggleFullscreen(): void {
+10
packages/core/src/renderers/code.ts
··· 18 18 readonly format: DocumentFormat = 'code' 19 19 20 20 private codeContainer!: HTMLElement 21 + private codeBody!: HTMLElement 21 22 private hljs: HighlightJS | null = null 23 + private wordWrap = false 22 24 23 25 protected async onMount(viewport: HTMLElement, options: PolyRenderOptions): Promise<void> { 24 26 this.showLoading('Loading file…') ··· 72 74 } 73 75 74 76 // Code body 77 + this.wordWrap = wordWrap 75 78 const body = el('pre', `dv-code-body${wordWrap ? ' dv-word-wrap' : ''}`) 76 79 const codeEl = document.createElement('code') 77 80 if (language) codeEl.className = `language-${language}` ··· 81 84 } 82 85 body.appendChild(codeEl) 83 86 this.codeContainer.appendChild(body) 87 + this.codeBody = body 84 88 85 89 this.setReady({ 86 90 format: 'code', ··· 138 142 if ('filename' in source && source.filename) return source.filename 139 143 if (source.type === 'url') return source.url.split('/').pop()?.split('?')[0] 140 144 return undefined 145 + } 146 + 147 + toggleWrap(): boolean { 148 + this.wordWrap = !this.wordWrap 149 + this.codeBody.classList.toggle('dv-word-wrap', this.wordWrap) 150 + return this.wordWrap 141 151 } 142 152 143 153 protected onDestroy(): void {
+650
packages/core/src/renderers/comic.ts
··· 1 + import type { PolyRenderOptions, DocumentFormat } from '../types.js' 2 + import { PolyRenderError } from '../types.js' 3 + import { BaseRenderer } from '../renderer.js' 4 + import { el, clamp, debounce, requirePeerDep, toArrayBuffer, fetchAsBuffer, getExtension } from '../utils.js' 5 + 6 + // --------------------------------------------------------------------------- 7 + // Image format constants 8 + // --------------------------------------------------------------------------- 9 + 10 + const NATIVE_IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'avif']) 11 + 12 + const IMAGE_MIME: Record<string, string> = { 13 + png: 'image/png', 14 + jpg: 'image/jpeg', 15 + jpeg: 'image/jpeg', 16 + gif: 'image/gif', 17 + bmp: 'image/bmp', 18 + webp: 'image/webp', 19 + avif: 'image/avif', 20 + tif: 'image/tiff', 21 + tiff: 'image/tiff', 22 + jxl: 'image/jxl', 23 + } 24 + 25 + // --------------------------------------------------------------------------- 26 + // Extracted image type 27 + // --------------------------------------------------------------------------- 28 + 29 + interface ExtractedImage { 30 + name: string 31 + data: ArrayBuffer 32 + ext: string 33 + mimeType: string 34 + } 35 + 36 + // --------------------------------------------------------------------------- 37 + // Natural sort (handles numeric runs: "page10" > "page9") 38 + // --------------------------------------------------------------------------- 39 + 40 + function naturalCompare(a: string, b: string): number { 41 + const re = /(\d+)|(\D+)/g 42 + const chunksA = a.match(re) ?? [] 43 + const chunksB = b.match(re) ?? [] 44 + for (let i = 0; i < Math.max(chunksA.length, chunksB.length); i++) { 45 + if (i >= chunksA.length) return -1 46 + if (i >= chunksB.length) return 1 47 + const ca = chunksA[i], cb = chunksB[i] 48 + const na = parseInt(ca, 10), nb = parseInt(cb, 10) 49 + if (!isNaN(na) && !isNaN(nb)) { 50 + if (na !== nb) return na - nb 51 + } else { 52 + const lca = ca.toLowerCase(), lcb = cb.toLowerCase() 53 + if (lca < lcb) return -1 54 + if (lca > lcb) return 1 55 + } 56 + } 57 + return 0 58 + } 59 + 60 + function fileBasename(path: string): string { 61 + return path.replace(/\\/g, '/').split('/').pop() ?? path 62 + } 63 + 64 + function fileExt(name: string): string { 65 + return (name.split('.').pop() ?? '').toLowerCase() 66 + } 67 + 68 + function isImage(name: string, allowed?: Set<string>): boolean { 69 + const ext = fileExt(name) 70 + return (allowed ?? new Set([...NATIVE_IMAGE_EXTS, 'tif', 'tiff', 'jxl'])).has(ext) 71 + } 72 + 73 + // --------------------------------------------------------------------------- 74 + // Built-in TAR reader (CBT = uncompressed TAR — no peer dep needed) 75 + // --------------------------------------------------------------------------- 76 + 77 + function extractFromTar(data: ArrayBuffer, allowed?: Set<string>): ExtractedImage[] { 78 + const bytes = new Uint8Array(data) 79 + const files: ExtractedImage[] = [] 80 + const dec = new TextDecoder() 81 + 82 + const str = (start: number, len: number): string => { 83 + let end = start 84 + while (end < start + len && bytes[end] !== 0) end++ 85 + return dec.decode(bytes.slice(start, end)).trim() 86 + } 87 + 88 + let offset = 0 89 + while (offset + 512 <= bytes.length) { 90 + const name = str(offset, 100) 91 + if (!name) break // two zero blocks = EOF 92 + 93 + const prefix = str(offset + 345, 155) 94 + const fullName = prefix ? `${prefix}/${name}` : name 95 + const basename = fileBasename(fullName) 96 + 97 + const sizeStr = str(offset + 124, 12) 98 + const size = parseInt(sizeStr, 8) || 0 99 + const typeFlag = String.fromCharCode(bytes[offset + 156]) 100 + 101 + offset += 512 102 + 103 + if ((typeFlag === '0' || typeFlag === '\0' || typeFlag === '') && size > 0) { 104 + if (isImage(basename, allowed)) { 105 + const ext = fileExt(basename) 106 + files.push({ 107 + name: basename, 108 + data: data.slice(offset, offset + size), 109 + ext, 110 + mimeType: IMAGE_MIME[ext] ?? 'application/octet-stream', 111 + }) 112 + } 113 + } 114 + 115 + offset += Math.ceil(size / 512) * 512 116 + } 117 + 118 + return files.sort((a, b) => naturalCompare(a.name, b.name)) 119 + } 120 + 121 + // --------------------------------------------------------------------------- 122 + // Peer-dep type declarations 123 + // --------------------------------------------------------------------------- 124 + 125 + // jszip ^3.0.0 126 + interface JSZipEntry { dir: boolean; async(type: 'arraybuffer'): Promise<ArrayBuffer> } 127 + interface JSZipInstance { loadAsync(data: ArrayBuffer): Promise<{ files: Record<string, JSZipEntry> }> } 128 + type JSZipConstructor = new () => JSZipInstance 129 + // requirePeerDep may return the constructor directly (build plugins unwrap .default) 130 + // or the ESM namespace object with a .default property (plain import) 131 + type JSZipModule = JSZipConstructor | { default: JSZipConstructor } 132 + 133 + // node-unrar-js ^2.0.0 https://github.com/YuJianrong/node-unrar-js 134 + interface UnrarFileHeader { 135 + name: string 136 + flags: { encrypted: boolean; solid: boolean; directory: boolean } 137 + } 138 + interface UnrarArcFile { fileHeader: UnrarFileHeader; extraction?: Uint8Array } 139 + interface UnrarExtractor { 140 + getFileList(): { arcHeader: unknown; fileHeaders: Generator<UnrarFileHeader> } 141 + extract(opts?: { 142 + files?: string[] | ((h: UnrarFileHeader) => boolean) 143 + password?: string 144 + }): { arcHeader: unknown; files: Generator<UnrarArcFile> } 145 + } 146 + interface UnrarModule { 147 + createExtractorFromData(opts: { data: ArrayBuffer; wasmBinary?: ArrayBuffer; password?: string }): Promise<UnrarExtractor> 148 + } 149 + 150 + // 7z-wasm ^1.2.0 https://github.com/nicktindall/7z-wasm (npm: 7z-wasm) 151 + // Uses an Emscripten virtual FS: write archive in, call main to extract, read files out. 152 + interface SevenZipNodeAttr { mode: number } 153 + interface SevenZipFS { 154 + writeFile(path: string, data: ArrayBufferView, opts?: { flags?: string }): void 155 + mkdir(path: string, mode?: number): unknown 156 + readdir(path: string): string[] 157 + readFile(path: string, opts?: { flags?: string }): Uint8Array 158 + stat(path: string, dontFollow?: boolean): SevenZipNodeAttr 159 + isFile(mode: number): boolean 160 + isDir(mode: number): boolean 161 + unlink(path: string): void 162 + rmdir(path: string): void 163 + } 164 + interface SevenZipModule { 165 + default: (opts?: { print?: (s: string) => void; printErr?: (s: string) => void }) => Promise<{ 166 + FS: SevenZipFS 167 + callMain(args: string[]): void 168 + }> 169 + } 170 + 171 + // utif ^3.1.0 https://github.com/photopea/UTIF.js 172 + interface UtifIfd { width: number; height: number; data: Uint8Array } 173 + interface UtifModule { 174 + default: { 175 + decode(buffer: ArrayBuffer): UtifIfd[] 176 + decodeImage(buffer: ArrayBuffer, ifd: UtifIfd): void 177 + } 178 + } 179 + 180 + // @jsquash/jxl ^1.3.0 https://github.com/jamsinclair/jSquash 181 + interface JxlModule { 182 + decode(data: ArrayBuffer | Uint8Array): Promise<ImageData> 183 + } 184 + 185 + // --------------------------------------------------------------------------- 186 + // ComicRenderer 187 + // --------------------------------------------------------------------------- 188 + 189 + export class ComicRenderer extends BaseRenderer { 190 + readonly format: DocumentFormat = 'comic' 191 + 192 + private pagesContainer!: HTMLElement 193 + private pageElements: HTMLElement[] = [] 194 + private pages: ExtractedImage[] = [] 195 + private pageDims = new Map<number, { w: number; h: number }>() // 1-indexed 196 + private blobUrls: string[] = [] 197 + private loadedPages = new Set<number>() 198 + private observer: IntersectionObserver | null = null 199 + private debouncedScroll: ReturnType<typeof debounce> | null = null 200 + 201 + private jxlDecoder: JxlModule | null = null 202 + private utifDecoder: UtifModule | null = null 203 + private fitMode = false 204 + 205 + protected async onMount(viewport: HTMLElement, options: PolyRenderOptions): Promise<void> { 206 + const loadingEl = this.showLoading('Loading archive…') 207 + const comic = options.comic ?? {} 208 + 209 + // -- Resolve source --------------------------------------------------------- 210 + let data: ArrayBuffer 211 + let filename: string | undefined 212 + 213 + if (options.source.type === 'file') { 214 + data = await toArrayBuffer(options.source.data) 215 + filename = options.source.filename 216 + } else if (options.source.type === 'url') { 217 + data = await fetchAsBuffer(options.source.url, options.source.fetchOptions) 218 + filename = options.source.filename ?? fileBasename(options.source.url) 219 + } else { 220 + throw new PolyRenderError('FORMAT_UNSUPPORTED', 'Comic renderer requires a file or URL source.') 221 + } 222 + 223 + const archiveExt = filename ? getExtension(filename) : '' 224 + 225 + // -- Build allowed image extension set from options ------------------------- 226 + let allowedExts: Set<string> | undefined 227 + if (comic.imageFormats) { 228 + allowedExts = new Set<string>() 229 + for (const fmt of comic.imageFormats) { 230 + if (fmt === 'jpg') { allowedExts.add('jpg'); allowedExts.add('jpeg') } 231 + else if (fmt === 'tiff') { allowedExts.add('tiff'); allowedExts.add('tif') } 232 + else allowedExts.add(fmt) 233 + } 234 + } 235 + 236 + // -- Optionally load special-format decoders -------------------------------- 237 + if (comic.jxlFallback) { 238 + try { 239 + this.jxlDecoder = await requirePeerDep<JxlModule>('@jsquash/jxl', 'JPEG XL images') 240 + } catch { 241 + // Silently continue without JXL support — JXL pages will be skipped 242 + } 243 + } 244 + if (comic.tiffSupport) { 245 + try { 246 + this.utifDecoder = await requirePeerDep<UtifModule>('utif', 'TIFF images') 247 + } catch { 248 + // Silently continue without TIFF support — TIFF pages will be skipped 249 + } 250 + } 251 + 252 + // -- Extract images from archive -------------------------------------------- 253 + const msgEl = loadingEl.querySelector('span') 254 + if (msgEl) msgEl.textContent = 'Extracting pages…' 255 + 256 + this.pages = await this.extract(data, archiveExt, allowedExts) 257 + 258 + if (this.pages.length === 0) { 259 + throw new PolyRenderError('RENDER_FAILED', 'No supported image files found in the archive.') 260 + } 261 + 262 + // -- Build placeholder page elements ---------------------------------------- 263 + this.pagesContainer = el('div', 'dv-pages dv-comic-pages') 264 + viewport.appendChild(this.pagesContainer) 265 + 266 + const defaultW = 800 * this.state.zoom 267 + const defaultH = 1200 * this.state.zoom 268 + 269 + for (let i = 0; i < this.pages.length; i++) { 270 + const pageEl = el('div', 'dv-page dv-browse-page dv-comic-page') 271 + pageEl.dataset.page = String(i + 1) 272 + pageEl.style.width = `${defaultW}px` 273 + pageEl.style.height = `${defaultH}px` 274 + 275 + const placeholder = el('div', 'dv-browse-page-placeholder') 276 + placeholder.style.width = `${defaultW}px` 277 + placeholder.style.height = `${defaultH}px` 278 + placeholder.textContent = String(i + 1) 279 + pageEl.appendChild(placeholder) 280 + 281 + this.pageElements.push(pageEl) 282 + this.pagesContainer.appendChild(pageEl) 283 + } 284 + 285 + loadingEl.remove() 286 + 287 + // -- Intersection observer for lazy image loading --------------------------- 288 + this.observer = new IntersectionObserver( 289 + (entries) => { 290 + for (const entry of entries) { 291 + if (entry.isIntersecting) { 292 + const p = parseInt((entry.target as HTMLElement).dataset.page!, 10) 293 + void this.loadPage(p) 294 + } 295 + } 296 + }, 297 + { root: viewport, rootMargin: '150% 0px' }, 298 + ) 299 + for (const pageEl of this.pageElements) this.observer.observe(pageEl) 300 + 301 + // -- Scroll-based current-page tracking ------------------------------------ 302 + this.debouncedScroll = debounce(() => this.updateCurrentPage(), 100) 303 + viewport.addEventListener('scroll', this.debouncedScroll) 304 + 305 + this.setReady({ format: 'comic', pageCount: this.pages.length, filename }) 306 + 307 + if (options.initialPage && options.initialPage > 1) { 308 + this.goToPage(options.initialPage) 309 + } 310 + } 311 + 312 + // --------------------------------------------------------------------------- 313 + // Archive extraction dispatcher 314 + // --------------------------------------------------------------------------- 315 + 316 + private async extract( 317 + data: ArrayBuffer, 318 + ext: string, 319 + allowed?: Set<string>, 320 + ): Promise<ExtractedImage[]> { 321 + switch (ext) { 322 + case 'cbz': return this.extractZip(data, allowed) 323 + case 'cbr': return this.extractRar(data, allowed) 324 + case 'cb7': return this.extract7z(data, allowed) 325 + case 'cbt': return extractFromTar(data, allowed) 326 + case 'cba': 327 + throw new PolyRenderError( 328 + 'FORMAT_UNSUPPORTED', 329 + 'CBА (ACE) archives are not supported. No browser-compatible ACE decoder is available.', 330 + ) 331 + default: 332 + // Unknown extension — try ZIP as a best-effort fallback (CBZ without correct ext) 333 + return this.extractZip(data, allowed) 334 + } 335 + } 336 + 337 + // -- CBZ via jszip ----------------------------------------------------------- 338 + 339 + private async extractZip(data: ArrayBuffer, allowed?: Set<string>): Promise<ExtractedImage[]> { 340 + const mod = await requirePeerDep<JSZipModule>('jszip', 'CBZ comic archives') 341 + const JSZip = typeof mod === 'function' ? mod : mod.default 342 + const zip = await new JSZip().loadAsync(data) 343 + const files: ExtractedImage[] = [] 344 + 345 + for (const [path, entry] of Object.entries(zip.files)) { 346 + if (entry.dir) continue 347 + const name = fileBasename(path) 348 + if (!isImage(name, allowed)) continue 349 + const ext = fileExt(name) 350 + const buf = await entry.async('arraybuffer') 351 + files.push({ name, data: buf, ext, mimeType: IMAGE_MIME[ext] ?? 'application/octet-stream' }) 352 + } 353 + 354 + return files.sort((a, b) => naturalCompare(a.name, b.name)) 355 + } 356 + 357 + // -- CBR via node-unrar-js --------------------------------------------------- 358 + // Peer dep: node-unrar-js ^2.0.0 (npm install node-unrar-js) 359 + 360 + private async extractRar(data: ArrayBuffer, allowed?: Set<string>): Promise<ExtractedImage[]> { 361 + const unrar = await requirePeerDep<UnrarModule>('node-unrar-js', 'CBR comic archives') 362 + const extractor = await unrar.createExtractorFromData({ data }) 363 + 364 + // Use a filter function so the generator only yields files we want, 365 + // avoiding re-iterating the fileHeaders generator. 366 + const { files: extracted } = extractor.extract({ 367 + files: (header) => 368 + !header.flags.encrypted && 369 + !header.flags.directory && 370 + isImage(fileBasename(header.name), allowed), 371 + }) 372 + 373 + const files: ExtractedImage[] = [] 374 + for (const f of extracted) { 375 + if (!f.extraction) continue 376 + const name = fileBasename(f.fileHeader.name) 377 + const ext = fileExt(name) 378 + const raw = f.extraction 379 + const buf = raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength) as ArrayBuffer 380 + files.push({ name, data: buf, ext, mimeType: IMAGE_MIME[ext] ?? 'application/octet-stream' }) 381 + } 382 + 383 + return files.sort((a, b) => naturalCompare(a.name, b.name)) 384 + } 385 + 386 + // -- CB7 via 7z-wasm --------------------------------------------------------- 387 + // Peer dep: 7z-wasm ^1.2.0 (npm install 7z-wasm) 388 + // Uses an Emscripten virtual FS: write archive in → call main → read files out. 389 + 390 + private async extract7z(data: ArrayBuffer, allowed?: Set<string>): Promise<ExtractedImage[]> { 391 + const sevenZipMod = await requirePeerDep<SevenZipModule>('7z-wasm', 'CB7 comic archives') 392 + // Silence 7-Zip stdout/stderr to keep the browser console clean 393 + const sz = await sevenZipMod.default({ print: () => {}, printErr: () => {} }) 394 + 395 + const archivePath = '/comic_input.7z' 396 + const outDir = '/cb7_output' 397 + 398 + sz.FS.writeFile(archivePath, new Uint8Array(data)) 399 + try { sz.FS.mkdir(outDir) } catch { /* already exists */ } 400 + sz.callMain(['x', archivePath, `-o${outDir}`, '-y']) 401 + 402 + const files: ExtractedImage[] = [] 403 + this.readDirRecursive(sz.FS, outDir, files, allowed) 404 + 405 + // Clean up virtual FS to free memory 406 + try { sz.FS.unlink(archivePath) } catch { /* ignore */ } 407 + 408 + return files.sort((a, b) => naturalCompare(a.name, b.name)) 409 + } 410 + 411 + private readDirRecursive( 412 + fs: SevenZipFS, 413 + dir: string, 414 + out: ExtractedImage[], 415 + allowed?: Set<string>, 416 + ): void { 417 + let entries: string[] 418 + try { entries = fs.readdir(dir) } catch { return } 419 + 420 + for (const entry of entries) { 421 + if (entry === '.' || entry === '..') continue 422 + const fullPath = `${dir}/${entry}` 423 + try { 424 + const stat = fs.stat(fullPath) 425 + if (fs.isDir(stat.mode)) { 426 + this.readDirRecursive(fs, fullPath, out, allowed) 427 + } else if (fs.isFile(stat.mode)) { 428 + const name = fileBasename(fullPath) 429 + if (!isImage(name, allowed)) continue 430 + const ext = fileExt(name) 431 + const raw = fs.readFile(fullPath) 432 + const buf = raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength) as ArrayBuffer 433 + out.push({ name, data: buf, ext, mimeType: IMAGE_MIME[ext] ?? 'application/octet-stream' }) 434 + } 435 + } catch { /* skip unreadable entries */ } 436 + } 437 + } 438 + 439 + // --------------------------------------------------------------------------- 440 + // Lazy page loading 441 + // --------------------------------------------------------------------------- 442 + 443 + private async loadPage(pageNum: number): Promise<void> { 444 + if (this.loadedPages.has(pageNum)) return 445 + this.loadedPages.add(pageNum) 446 + 447 + const pageEl = this.pageElements[pageNum - 1] 448 + const page = this.pages[pageNum - 1] 449 + if (!pageEl || !page) return 450 + 451 + try { 452 + const blobUrl = await this.getImageBlobUrl(page) 453 + this.blobUrls.push(blobUrl) 454 + 455 + const img = document.createElement('img') 456 + img.alt = `Page ${pageNum}` 457 + img.draggable = false 458 + img.decoding = 'async' 459 + 460 + img.onload = () => { 461 + const nw = img.naturalWidth 462 + const nh = img.naturalHeight 463 + this.pageDims.set(pageNum, { w: nw, h: nh }) 464 + if (!this.fitMode) { 465 + const w = nw * this.state.zoom 466 + const h = nh * this.state.zoom 467 + img.style.width = `${w}px` 468 + img.style.height = `${h}px` 469 + pageEl.style.width = `${w}px` 470 + pageEl.style.height = `${h}px` 471 + } 472 + } 473 + 474 + if (!this.fitMode) { 475 + img.style.width = pageEl.style.width 476 + img.style.height = pageEl.style.height 477 + } 478 + img.src = blobUrl 479 + 480 + pageEl.innerHTML = '' 481 + pageEl.appendChild(img) 482 + } catch { 483 + this.loadedPages.delete(pageNum) 484 + } 485 + } 486 + 487 + private async getImageBlobUrl(page: ExtractedImage): Promise<string> { 488 + // TIFF via utif 489 + if ((page.ext === 'tiff' || page.ext === 'tif') && this.utifDecoder) { 490 + return this.decodeTiff(page.data) 491 + } 492 + 493 + // JPEG XL via @jsquash/jxl 494 + if (page.ext === 'jxl' && this.jxlDecoder) { 495 + return this.decodeJxl(page.data) 496 + } 497 + 498 + // Native browser formats 499 + return URL.createObjectURL(new Blob([page.data], { type: page.mimeType })) 500 + } 501 + 502 + private async decodeTiff(data: ArrayBuffer): Promise<string> { 503 + const UTIF = this.utifDecoder!.default 504 + const ifds = UTIF.decode(data) 505 + if (!ifds.length) throw new Error('Empty TIFF file') 506 + UTIF.decodeImage(data, ifds[0]) 507 + const { width, height, data: rgba } = ifds[0] 508 + const canvas = document.createElement('canvas') 509 + canvas.width = width 510 + canvas.height = height 511 + const ctx = canvas.getContext('2d')! 512 + ctx.putImageData(new ImageData(new Uint8ClampedArray(rgba), width, height), 0, 0) 513 + return new Promise<string>((resolve, reject) => { 514 + canvas.toBlob((blob) => { 515 + if (!blob) { reject(new Error('Failed to encode TIFF as PNG')); return } 516 + resolve(URL.createObjectURL(blob)) 517 + }, 'image/png') 518 + }) 519 + } 520 + 521 + private async decodeJxl(data: ArrayBuffer): Promise<string> { 522 + const imageData = await this.jxlDecoder!.decode(data) 523 + const canvas = document.createElement('canvas') 524 + canvas.width = imageData.width 525 + canvas.height = imageData.height 526 + canvas.getContext('2d')!.putImageData(imageData, 0, 0) 527 + return new Promise<string>((resolve, reject) => { 528 + canvas.toBlob((blob) => { 529 + if (!blob) { reject(new Error('Failed to encode JXL as PNG')); return } 530 + resolve(URL.createObjectURL(blob)) 531 + }, 'image/png') 532 + }) 533 + } 534 + 535 + // --------------------------------------------------------------------------- 536 + // Page tracking 537 + // --------------------------------------------------------------------------- 538 + 539 + private updateCurrentPage(): void { 540 + const viewportRect = this.viewport.getBoundingClientRect() 541 + const mid = viewportRect.top + viewportRect.height / 2 542 + 543 + for (let i = 0; i < this.pageElements.length; i++) { 544 + const rect = this.pageElements[i].getBoundingClientRect() 545 + if (rect.top <= mid && rect.bottom >= mid) { 546 + const newPage = i + 1 547 + if (newPage !== this.state.currentPage) { 548 + this.state.currentPage = newPage 549 + this.emitPageChange() 550 + } 551 + return 552 + } 553 + } 554 + } 555 + 556 + protected onPageChange(page: number): void { 557 + this.pageElements[page - 1]?.scrollIntoView({ behavior: 'smooth', block: 'start' }) 558 + } 559 + 560 + // --------------------------------------------------------------------------- 561 + // Zoom 562 + // --------------------------------------------------------------------------- 563 + 564 + protected resolveZoomMode(mode: 'fit-width' | 'fit-page'): number { 565 + const vw = this.viewport.clientWidth 566 + if (!vw) return 1 567 + // Find the first page with known dimensions 568 + for (const [, dims] of this.pageDims) { 569 + if (dims.w > 0) { 570 + if (mode === 'fit-width') return (vw - 32) / dims.w // 16px padding each side 571 + const vh = this.viewport.clientHeight 572 + return Math.min((vw - 32) / dims.w, (vh - 32) / dims.h) 573 + } 574 + } 575 + return 1 576 + } 577 + 578 + protected onZoomChange(zoom: number): void { 579 + this.state.zoom = clamp(zoom, 0.1, 10) 580 + if (this.fitMode) return // CSS controls sizing in fit mode 581 + for (let i = 0; i < this.pageElements.length; i++) { 582 + const pageEl = this.pageElements[i] 583 + const dims = this.pageDims.get(i + 1) 584 + if (dims) { 585 + const w = dims.w * this.state.zoom 586 + const h = dims.h * this.state.zoom 587 + pageEl.style.width = `${w}px` 588 + pageEl.style.height = `${h}px` 589 + const img = pageEl.querySelector('img') 590 + if (img) { img.style.width = `${w}px`; img.style.height = `${h}px` } 591 + } 592 + } 593 + } 594 + 595 + // --------------------------------------------------------------------------- 596 + // Fit-mode toggle (wrap button) 597 + // --------------------------------------------------------------------------- 598 + 599 + toggleWrap(): boolean { 600 + this.fitMode = !this.fitMode 601 + 602 + if (this.fitMode) { 603 + this.pagesContainer.classList.add('dv-comic-fit-mode') 604 + // Clear inline sizes so CSS controls layout 605 + for (const pageEl of this.pageElements) { 606 + pageEl.style.width = '' 607 + pageEl.style.height = '' 608 + const img = pageEl.querySelector('img') as HTMLImageElement | null 609 + if (img) { img.style.width = ''; img.style.height = '' } 610 + const placeholder = pageEl.querySelector('.dv-browse-page-placeholder') as HTMLElement | null 611 + if (placeholder) { placeholder.style.width = ''; placeholder.style.height = '' } 612 + } 613 + } else { 614 + this.pagesContainer.classList.remove('dv-comic-fit-mode') 615 + // Re-apply zoom-based sizing 616 + for (let i = 0; i < this.pageElements.length; i++) { 617 + const pageEl = this.pageElements[i] 618 + const dims = this.pageDims.get(i + 1) 619 + if (dims) { 620 + const w = dims.w * this.state.zoom 621 + const h = dims.h * this.state.zoom 622 + pageEl.style.width = `${w}px` 623 + pageEl.style.height = `${h}px` 624 + const img = pageEl.querySelector('img') as HTMLImageElement | null 625 + if (img) { img.style.width = `${w}px`; img.style.height = `${h}px` } 626 + } else { 627 + pageEl.style.width = `${800 * this.state.zoom}px` 628 + pageEl.style.height = `${1200 * this.state.zoom}px` 629 + } 630 + } 631 + } 632 + 633 + return this.fitMode 634 + } 635 + 636 + // --------------------------------------------------------------------------- 637 + // Cleanup 638 + // --------------------------------------------------------------------------- 639 + 640 + protected onDestroy(): void { 641 + this.observer?.disconnect() 642 + this.debouncedScroll?.cancel() 643 + for (const url of this.blobUrls) URL.revokeObjectURL(url) 644 + this.pageElements = [] 645 + this.pages = [] 646 + this.blobUrls = [] 647 + this.pageDims.clear() 648 + this.loadedPages.clear() 649 + } 650 + }
+3
packages/core/src/renderers/index.ts
··· 8 8 export { CsvRenderer } from './csv.js' 9 9 export { CodeRenderer } from './code.js' 10 10 export { TextRenderer } from './text.js' 11 + export { ComicRenderer } from './comic.js' 11 12 12 13 import { registry } from '../registry.js' 13 14 import { PdfRenderer } from './pdf.js' ··· 20 21 import { CsvRenderer } from './csv.js' 21 22 import { CodeRenderer } from './code.js' 22 23 import { TextRenderer } from './text.js' 24 + import { ComicRenderer } from './comic.js' 23 25 24 26 /** 25 27 * Register all built-in renderers with the format registry. ··· 36 38 registry.register('csv', () => new CsvRenderer()) 37 39 registry.register('code', () => new CodeRenderer()) 38 40 registry.register('text', () => new TextRenderer()) 41 + registry.register('comic', () => new ComicRenderer()) 39 42 }
+10
packages/core/src/renderers/text.ts
··· 10 10 readonly format: DocumentFormat = 'text' 11 11 12 12 private textContainer!: HTMLElement 13 + // Text starts wrapped (pre-wrap in CSS). toggled = wrap is OFF. 14 + private wrapDisabled = false 13 15 14 16 protected async onMount(viewport: HTMLElement, options: PolyRenderOptions): Promise<void> { 15 17 this.showLoading('Loading text…') ··· 61 63 return source.url.split('/').pop()?.split('?')[0] 62 64 } 63 65 return undefined 66 + } 67 + 68 + // Returns true when wrap is active (the "on" state), false when off. 69 + // Text starts wrapped, so the initial active state is true. 70 + toggleWrap(): boolean { 71 + this.wrapDisabled = !this.wrapDisabled 72 + this.textContainer.classList.toggle('dv-no-wrap', this.wrapDisabled) 73 + return !this.wrapDisabled 64 74 } 65 75 66 76 protected onDestroy(): void {
+52
packages/core/src/styles.css
··· 200 200 pointer-events: none; 201 201 } 202 202 203 + .dv-toolbar-btn--active { 204 + background: var(--dv-accent-subtle); 205 + color: var(--dv-accent); 206 + } 207 + 208 + .dv-toolbar-btn--active:hover { 209 + background: var(--dv-accent-subtle); 210 + color: var(--dv-accent-hover); 211 + } 212 + 203 213 .dv-toolbar-btn svg { 204 214 width: 16px; 205 215 height: 16px; ··· 726 736 max-width: none; 727 737 } 728 738 739 + .dv-text-container.dv-no-wrap { 740 + white-space: pre; 741 + word-wrap: normal; 742 + overflow-x: auto; 743 + } 744 + 729 745 /* ========================================================================== 730 746 EPUB Renderer 731 747 ========================================================================== */ ··· 795 811 color: var(--dv-text-muted); 796 812 font-size: var(--dv-font-size-sm); 797 813 } 814 + 815 + /* ========================================================================== 816 + Comic Book Archive Renderer 817 + ========================================================================== */ 818 + 819 + .dv-comic-pages { 820 + background: #111; 821 + } 822 + 823 + .dv-comic-page { 824 + /* Comic images are often full-bleed; use a dark background to match 825 + page edges instead of the white document background */ 826 + background: #000; 827 + } 828 + 829 + .dv-comic-page img { 830 + display: block; 831 + /* Images fill the page element exactly; dimensions are set in JS */ 832 + width: 100%; 833 + height: 100%; 834 + object-fit: contain; 835 + image-rendering: auto; 836 + } 837 + 838 + /* Fit mode: each page scales independently to fill the container width. 839 + Inline px sizes are cleared in JS when this class is applied. */ 840 + .dv-comic-fit-mode .dv-comic-page { 841 + width: 100% !important; 842 + height: auto !important; 843 + min-height: 40px; /* keep placeholder visible while image loads */ 844 + } 845 + 846 + .dv-comic-fit-mode .dv-comic-page img { 847 + height: auto !important; 848 + object-fit: unset; 849 + }
+21
packages/core/src/toolbar.ts
··· 10 10 onFitWidth(): void 11 11 onFullscreen(): void 12 12 onDownload?(): void 13 + onWrapToggle?(): void 13 14 } 14 15 15 16 export interface ToolbarHandle { ··· 17 18 element: HTMLElement 18 19 /** Update displayed state (page, total, zoom). */ 19 20 updateState(state: PolyRenderState): void 21 + /** Set the active (pressed) state of the wrap toggle button. */ 22 + setWrapActive(active: boolean): void 20 23 /** Destroy and clean up listeners. */ 21 24 destroy(): void 22 25 } ··· 40 43 let zoomLabel: HTMLSpanElement | null = null 41 44 let prevBtn: HTMLButtonElement | null = null 42 45 let nextBtn: HTMLButtonElement | null = null 46 + let wrapBtn: HTMLButtonElement | null = null 43 47 44 48 // --- Navigation group --- 45 49 if (config.navigation !== false) { ··· 112 116 fitWidthBtn.addEventListener('click', actions.onFitWidth) 113 117 114 118 zoomGroup.append(zoomOutBtn, zoomLabel, zoomInBtn, fitWidthBtn) 119 + 120 + if (actions.onWrapToggle && config.wrapToggle !== false) { 121 + wrapBtn = el('button', 'dv-toolbar-btn') 122 + wrapBtn.title = 'Toggle word wrap / fit pages' 123 + wrapBtn.appendChild(svgIcon(icons.wrapToggle)) 124 + wrapBtn.addEventListener('click', actions.onWrapToggle) 125 + zoomGroup.appendChild(wrapBtn) 126 + } 127 + 115 128 toolbar.appendChild(zoomGroup) 116 129 } 117 130 ··· 142 155 if (zoomLabel) zoomLabel.textContent = `${Math.round(state.zoom * 100)}%` 143 156 if (prevBtn) prevBtn.disabled = state.currentPage <= 1 144 157 if (nextBtn) nextBtn.disabled = state.currentPage >= state.totalPages 158 + }, 159 + setWrapActive(active: boolean) { 160 + if (wrapBtn) { 161 + wrapBtn.classList.toggle('dv-toolbar-btn--active', active) 162 + wrapBtn.title = active 163 + ? 'Disable word wrap / fit pages' 164 + : 'Toggle word wrap / fit pages' 165 + } 145 166 }, 146 167 destroy() { 147 168 toolbar.remove()
+34
packages/core/src/types.ts
··· 194 194 epub?: EpubOptions 195 195 odt?: OdtOptions 196 196 ods?: OdsOptions 197 + comic?: ComicOptions 197 198 } 198 199 199 200 export interface PdfOptions { ··· 254 255 header?: boolean 255 256 } 256 257 258 + export interface ComicOptions { 259 + /** 260 + * Image formats to include from the archive. Defaults to all natively 261 + * supported browser formats (png, jpg, gif, bmp, webp, avif). 262 + * Add 'tiff' together with `tiffSupport: true` to enable TIFF decoding. 263 + * Add 'jxl' together with `jxlFallback: true` to enable JPEG XL decoding. 264 + */ 265 + imageFormats?: Array<'png' | 'jpg' | 'gif' | 'bmp' | 'webp' | 'avif' | 'tiff' | 'jxl'> 266 + /** 267 + * Enable JPEG XL fallback decoding via the `@jsquash/jxl` peer dependency. 268 + * Install it with: npm install @jsquash/jxl 269 + * Default: false. 270 + */ 271 + jxlFallback?: boolean 272 + /** 273 + * Enable TIFF image decoding via the `utif` peer dependency. 274 + * Install it with: npm install utif 275 + * Default: false. 276 + */ 277 + tiffSupport?: boolean 278 + } 279 + 257 280 export interface ToolbarConfig { 258 281 /** Show page navigation controls. Default true. */ 259 282 navigation?: boolean ··· 267 290 fullscreen?: boolean 268 291 /** Toolbar position. Default 'top'. */ 269 292 position?: 'top' | 'bottom' 293 + /** Show word-wrap / fit-to-width toggle button. Shown automatically for 294 + * code, text, and comic formats; set to `false` to hide it explicitly. */ 295 + wrapToggle?: boolean 270 296 } 271 297 272 298 ··· 352 378 | 'html' 353 379 | 'json' 354 380 | 'xml' 381 + | 'comic' // comic book archive (.cbz, .cbr, .cb7, .cbt) 355 382 | 'pages' // pre-rendered page images (no original document) 356 383 | 'chunked-pdf' // chunked PDF streaming 357 384 | (string & {}) // open union — consumers can register custom formats ··· 398 425 399 426 /** Get current zoom as a numeric scale factor. */ 400 427 getZoom(): number 428 + 429 + /** 430 + * Toggle word wrap (text/code formats) or fit-to-width mode (comic format). 431 + * Called by the toolbar wrap-toggle button. 432 + * Returns `true` when the feature is now active, `false` when inactive. 433 + */ 434 + toggleWrap?(): boolean 401 435 402 436 /** Perform a text search within the document. Returns match count. */ 403 437 search?(query: string): Promise<number>
+14
packages/core/src/utils.ts
··· 45 45 fitWidth: 'M1 4h14M1 12h14M4 1v3M4 12v3M12 1v3M12 12v3', 46 46 fullscreen: 'M2 5V2h3M11 2h3v3M14 11v3h-3M5 14H2v-3', 47 47 download: 'M8 2v8M4 7l4 4 4-4M3 13h10', 48 + wrapToggle: 'M2 4h12M2 9h6M13 7v2a2 2 0 0 1-2 2H5m2-2L5 11l2 2', 48 49 } as const 49 50 50 51 /** Remove all child nodes from an element. */ ··· 58 59 // --------------------------------------------------------------------------- 59 60 60 61 const EXTENSION_MAP: Record<string, DocumentFormat> = { 62 + // Comic book archives 63 + cbz: 'comic', 64 + cbr: 'comic', 65 + cb7: 'comic', 66 + cbt: 'comic', 67 + cba: 'comic', 61 68 pdf: 'pdf', 62 69 epub: 'epub', 63 70 docx: 'docx', ··· 97 104 } 98 105 99 106 const MIME_MAP: Record<string, DocumentFormat> = { 107 + // Comic book archives 108 + 'application/vnd.comicbook+zip': 'comic', 109 + 'application/vnd.comicbook-rar': 'comic', 110 + 'application/x-cbr': 'comic', 111 + 'application/x-cbz': 'comic', 112 + 'application/x-cb7': 'comic', 113 + 'application/x-cbt': 'comic', 100 114 'application/pdf': 'pdf', 101 115 'application/epub+zip': 'epub', 102 116 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
+12 -3
packages/react/README.md
··· 2 2 3 3 React component and hook for rendering documents in the browser. A thin wrapper around [`@polyrender/core`](https://www.npmjs.com/package/@polyrender/core) that handles React lifecycle, cleanup, and ref-based imperative control. 4 4 5 - Supports PDF, EPUB, DOCX, ODT, ODS, CSV/TSV, source code, and plain text. 5 + Supports PDF, EPUB, DOCX, ODT, ODS, CSV/TSV, source code, plain text, and comic book archives (.cbz, .cbr, .cb7, .cbt). 6 6 7 7 ## Installation 8 8 ··· 16 16 npm install pdfjs-dist # PDF 17 17 npm install epubjs # EPUB 18 18 npm install docx-preview # DOCX 19 - npm install jszip # ODT 19 + npm install jszip # ODT, CBZ comic archives 20 20 npm install xlsx # ODS 21 21 npm install papaparse # CSV/TSV 22 22 npm install highlight.js # Code, Markdown, JSON, XML/HTML 23 + 24 + # Comic book archives — additional optional backends: 25 + npm install node-unrar-js # CBR (.cbr, RAR-compressed comics) 26 + npm install 7z-wasm # CB7 (.cb7, 7-Zip-compressed comics) 27 + 28 + # Comic book archives — optional exotic image format decoders: 29 + npm install @jsquash/jxl # JPEG XL images inside archives 30 + npm install utif # TIFF images inside archives 23 31 ``` 24 32 25 33 ## Quick Start ··· 56 64 | `style` | `React.CSSProperties` | — | Styles for the wrapper div (set width/height here) | 57 65 | `initialPage` | `number` | `1` | Starting page | 58 66 | `zoom` | `number \| 'fit-width' \| 'fit-page' \| 'auto'` | — | Initial zoom | 59 - | `toolbar` | `boolean \| ToolbarConfig` | `true` | Toolbar visibility/config | 67 + | `toolbar` | `boolean \| ToolbarConfig` | `true` | Toolbar visibility/config. `ToolbarConfig` fields: `navigation`, `zoom`, `wrapToggle`, `fullscreen`, `info`, `download`, `position` | 60 68 | `showPageNumbers` | `boolean` | — | Show page numbers | 61 69 | `onReady` | `(info: DocumentInfo) => void` | — | Fired when document is loaded | 62 70 | `onPageChange` | `(page, total) => void` | — | Fired on page navigation | ··· 67 75 | `epub` | `EpubOptions` | — | EPUB-specific options | 68 76 | `code` | `CodeOptions` | — | Code-specific options | 69 77 | `csv` | `CsvOptions` | — | CSV/TSV-specific options | 78 + | `comic` | `ComicOptions` | — | Comic archive options (JXL/TIFF decoders, format filter) | 70 79 71 80 ### Imperative Control via Ref 72 81
+2 -2
packages/react/package.json
··· 21 21 "types": "./dist/index.d.ts", 22 22 "exports": { 23 23 ".": { 24 + "types": "./dist/index.d.ts", 24 25 "import": "./dist/index.js", 25 - "require": "./dist/index.cjs", 26 - "types": "./dist/index.d.ts" 26 + "require": "./dist/index.cjs" 27 27 } 28 28 }, 29 29 "files": [
+76
pnpm-lock.yaml
··· 14 14 15 15 examples/basic: 16 16 dependencies: 17 + 7z-wasm: 18 + specifier: '>=1.0.0' 19 + version: 1.2.0 20 + '@jsquash/jxl': 21 + specifier: '>=1.0.0' 22 + version: 1.3.0 17 23 '@polyrender/core': 18 24 specifier: workspace:* 19 25 version: link:../../packages/core ··· 29 35 jszip: 30 36 specifier: '>=3.0.0' 31 37 version: 3.10.1 38 + node-unrar-js: 39 + specifier: '>=2.0.0' 40 + version: 2.0.2 32 41 papaparse: 33 42 specifier: '>=5.0.0' 34 43 version: 5.5.3 35 44 pdfjs-dist: 36 45 specifier: '>=4.0.0' 37 46 version: 5.5.207 47 + utif: 48 + specifier: '>=3.0.0' 49 + version: 3.1.0 38 50 xlsx: 39 51 specifier: '>=0.18.0' 40 52 version: 0.18.5 ··· 48 60 49 61 examples/vanilla: 50 62 dependencies: 63 + 7z-wasm: 64 + specifier: '>=1.0.0' 65 + version: 1.2.0 66 + '@jsquash/jxl': 67 + specifier: '>=1.0.0' 68 + version: 1.3.0 51 69 '@polyrender/core': 52 70 specifier: workspace:* 53 71 version: link:../../packages/core ··· 63 81 jszip: 64 82 specifier: '>=3.0.0' 65 83 version: 3.10.1 84 + node-unrar-js: 85 + specifier: '>=2.0.0' 86 + version: 2.0.2 66 87 papaparse: 67 88 specifier: '>=5.0.0' 68 89 version: 5.5.3 69 90 pdfjs-dist: 70 91 specifier: '>=4.0.0' 71 92 version: 5.5.207 93 + utif: 94 + specifier: '>=3.0.0' 95 + version: 3.1.0 72 96 xlsx: 73 97 specifier: '>=0.18.0' 74 98 version: 0.18.5 ··· 82 106 83 107 packages/core: 84 108 dependencies: 109 + 7z-wasm: 110 + specifier: '>=1.0.0' 111 + version: 1.2.0 112 + '@jsquash/jxl': 113 + specifier: '>=1.0.0' 114 + version: 1.3.0 85 115 docx-preview: 86 116 specifier: '>=0.3.0' 87 117 version: 0.3.7 ··· 94 124 jszip: 95 125 specifier: '>=3.0.0' 96 126 version: 3.10.1 127 + node-unrar-js: 128 + specifier: '>=2.0.0' 129 + version: 2.0.2 97 130 papaparse: 98 131 specifier: '>=5.0.0' 99 132 version: 5.5.3 100 133 pdfjs-dist: 101 134 specifier: '>=4.0.0' 102 135 version: 5.5.207 136 + utif: 137 + specifier: '>=3.0.0' 138 + version: 3.1.0 103 139 xlsx: 104 140 specifier: '>=0.18.0' 105 141 version: 0.18.5 ··· 138 174 139 175 packages: 140 176 177 + 7z-wasm@1.2.0: 178 + resolution: {integrity: sha512-AZjtWXFleAe5nQ27JYISs70cICjO0zZr4LU45I9pXKcrhG0qiEWgq3Vjwr9JHHihFLuqgc7uSHYVn/z3vXCjdw==} 179 + engines: {node: '>=8.0.0'} 180 + hasBin: true 181 + 141 182 '@esbuild/aix-ppc64@0.25.12': 142 183 resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} 143 184 engines: {node: '>=18'} ··· 462 503 463 504 '@jridgewell/trace-mapping@0.3.31': 464 505 resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} 506 + 507 + '@jsquash/jxl@1.3.0': 508 + resolution: {integrity: sha512-IVOPTneyOd9eBAuow+FnCYIP6wkIc7CjrCDnZGiqAPyJ63vMVxv3zoWiucGiZVh6u9vi/l40AkcS9QwkoVSAqA==} 465 509 466 510 '@napi-rs/canvas-android-arm64@0.1.96': 467 511 resolution: {integrity: sha512-ew1sPrN3dGdZ3L4FoohPfnjq0f9/Jk7o+wP7HkQZokcXgIUD6FIyICEWGhMYzv53j63wUcPvZeAwgewX58/egg==} ··· 904 948 905 949 node-readable-to-web-readable-stream@0.4.2: 906 950 resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==} 951 + 952 + node-unrar-js@2.0.2: 953 + resolution: {integrity: sha512-hLNmoJzqaKJnod8yiTVGe9hnlNRHotUi0CreSv/8HtfRi/3JnRC8DvsmKfeGGguRjTEulhZK6zXX5PXoVuDZ2w==} 954 + engines: {node: '>=10.0.0'} 907 955 908 956 object-assign@4.1.1: 909 957 resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} ··· 980 1028 resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} 981 1029 engines: {node: '>= 14.18.0'} 982 1030 1031 + readline-sync@1.4.10: 1032 + resolution: {integrity: sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==} 1033 + engines: {node: '>= 0.8.0'} 1034 + 983 1035 resolve-from@5.0.0: 984 1036 resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} 985 1037 engines: {node: '>=8'} ··· 1069 1121 ufo@1.6.3: 1070 1122 resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} 1071 1123 1124 + utif@3.1.0: 1125 + resolution: {integrity: sha512-WEo4D/xOvFW53K5f5QTaTbbiORcm2/pCL9P6qmJnup+17eYfKaEhDeX9PeQkuyEoIxlbGklDuGl8xwuXYMrrXQ==} 1126 + 1072 1127 util-deprecate@1.0.2: 1073 1128 resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} 1074 1129 ··· 1112 1167 yaml: 1113 1168 optional: true 1114 1169 1170 + wasm-feature-detect@1.8.0: 1171 + resolution: {integrity: sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==} 1172 + 1115 1173 wmf@1.0.2: 1116 1174 resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} 1117 1175 engines: {node: '>=0.8'} ··· 1126 1184 hasBin: true 1127 1185 1128 1186 snapshots: 1187 + 1188 + 7z-wasm@1.2.0: 1189 + dependencies: 1190 + readline-sync: 1.4.10 1129 1191 1130 1192 '@esbuild/aix-ppc64@0.25.12': 1131 1193 optional: true ··· 1296 1358 dependencies: 1297 1359 '@jridgewell/resolve-uri': 3.1.2 1298 1360 '@jridgewell/sourcemap-codec': 1.5.5 1361 + 1362 + '@jsquash/jxl@1.3.0': 1363 + dependencies: 1364 + wasm-feature-detect: 1.8.0 1299 1365 1300 1366 '@napi-rs/canvas-android-arm64@0.1.96': 1301 1367 optional: true ··· 1680 1746 node-readable-to-web-readable-stream@0.4.2: 1681 1747 optional: true 1682 1748 1749 + node-unrar-js@2.0.2: {} 1750 + 1683 1751 object-assign@4.1.1: {} 1684 1752 1685 1753 pako@1.0.11: {} ··· 1742 1810 util-deprecate: 1.0.2 1743 1811 1744 1812 readdirp@4.1.2: {} 1813 + 1814 + readline-sync@1.4.10: {} 1745 1815 1746 1816 resolve-from@5.0.0: {} 1747 1817 ··· 1859 1929 1860 1930 ufo@1.6.3: {} 1861 1931 1932 + utif@3.1.0: 1933 + dependencies: 1934 + pako: 1.0.11 1935 + 1862 1936 util-deprecate@1.0.2: {} 1863 1937 1864 1938 vite@6.4.1: ··· 1871 1945 tinyglobby: 0.2.15 1872 1946 optionalDependencies: 1873 1947 fsevents: 2.3.3 1948 + 1949 + wasm-feature-detect@1.8.0: {} 1874 1950 1875 1951 wmf@1.0.2: {} 1876 1952