diff --git a/README.md b/README.md
index 647d297..e72a1a2 100644
--- a/README.md
+++ b/README.md
@@ -6,13 +6,14 @@ A framework-agnostic, universal document renderer for the browser. Render PDFs,
## Features
-- **Multi-format rendering** — PDF, EPUB, DOCX, CSV/TSV, source code (100+ languages), plain text
+- **Multi-format rendering** — PDF, EPUB, DOCX, ODT, ODS, CSV/TSV, source code (100+ languages), plain text, and comic book archives (.cbz, .cbr, .cb7, .cbt)
- **Chunked loading** — Stream large documents via pre-rendered page images or split PDF chunks
- **Fetch adapters** — Pass data directly or provide a lazy-loading callback for on-demand fetching
- **CSS variable theming** — Dark and light themes built in, fully customizable via `--dv-*` variables
- **Framework-agnostic** — Use vanilla JS, React, or build your own wrapper
- **Lazy peer dependencies** — Only loads renderer libraries (pdfjs, epubjs, etc.) when that format is actually used
- **Custom renderers** — Register your own renderer for any format via the plugin registry
+- **Word wrap / fit toggle** — Toolbar button to toggle word wrap on code/text files and fit-to-width on comic pages
- **TypeScript-first** — Complete type definitions for all APIs
## Installation
@@ -30,8 +31,18 @@ npm install epubjs # EPUB
npm install docx-preview # DOCX
npm install papaparse # CSV/TSV
npm install highlight.js # Code syntax highlighting
-npm install jszip # ODT
+npm install jszip # ODT, CBZ comic archives
npm install xlsx # ODS
+npm install papaparse # CSV/TSV
+npm install highlight.js # Code syntax highlighting
+
+# Comic book archives — additional optional backends:
+npm install node-unrar-js # CBR (.cbr, RAR-compressed comics)
+npm install 7z-wasm # CB7 (.cb7, 7-Zip-compressed comics)
+
+# Comic book archives — optional exotic image format decoders:
+npm install @jsquash/jxl # JPEG XL images inside archives
+npm install utif # TIFF images inside archives
```
You only need to install peer dependencies for the formats you plan to render. Unused formats won't add to your bundle.
@@ -388,6 +399,11 @@ new PolyRender(container, {
| XML/HTML | `highlight.js` | `.xml`, `.html`, `.svg` |
| Pages | _(none)_ | N/A (explicit `type: 'pages'`) |
| Chunked PDF | `pdfjs-dist` | N/A (explicit `type: 'chunked'`) |
+| Comic — CBZ | `jszip` | `.cbz` |
+| Comic — CBR | `node-unrar-js` _(optional)_ | `.cbr` |
+| Comic — CB7 | `7z-wasm` _(optional)_ | `.cb7` |
+| Comic — CBT | _(none, built-in TAR reader)_ | `.cbt` |
+| Comic — CBA | ❌ not supported | `.cba` |
## Browser Support
@@ -418,7 +434,8 @@ packages/
│ │ ├── ods.ts # ODS (xlsx)
│ │ ├── csv.ts # CSV/TSV (papaparse)
│ │ ├── code.ts # Code (highlight.js)
-│ │ └── text.ts # Plain text
+│ │ ├── text.ts # Plain text
+│ │ └── comic.ts # Comic book archives (jszip / node-unrar-js / 7z-wasm)
│ └── package.json
└── react/ @polyrender/react — React wrapper
├── src/
diff --git a/examples/basic/README.md b/examples/basic/README.md
index 03679b2..ffaf250 100644
--- a/examples/basic/README.md
+++ b/examples/basic/README.md
@@ -28,3 +28,5 @@ pnpm preview
- Auto-detecting the document format from the filename
- Rendering with the built-in dark-themed toolbar
- Configuring the `pdfjs-dist` worker URL via Vite's `?url` import
+- Comic book archives (`.cbz`, `.cbr`, `.cb7`, `.cbt`) with JPEG XL and TIFF support enabled via `@jsquash/jxl` and `utif`
+- Word wrap / fit-to-width toolbar toggle (active for code, text, and comic files)
diff --git a/examples/basic/index.html b/examples/basic/index.html
index 9853f10..e67ea49 100644
--- a/examples/basic/index.html
+++ b/examples/basic/index.html
@@ -78,7 +78,7 @@
diff --git a/examples/basic/package.json b/examples/basic/package.json
index 613047e..375df2d 100644
--- a/examples/basic/package.json
+++ b/examples/basic/package.json
@@ -16,7 +16,11 @@
"papaparse": ">=5.0.0",
"highlight.js": ">=11.0.0",
"jszip": ">=3.0.0",
- "xlsx": ">=0.18.0"
+ "xlsx": ">=0.18.0",
+ "node-unrar-js": ">=2.0.0",
+ "7z-wasm": ">=1.0.0",
+ "@jsquash/jxl": ">=1.0.0",
+ "utif": ">=3.0.0"
},
"devDependencies": {
"typescript": "^5.5.0",
diff --git a/examples/basic/src/main.ts b/examples/basic/src/main.ts
index 0b90d12..8a1bc83 100644
--- a/examples/basic/src/main.ts
+++ b/examples/basic/src/main.ts
@@ -29,6 +29,11 @@ fileInput.addEventListener('change', () => {
pdf: {
workerSrc: pdfjsWorker,
},
+ // Comic book archives (.cbz, .cbr, .cb7, .cbt)
+ comic: {
+ jxlFallback: true, // JPEG XL decoding via @jsquash/jxl
+ tiffSupport: true, // TIFF decoding via utif
+ },
onReady: (info) => {
console.log(`Loaded "${file.name}" — ${info.pageCount} page(s), format: ${info.format}`)
},
diff --git a/examples/basic/vite.config.ts b/examples/basic/vite.config.ts
index 92c43c9..44769be 100644
--- a/examples/basic/vite.config.ts
+++ b/examples/basic/vite.config.ts
@@ -17,6 +17,9 @@ function resolvePeerDeps(): Plugin {
'highlight.js',
'jszip',
'xlsx',
+ 'node-unrar-js',
+ '@jsquash/jxl',
+ 'utif',
]
// Build a code snippet that maps module names → static imports.
@@ -25,9 +28,14 @@ function resolvePeerDeps(): Plugin {
.map((d) => ` case '${d}': return import('${d}').then(m => m.default || m);`)
.join('\n')
+ // 7z-wasm's default ESM build imports Node's 'module' built-in, so we
+ // redirect to its UMD build which is browser-safe.
+ const sevenZipCase = ` case '7z-wasm': return import('7z-wasm/7zz.umd.js').then(m => m.default || m);`
+
const replacement = [
'(async (name) => { switch(name) {',
cases,
+ sevenZipCase,
' default: throw new Error(`Unknown peer dep: ${name}`);',
' }})(moduleName)',
].join('\n')
@@ -53,6 +61,13 @@ function resolvePeerDeps(): Plugin {
export default defineConfig({
plugins: [resolvePeerDeps()],
+ // @jsquash/jxl ships a Web Worker whose internal format is "iife".
+ // Forcing workers to ES module format prevents the Rollup error:
+ // "Invalid value 'iife' for option 'worker.format' — UMD and IIFE output
+ // formats are not supported for code-splitting builds."
+ worker: {
+ format: 'es',
+ },
optimizeDeps: {
include: [
'pdfjs-dist',
@@ -62,6 +77,10 @@ export default defineConfig({
'highlight.js',
'jszip',
'xlsx',
+ 'node-unrar-js',
+ '7z-wasm/7zz.umd.js',
+ '@jsquash/jxl',
+ 'utif',
],
},
})
diff --git a/examples/vanilla/README.md b/examples/vanilla/README.md
index afe45bd..e9155df 100644
--- a/examples/vanilla/README.md
+++ b/examples/vanilla/README.md
@@ -20,3 +20,11 @@ Or use watch mode during development:
```bash
pnpm dev
```
+
+## What it shows
+
+- Opening a local file via a file input (PDF, EPUB, DOCX, CSV, code, comic archives, and more)
+- Auto-detecting the document format from the filename
+- Rendering with the built-in dark-themed toolbar
+- Comic book archives (`.cbz`, `.cbr`, `.cb7`, `.cbt`) with JPEG XL and TIFF support enabled via `@jsquash/jxl` and `utif`
+- Word wrap / fit-to-width toolbar toggle (active for code, text, and comic files)
diff --git a/examples/vanilla/build.js b/examples/vanilla/build.js
index 3e260fd..5f56f20 100644
--- a/examples/vanilla/build.js
+++ b/examples/vanilla/build.js
@@ -16,6 +16,28 @@ import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const isWatch = process.argv.includes("--watch");
+/**
+ * Plugin that provides empty shims for Node.js built-in modules when bundling
+ * for the browser. Some WASM-based packages (e.g. 7z-wasm) include conditional
+ * Node code paths that are never reached in a browser, but esbuild still tries
+ * to resolve the `require("fs")` / `require("crypto")` calls at bundle time.
+ */
+const shimNodeBuiltins = {
+ name: "shim-node-builtins",
+ setup(build) {
+ const builtins =
+ /^(fs|crypto|path|os|module|stream|util|events|buffer|assert|http|https|net|tls|url|zlib|readline|child_process|worker_threads|perf_hooks)$/;
+ build.onResolve({ filter: builtins }, (args) => ({
+ path: args.path,
+ namespace: "node-builtin-shim",
+ }));
+ build.onLoad({ filter: /.*/, namespace: "node-builtin-shim" }, () => ({
+ contents: "module.exports = {}",
+ loader: "js",
+ }));
+ },
+};
+
/** Plugin to resolve @polyrender/core's dynamic peer-dep imports. */
const resolvePeerDeps = {
name: "resolve-peer-deps",
@@ -37,7 +59,13 @@ const resolvePeerDeps = {
"highlight.js",
"jszip",
"xlsx",
+ "node-unrar-js",
+ "@jsquash/jxl",
+ "utif",
];
+ // 7z-wasm's default ESM build imports Node's 'module' built-in,
+ // so we redirect to its UMD build which is browser-safe.
+ const sevenZipCase = ` case '7z-wasm': return import('7z-wasm/7zz.umd.js').then(m => m.default || m);`;
const cases = peerDeps
.map(
(d) =>
@@ -48,6 +76,7 @@ const resolvePeerDeps = {
const replacement = [
"(async (name) => { switch(name) {",
cases,
+ sevenZipCase,
" default: throw new Error(`Unknown peer dep: ${name}`);",
" }})(moduleName)",
].join("\n");
@@ -100,10 +129,11 @@ const buildOptions = {
entryPoints: [resolve(__dirname, "src/main.ts")],
bundle: true,
format: "esm",
+ platform: "browser",
outdir: distDir,
sourcemap: true,
target: "es2022",
- plugins: [resolvePeerDeps],
+ plugins: [shimNodeBuiltins, resolvePeerDeps],
logLevel: "info",
};
diff --git a/examples/vanilla/index.html b/examples/vanilla/index.html
index d62380f..df78fcd 100644
--- a/examples/vanilla/index.html
+++ b/examples/vanilla/index.html
@@ -79,7 +79,7 @@
diff --git a/examples/vanilla/package.json b/examples/vanilla/package.json
index d7e0955..6da0bf1 100644
--- a/examples/vanilla/package.json
+++ b/examples/vanilla/package.json
@@ -16,7 +16,11 @@
"papaparse": ">=5.0.0",
"highlight.js": ">=11.0.0",
"jszip": ">=3.0.0",
- "xlsx": ">=0.18.0"
+ "xlsx": ">=0.18.0",
+ "node-unrar-js": ">=2.0.0",
+ "7z-wasm": ">=1.0.0",
+ "@jsquash/jxl": ">=1.0.0",
+ "utif": ">=3.0.0"
},
"devDependencies": {
"typescript": "^5.5.0",
diff --git a/examples/vanilla/src/main.ts b/examples/vanilla/src/main.ts
index d183084..d32266a 100644
--- a/examples/vanilla/src/main.ts
+++ b/examples/vanilla/src/main.ts
@@ -28,6 +28,11 @@ fileInput.addEventListener('change', () => {
// In the bundled output, the worker is a sibling file in dist/
workerSrc: './pdf.worker.min.mjs',
},
+ // Comic book archives (.cbz, .cbr, .cb7, .cbt)
+ comic: {
+ jxlFallback: true, // JPEG XL decoding via @jsquash/jxl
+ tiffSupport: true, // TIFF decoding via utif
+ },
onReady: (info) => {
console.log(`Loaded "${file.name}" — ${info.pageCount} page(s), format: ${info.format}`)
},
diff --git a/packages/core/README.md b/packages/core/README.md
index 8a56864..f285ac9 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -1,6 +1,6 @@
# @polyrender/core
-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.
+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.
For React support, see [`@polyrender/react`](https://www.npmjs.com/package/@polyrender/react).
@@ -16,10 +16,18 @@ Install peer dependencies only for the formats you need:
npm install pdfjs-dist # PDF
npm install epubjs # EPUB
npm install docx-preview # DOCX
-npm install jszip # ODT
+npm install jszip # ODT, CBZ comic archives
npm install xlsx # ODS
npm install papaparse # CSV/TSV
npm install highlight.js # Code, Markdown, JSON, XML/HTML
+
+# Comic book archives — additional optional backends:
+npm install node-unrar-js # CBR (.cbr, RAR-compressed comics)
+npm install 7z-wasm # CB7 (.cb7, 7-Zip-compressed comics)
+
+# Comic book archives — optional exotic image format decoders:
+npm install @jsquash/jxl # JPEG XL images inside archives
+npm install utif # TIFF images inside archives
```
## Usage
@@ -141,6 +149,14 @@ new PolyRender(container, {
initialPage?: number, // Starting page (default: 1)
zoom?: number | 'fit-width' | 'fit-page' | 'auto',
toolbar?: boolean | ToolbarConfig,
+ // ToolbarConfig fields:
+ // navigation?: boolean Show page nav controls (default true)
+ // zoom?: boolean Show zoom controls (default true)
+ // wrapToggle?: boolean Show word-wrap/fit toggle (auto for code, text, comic)
+ // fullscreen?: boolean Show fullscreen button (default true)
+ // info?: boolean Show filename label (default true)
+ // download?: boolean Show download button (default false)
+ // position?: 'top'|'bottom'
// Callbacks
onReady?: (info: DocumentInfo) => void,
@@ -156,6 +172,7 @@ new PolyRender(container, {
csv?: CsvOptions,
odt?: OdtOptions,
ods?: OdsOptions,
+ comic?: ComicOptions,
})
```
@@ -217,6 +234,25 @@ ods: {
}
```
+**Comic book archives**
+```typescript
+comic: {
+ // Image formats to extract from the archive.
+ // Defaults to all natively supported browser formats.
+ // Add 'jxl' + jxlFallback: true to enable JPEG XL decoding.
+ // Add 'tiff' + tiffSupport: true to enable TIFF decoding.
+ imageFormats?: Array<'png' | 'jpg' | 'gif' | 'bmp' | 'webp' | 'avif' | 'tiff' | 'jxl'>,
+
+ // Enable JPEG XL fallback decoding via @jsquash/jxl.
+ // Requires: npm install @jsquash/jxl
+ jxlFallback?: boolean,
+
+ // Enable TIFF image decoding via utif.
+ // Requires: npm install utif
+ tiffSupport?: boolean,
+}
+```
+
## Events
Subscribe to events using `.on()` (returns an unsubscribe function):
@@ -286,6 +322,13 @@ PolyRender.registerRenderer('custom-markdown', () => new MarkdownRenderer())
| XML/HTML | `highlight.js` | `.xml`, `.html`, `.svg` |
| Pages | _(none)_ | N/A (explicit `type: 'pages'`) |
| Chunked PDF | `pdfjs-dist` | N/A (explicit `type: 'chunked'`) |
+| Comic — CBZ | `jszip` | `.cbz` |
+| Comic — CBR | `node-unrar-js` _(optional)_ | `.cbr` |
+| Comic — CB7 | `7z-wasm` _(optional)_ | `.cb7` |
+| Comic — CBT | _(none, built-in TAR reader)_ | `.cbt` |
+| Comic — CBA | ❌ not supported | `.cba` |
+
+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).
## Live Demo
diff --git a/packages/core/package.json b/packages/core/package.json
index dbf3587..c24e33b 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -21,9 +21,9 @@
"types": "./dist/index.d.ts",
"exports": {
".": {
+ "types": "./dist/index.d.ts",
"import": "./dist/index.js",
- "require": "./dist/index.cjs",
- "types": "./dist/index.d.ts"
+ "require": "./dist/index.cjs"
},
"./styles.css": "./dist/styles.css"
},
@@ -52,7 +52,11 @@
"papaparse": ">=5.0.0",
"highlight.js": ">=11.0.0",
"jszip": ">=3.0.0",
- "xlsx": ">=0.18.0"
+ "xlsx": ">=0.18.0",
+ "node-unrar-js": ">=2.0.0",
+ "7z-wasm": ">=1.0.0",
+ "utif": ">=3.0.0",
+ "@jsquash/jxl": ">=1.0.0"
},
"peerDependenciesMeta": {
"pdfjs-dist": {
@@ -75,6 +79,18 @@
},
"xlsx": {
"optional": true
+ },
+ "node-unrar-js": {
+ "optional": true
+ },
+ "7z-wasm": {
+ "optional": true
+ },
+ "utif": {
+ "optional": true
+ },
+ "@jsquash/jxl": {
+ "optional": true
}
},
"keywords": [
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 5c60741..02cc6fe 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -29,6 +29,7 @@ export type {
EpubOptions,
OdtOptions,
OdsOptions,
+ ComicOptions,
ToolbarConfig,
// State & Info
@@ -68,6 +69,7 @@ export {
CsvRenderer,
CodeRenderer,
TextRenderer,
+ ComicRenderer,
} from './renderers/index.js'
// Utilities (for custom renderer authors)
diff --git a/packages/core/src/polyrender.ts b/packages/core/src/polyrender.ts
index 22cf93a..5037945 100644
--- a/packages/core/src/polyrender.ts
+++ b/packages/core/src/polyrender.ts
@@ -55,6 +55,7 @@ export class PolyRender {
private root: HTMLElement
private listeners = new Map void>>()
private destroyed = false
+ private wrapActive = false
constructor(container: HTMLElement, options: PolyRenderOptions) {
ensureRegistered()
@@ -243,6 +244,15 @@ export class PolyRender {
this.emit('error', err)
}
+ // Formats whose renderers support the wrap/fit toggle
+ const supportsWrap =
+ rendererFormat === 'code' ||
+ rendererFormat === 'text' ||
+ rendererFormat === 'comic'
+
+ // Text renderer starts with wrap on (pre-wrap by default in CSS)
+ this.wrapActive = rendererFormat === 'text'
+
// Create toolbar (before renderer mount, so it appears above the viewport)
const toolbarOpt = this.options.toolbar
if (toolbarOpt !== false) {
@@ -258,8 +268,13 @@ export class PolyRender {
onZoomOut: () => this.setZoom(this.getZoom() / 1.2),
onFitWidth: () => this.setZoom('fit-width'),
onFullscreen: () => this.toggleFullscreen(),
+ onWrapToggle: supportsWrap ? () => this.doWrapToggle() : undefined,
}, this.getState())
+ if (supportsWrap) {
+ this.toolbar.setWrapActive(this.wrapActive)
+ }
+
if (config.position === 'bottom') {
this.root.appendChild(this.toolbar.element)
} else {
@@ -289,6 +304,12 @@ export class PolyRender {
this.toolbar?.updateState(this.getState())
}
+ private doWrapToggle(): void {
+ if (!this.renderer?.toggleWrap) return
+ this.wrapActive = this.renderer.toggleWrap()
+ this.toolbar?.setWrapActive(this.wrapActive)
+ }
+
private toggleFullscreen(): void {
if (document.fullscreenElement === this.root) {
document.exitFullscreen()
diff --git a/packages/core/src/renderers/code.ts b/packages/core/src/renderers/code.ts
index 849c874..dd1bcc2 100644
--- a/packages/core/src/renderers/code.ts
+++ b/packages/core/src/renderers/code.ts
@@ -18,7 +18,9 @@ export class CodeRenderer extends BaseRenderer {
readonly format: DocumentFormat = 'code'
private codeContainer!: HTMLElement
+ private codeBody!: HTMLElement
private hljs: HighlightJS | null = null
+ private wordWrap = false
protected async onMount(viewport: HTMLElement, options: PolyRenderOptions): Promise {
this.showLoading('Loading file…')
@@ -72,6 +74,7 @@ export class CodeRenderer extends BaseRenderer {
}
// Code body
+ this.wordWrap = wordWrap
const body = el('pre', `dv-code-body${wordWrap ? ' dv-word-wrap' : ''}`)
const codeEl = document.createElement('code')
if (language) codeEl.className = `language-${language}`
@@ -81,6 +84,7 @@ export class CodeRenderer extends BaseRenderer {
}
body.appendChild(codeEl)
this.codeContainer.appendChild(body)
+ this.codeBody = body
this.setReady({
format: 'code',
@@ -140,6 +144,12 @@ export class CodeRenderer extends BaseRenderer {
return undefined
}
+ toggleWrap(): boolean {
+ this.wordWrap = !this.wordWrap
+ this.codeBody.classList.toggle('dv-word-wrap', this.wordWrap)
+ return this.wordWrap
+ }
+
protected onDestroy(): void {
this.hljs = null
}
diff --git a/packages/core/src/renderers/comic.ts b/packages/core/src/renderers/comic.ts
new file mode 100644
index 0000000..2715166
--- /dev/null
+++ b/packages/core/src/renderers/comic.ts
@@ -0,0 +1,650 @@
+import type { PolyRenderOptions, DocumentFormat } from '../types.js'
+import { PolyRenderError } from '../types.js'
+import { BaseRenderer } from '../renderer.js'
+import { el, clamp, debounce, requirePeerDep, toArrayBuffer, fetchAsBuffer, getExtension } from '../utils.js'
+
+// ---------------------------------------------------------------------------
+// Image format constants
+// ---------------------------------------------------------------------------
+
+const NATIVE_IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'avif'])
+
+const IMAGE_MIME: Record = {
+ png: 'image/png',
+ jpg: 'image/jpeg',
+ jpeg: 'image/jpeg',
+ gif: 'image/gif',
+ bmp: 'image/bmp',
+ webp: 'image/webp',
+ avif: 'image/avif',
+ tif: 'image/tiff',
+ tiff: 'image/tiff',
+ jxl: 'image/jxl',
+}
+
+// ---------------------------------------------------------------------------
+// Extracted image type
+// ---------------------------------------------------------------------------
+
+interface ExtractedImage {
+ name: string
+ data: ArrayBuffer
+ ext: string
+ mimeType: string
+}
+
+// ---------------------------------------------------------------------------
+// Natural sort (handles numeric runs: "page10" > "page9")
+// ---------------------------------------------------------------------------
+
+function naturalCompare(a: string, b: string): number {
+ const re = /(\d+)|(\D+)/g
+ const chunksA = a.match(re) ?? []
+ const chunksB = b.match(re) ?? []
+ for (let i = 0; i < Math.max(chunksA.length, chunksB.length); i++) {
+ if (i >= chunksA.length) return -1
+ if (i >= chunksB.length) return 1
+ const ca = chunksA[i], cb = chunksB[i]
+ const na = parseInt(ca, 10), nb = parseInt(cb, 10)
+ if (!isNaN(na) && !isNaN(nb)) {
+ if (na !== nb) return na - nb
+ } else {
+ const lca = ca.toLowerCase(), lcb = cb.toLowerCase()
+ if (lca < lcb) return -1
+ if (lca > lcb) return 1
+ }
+ }
+ return 0
+}
+
+function fileBasename(path: string): string {
+ return path.replace(/\\/g, '/').split('/').pop() ?? path
+}
+
+function fileExt(name: string): string {
+ return (name.split('.').pop() ?? '').toLowerCase()
+}
+
+function isImage(name: string, allowed?: Set): boolean {
+ const ext = fileExt(name)
+ return (allowed ?? new Set([...NATIVE_IMAGE_EXTS, 'tif', 'tiff', 'jxl'])).has(ext)
+}
+
+// ---------------------------------------------------------------------------
+// Built-in TAR reader (CBT = uncompressed TAR — no peer dep needed)
+// ---------------------------------------------------------------------------
+
+function extractFromTar(data: ArrayBuffer, allowed?: Set): ExtractedImage[] {
+ const bytes = new Uint8Array(data)
+ const files: ExtractedImage[] = []
+ const dec = new TextDecoder()
+
+ const str = (start: number, len: number): string => {
+ let end = start
+ while (end < start + len && bytes[end] !== 0) end++
+ return dec.decode(bytes.slice(start, end)).trim()
+ }
+
+ let offset = 0
+ while (offset + 512 <= bytes.length) {
+ const name = str(offset, 100)
+ if (!name) break // two zero blocks = EOF
+
+ const prefix = str(offset + 345, 155)
+ const fullName = prefix ? `${prefix}/${name}` : name
+ const basename = fileBasename(fullName)
+
+ const sizeStr = str(offset + 124, 12)
+ const size = parseInt(sizeStr, 8) || 0
+ const typeFlag = String.fromCharCode(bytes[offset + 156])
+
+ offset += 512
+
+ if ((typeFlag === '0' || typeFlag === '\0' || typeFlag === '') && size > 0) {
+ if (isImage(basename, allowed)) {
+ const ext = fileExt(basename)
+ files.push({
+ name: basename,
+ data: data.slice(offset, offset + size),
+ ext,
+ mimeType: IMAGE_MIME[ext] ?? 'application/octet-stream',
+ })
+ }
+ }
+
+ offset += Math.ceil(size / 512) * 512
+ }
+
+ return files.sort((a, b) => naturalCompare(a.name, b.name))
+}
+
+// ---------------------------------------------------------------------------
+// Peer-dep type declarations
+// ---------------------------------------------------------------------------
+
+// jszip ^3.0.0
+interface JSZipEntry { dir: boolean; async(type: 'arraybuffer'): Promise }
+interface JSZipInstance { loadAsync(data: ArrayBuffer): Promise<{ files: Record }> }
+type JSZipConstructor = new () => JSZipInstance
+// requirePeerDep may return the constructor directly (build plugins unwrap .default)
+// or the ESM namespace object with a .default property (plain import)
+type JSZipModule = JSZipConstructor | { default: JSZipConstructor }
+
+// node-unrar-js ^2.0.0 https://github.com/YuJianrong/node-unrar-js
+interface UnrarFileHeader {
+ name: string
+ flags: { encrypted: boolean; solid: boolean; directory: boolean }
+}
+interface UnrarArcFile { fileHeader: UnrarFileHeader; extraction?: Uint8Array }
+interface UnrarExtractor {
+ getFileList(): { arcHeader: unknown; fileHeaders: Generator }
+ extract(opts?: {
+ files?: string[] | ((h: UnrarFileHeader) => boolean)
+ password?: string
+ }): { arcHeader: unknown; files: Generator }
+}
+interface UnrarModule {
+ createExtractorFromData(opts: { data: ArrayBuffer; wasmBinary?: ArrayBuffer; password?: string }): Promise
+}
+
+// 7z-wasm ^1.2.0 https://github.com/nicktindall/7z-wasm (npm: 7z-wasm)
+// Uses an Emscripten virtual FS: write archive in, call main to extract, read files out.
+interface SevenZipNodeAttr { mode: number }
+interface SevenZipFS {
+ writeFile(path: string, data: ArrayBufferView, opts?: { flags?: string }): void
+ mkdir(path: string, mode?: number): unknown
+ readdir(path: string): string[]
+ readFile(path: string, opts?: { flags?: string }): Uint8Array
+ stat(path: string, dontFollow?: boolean): SevenZipNodeAttr
+ isFile(mode: number): boolean
+ isDir(mode: number): boolean
+ unlink(path: string): void
+ rmdir(path: string): void
+}
+interface SevenZipModule {
+ default: (opts?: { print?: (s: string) => void; printErr?: (s: string) => void }) => Promise<{
+ FS: SevenZipFS
+ callMain(args: string[]): void
+ }>
+}
+
+// utif ^3.1.0 https://github.com/photopea/UTIF.js
+interface UtifIfd { width: number; height: number; data: Uint8Array }
+interface UtifModule {
+ default: {
+ decode(buffer: ArrayBuffer): UtifIfd[]
+ decodeImage(buffer: ArrayBuffer, ifd: UtifIfd): void
+ }
+}
+
+// @jsquash/jxl ^1.3.0 https://github.com/jamsinclair/jSquash
+interface JxlModule {
+ decode(data: ArrayBuffer | Uint8Array): Promise
+}
+
+// ---------------------------------------------------------------------------
+// ComicRenderer
+// ---------------------------------------------------------------------------
+
+export class ComicRenderer extends BaseRenderer {
+ readonly format: DocumentFormat = 'comic'
+
+ private pagesContainer!: HTMLElement
+ private pageElements: HTMLElement[] = []
+ private pages: ExtractedImage[] = []
+ private pageDims = new Map() // 1-indexed
+ private blobUrls: string[] = []
+ private loadedPages = new Set()
+ private observer: IntersectionObserver | null = null
+ private debouncedScroll: ReturnType | null = null
+
+ private jxlDecoder: JxlModule | null = null
+ private utifDecoder: UtifModule | null = null
+ private fitMode = false
+
+ protected async onMount(viewport: HTMLElement, options: PolyRenderOptions): Promise {
+ const loadingEl = this.showLoading('Loading archive…')
+ const comic = options.comic ?? {}
+
+ // -- Resolve source ---------------------------------------------------------
+ let data: ArrayBuffer
+ let filename: string | undefined
+
+ if (options.source.type === 'file') {
+ data = await toArrayBuffer(options.source.data)
+ filename = options.source.filename
+ } else if (options.source.type === 'url') {
+ data = await fetchAsBuffer(options.source.url, options.source.fetchOptions)
+ filename = options.source.filename ?? fileBasename(options.source.url)
+ } else {
+ throw new PolyRenderError('FORMAT_UNSUPPORTED', 'Comic renderer requires a file or URL source.')
+ }
+
+ const archiveExt = filename ? getExtension(filename) : ''
+
+ // -- Build allowed image extension set from options -------------------------
+ let allowedExts: Set | undefined
+ if (comic.imageFormats) {
+ allowedExts = new Set()
+ for (const fmt of comic.imageFormats) {
+ if (fmt === 'jpg') { allowedExts.add('jpg'); allowedExts.add('jpeg') }
+ else if (fmt === 'tiff') { allowedExts.add('tiff'); allowedExts.add('tif') }
+ else allowedExts.add(fmt)
+ }
+ }
+
+ // -- Optionally load special-format decoders --------------------------------
+ if (comic.jxlFallback) {
+ try {
+ this.jxlDecoder = await requirePeerDep('@jsquash/jxl', 'JPEG XL images')
+ } catch {
+ // Silently continue without JXL support — JXL pages will be skipped
+ }
+ }
+ if (comic.tiffSupport) {
+ try {
+ this.utifDecoder = await requirePeerDep('utif', 'TIFF images')
+ } catch {
+ // Silently continue without TIFF support — TIFF pages will be skipped
+ }
+ }
+
+ // -- Extract images from archive --------------------------------------------
+ const msgEl = loadingEl.querySelector('span')
+ if (msgEl) msgEl.textContent = 'Extracting pages…'
+
+ this.pages = await this.extract(data, archiveExt, allowedExts)
+
+ if (this.pages.length === 0) {
+ throw new PolyRenderError('RENDER_FAILED', 'No supported image files found in the archive.')
+ }
+
+ // -- Build placeholder page elements ----------------------------------------
+ this.pagesContainer = el('div', 'dv-pages dv-comic-pages')
+ viewport.appendChild(this.pagesContainer)
+
+ const defaultW = 800 * this.state.zoom
+ const defaultH = 1200 * this.state.zoom
+
+ for (let i = 0; i < this.pages.length; i++) {
+ const pageEl = el('div', 'dv-page dv-browse-page dv-comic-page')
+ pageEl.dataset.page = String(i + 1)
+ pageEl.style.width = `${defaultW}px`
+ pageEl.style.height = `${defaultH}px`
+
+ const placeholder = el('div', 'dv-browse-page-placeholder')
+ placeholder.style.width = `${defaultW}px`
+ placeholder.style.height = `${defaultH}px`
+ placeholder.textContent = String(i + 1)
+ pageEl.appendChild(placeholder)
+
+ this.pageElements.push(pageEl)
+ this.pagesContainer.appendChild(pageEl)
+ }
+
+ loadingEl.remove()
+
+ // -- Intersection observer for lazy image loading ---------------------------
+ this.observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ if (entry.isIntersecting) {
+ const p = parseInt((entry.target as HTMLElement).dataset.page!, 10)
+ void this.loadPage(p)
+ }
+ }
+ },
+ { root: viewport, rootMargin: '150% 0px' },
+ )
+ for (const pageEl of this.pageElements) this.observer.observe(pageEl)
+
+ // -- Scroll-based current-page tracking ------------------------------------
+ this.debouncedScroll = debounce(() => this.updateCurrentPage(), 100)
+ viewport.addEventListener('scroll', this.debouncedScroll)
+
+ this.setReady({ format: 'comic', pageCount: this.pages.length, filename })
+
+ if (options.initialPage && options.initialPage > 1) {
+ this.goToPage(options.initialPage)
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Archive extraction dispatcher
+ // ---------------------------------------------------------------------------
+
+ private async extract(
+ data: ArrayBuffer,
+ ext: string,
+ allowed?: Set,
+ ): Promise {
+ switch (ext) {
+ case 'cbz': return this.extractZip(data, allowed)
+ case 'cbr': return this.extractRar(data, allowed)
+ case 'cb7': return this.extract7z(data, allowed)
+ case 'cbt': return extractFromTar(data, allowed)
+ case 'cba':
+ throw new PolyRenderError(
+ 'FORMAT_UNSUPPORTED',
+ 'CBА (ACE) archives are not supported. No browser-compatible ACE decoder is available.',
+ )
+ default:
+ // Unknown extension — try ZIP as a best-effort fallback (CBZ without correct ext)
+ return this.extractZip(data, allowed)
+ }
+ }
+
+ // -- CBZ via jszip -----------------------------------------------------------
+
+ private async extractZip(data: ArrayBuffer, allowed?: Set): Promise {
+ const mod = await requirePeerDep('jszip', 'CBZ comic archives')
+ const JSZip = typeof mod === 'function' ? mod : mod.default
+ const zip = await new JSZip().loadAsync(data)
+ const files: ExtractedImage[] = []
+
+ for (const [path, entry] of Object.entries(zip.files)) {
+ if (entry.dir) continue
+ const name = fileBasename(path)
+ if (!isImage(name, allowed)) continue
+ const ext = fileExt(name)
+ const buf = await entry.async('arraybuffer')
+ files.push({ name, data: buf, ext, mimeType: IMAGE_MIME[ext] ?? 'application/octet-stream' })
+ }
+
+ return files.sort((a, b) => naturalCompare(a.name, b.name))
+ }
+
+ // -- CBR via node-unrar-js ---------------------------------------------------
+ // Peer dep: node-unrar-js ^2.0.0 (npm install node-unrar-js)
+
+ private async extractRar(data: ArrayBuffer, allowed?: Set): Promise {
+ const unrar = await requirePeerDep('node-unrar-js', 'CBR comic archives')
+ const extractor = await unrar.createExtractorFromData({ data })
+
+ // Use a filter function so the generator only yields files we want,
+ // avoiding re-iterating the fileHeaders generator.
+ const { files: extracted } = extractor.extract({
+ files: (header) =>
+ !header.flags.encrypted &&
+ !header.flags.directory &&
+ isImage(fileBasename(header.name), allowed),
+ })
+
+ const files: ExtractedImage[] = []
+ for (const f of extracted) {
+ if (!f.extraction) continue
+ const name = fileBasename(f.fileHeader.name)
+ const ext = fileExt(name)
+ const raw = f.extraction
+ const buf = raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength) as ArrayBuffer
+ files.push({ name, data: buf, ext, mimeType: IMAGE_MIME[ext] ?? 'application/octet-stream' })
+ }
+
+ return files.sort((a, b) => naturalCompare(a.name, b.name))
+ }
+
+ // -- CB7 via 7z-wasm ---------------------------------------------------------
+ // Peer dep: 7z-wasm ^1.2.0 (npm install 7z-wasm)
+ // Uses an Emscripten virtual FS: write archive in → call main → read files out.
+
+ private async extract7z(data: ArrayBuffer, allowed?: Set): Promise {
+ const sevenZipMod = await requirePeerDep('7z-wasm', 'CB7 comic archives')
+ // Silence 7-Zip stdout/stderr to keep the browser console clean
+ const sz = await sevenZipMod.default({ print: () => {}, printErr: () => {} })
+
+ const archivePath = '/comic_input.7z'
+ const outDir = '/cb7_output'
+
+ sz.FS.writeFile(archivePath, new Uint8Array(data))
+ try { sz.FS.mkdir(outDir) } catch { /* already exists */ }
+ sz.callMain(['x', archivePath, `-o${outDir}`, '-y'])
+
+ const files: ExtractedImage[] = []
+ this.readDirRecursive(sz.FS, outDir, files, allowed)
+
+ // Clean up virtual FS to free memory
+ try { sz.FS.unlink(archivePath) } catch { /* ignore */ }
+
+ return files.sort((a, b) => naturalCompare(a.name, b.name))
+ }
+
+ private readDirRecursive(
+ fs: SevenZipFS,
+ dir: string,
+ out: ExtractedImage[],
+ allowed?: Set,
+ ): void {
+ let entries: string[]
+ try { entries = fs.readdir(dir) } catch { return }
+
+ for (const entry of entries) {
+ if (entry === '.' || entry === '..') continue
+ const fullPath = `${dir}/${entry}`
+ try {
+ const stat = fs.stat(fullPath)
+ if (fs.isDir(stat.mode)) {
+ this.readDirRecursive(fs, fullPath, out, allowed)
+ } else if (fs.isFile(stat.mode)) {
+ const name = fileBasename(fullPath)
+ if (!isImage(name, allowed)) continue
+ const ext = fileExt(name)
+ const raw = fs.readFile(fullPath)
+ const buf = raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength) as ArrayBuffer
+ out.push({ name, data: buf, ext, mimeType: IMAGE_MIME[ext] ?? 'application/octet-stream' })
+ }
+ } catch { /* skip unreadable entries */ }
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Lazy page loading
+ // ---------------------------------------------------------------------------
+
+ private async loadPage(pageNum: number): Promise {
+ if (this.loadedPages.has(pageNum)) return
+ this.loadedPages.add(pageNum)
+
+ const pageEl = this.pageElements[pageNum - 1]
+ const page = this.pages[pageNum - 1]
+ if (!pageEl || !page) return
+
+ try {
+ const blobUrl = await this.getImageBlobUrl(page)
+ this.blobUrls.push(blobUrl)
+
+ const img = document.createElement('img')
+ img.alt = `Page ${pageNum}`
+ img.draggable = false
+ img.decoding = 'async'
+
+ img.onload = () => {
+ const nw = img.naturalWidth
+ const nh = img.naturalHeight
+ this.pageDims.set(pageNum, { w: nw, h: nh })
+ if (!this.fitMode) {
+ const w = nw * this.state.zoom
+ const h = nh * this.state.zoom
+ img.style.width = `${w}px`
+ img.style.height = `${h}px`
+ pageEl.style.width = `${w}px`
+ pageEl.style.height = `${h}px`
+ }
+ }
+
+ if (!this.fitMode) {
+ img.style.width = pageEl.style.width
+ img.style.height = pageEl.style.height
+ }
+ img.src = blobUrl
+
+ pageEl.innerHTML = ''
+ pageEl.appendChild(img)
+ } catch {
+ this.loadedPages.delete(pageNum)
+ }
+ }
+
+ private async getImageBlobUrl(page: ExtractedImage): Promise {
+ // TIFF via utif
+ if ((page.ext === 'tiff' || page.ext === 'tif') && this.utifDecoder) {
+ return this.decodeTiff(page.data)
+ }
+
+ // JPEG XL via @jsquash/jxl
+ if (page.ext === 'jxl' && this.jxlDecoder) {
+ return this.decodeJxl(page.data)
+ }
+
+ // Native browser formats
+ return URL.createObjectURL(new Blob([page.data], { type: page.mimeType }))
+ }
+
+ private async decodeTiff(data: ArrayBuffer): Promise {
+ const UTIF = this.utifDecoder!.default
+ const ifds = UTIF.decode(data)
+ if (!ifds.length) throw new Error('Empty TIFF file')
+ UTIF.decodeImage(data, ifds[0])
+ const { width, height, data: rgba } = ifds[0]
+ const canvas = document.createElement('canvas')
+ canvas.width = width
+ canvas.height = height
+ const ctx = canvas.getContext('2d')!
+ ctx.putImageData(new ImageData(new Uint8ClampedArray(rgba), width, height), 0, 0)
+ return new Promise((resolve, reject) => {
+ canvas.toBlob((blob) => {
+ if (!blob) { reject(new Error('Failed to encode TIFF as PNG')); return }
+ resolve(URL.createObjectURL(blob))
+ }, 'image/png')
+ })
+ }
+
+ private async decodeJxl(data: ArrayBuffer): Promise {
+ const imageData = await this.jxlDecoder!.decode(data)
+ const canvas = document.createElement('canvas')
+ canvas.width = imageData.width
+ canvas.height = imageData.height
+ canvas.getContext('2d')!.putImageData(imageData, 0, 0)
+ return new Promise((resolve, reject) => {
+ canvas.toBlob((blob) => {
+ if (!blob) { reject(new Error('Failed to encode JXL as PNG')); return }
+ resolve(URL.createObjectURL(blob))
+ }, 'image/png')
+ })
+ }
+
+ // ---------------------------------------------------------------------------
+ // Page tracking
+ // ---------------------------------------------------------------------------
+
+ private updateCurrentPage(): void {
+ const viewportRect = this.viewport.getBoundingClientRect()
+ const mid = viewportRect.top + viewportRect.height / 2
+
+ for (let i = 0; i < this.pageElements.length; i++) {
+ const rect = this.pageElements[i].getBoundingClientRect()
+ if (rect.top <= mid && rect.bottom >= mid) {
+ const newPage = i + 1
+ if (newPage !== this.state.currentPage) {
+ this.state.currentPage = newPage
+ this.emitPageChange()
+ }
+ return
+ }
+ }
+ }
+
+ protected onPageChange(page: number): void {
+ this.pageElements[page - 1]?.scrollIntoView({ behavior: 'smooth', block: 'start' })
+ }
+
+ // ---------------------------------------------------------------------------
+ // Zoom
+ // ---------------------------------------------------------------------------
+
+ protected resolveZoomMode(mode: 'fit-width' | 'fit-page'): number {
+ const vw = this.viewport.clientWidth
+ if (!vw) return 1
+ // Find the first page with known dimensions
+ for (const [, dims] of this.pageDims) {
+ if (dims.w > 0) {
+ if (mode === 'fit-width') return (vw - 32) / dims.w // 16px padding each side
+ const vh = this.viewport.clientHeight
+ return Math.min((vw - 32) / dims.w, (vh - 32) / dims.h)
+ }
+ }
+ return 1
+ }
+
+ protected onZoomChange(zoom: number): void {
+ this.state.zoom = clamp(zoom, 0.1, 10)
+ if (this.fitMode) return // CSS controls sizing in fit mode
+ for (let i = 0; i < this.pageElements.length; i++) {
+ const pageEl = this.pageElements[i]
+ const dims = this.pageDims.get(i + 1)
+ if (dims) {
+ const w = dims.w * this.state.zoom
+ const h = dims.h * this.state.zoom
+ pageEl.style.width = `${w}px`
+ pageEl.style.height = `${h}px`
+ const img = pageEl.querySelector('img')
+ if (img) { img.style.width = `${w}px`; img.style.height = `${h}px` }
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Fit-mode toggle (wrap button)
+ // ---------------------------------------------------------------------------
+
+ toggleWrap(): boolean {
+ this.fitMode = !this.fitMode
+
+ if (this.fitMode) {
+ this.pagesContainer.classList.add('dv-comic-fit-mode')
+ // Clear inline sizes so CSS controls layout
+ for (const pageEl of this.pageElements) {
+ pageEl.style.width = ''
+ pageEl.style.height = ''
+ const img = pageEl.querySelector('img') as HTMLImageElement | null
+ if (img) { img.style.width = ''; img.style.height = '' }
+ const placeholder = pageEl.querySelector('.dv-browse-page-placeholder') as HTMLElement | null
+ if (placeholder) { placeholder.style.width = ''; placeholder.style.height = '' }
+ }
+ } else {
+ this.pagesContainer.classList.remove('dv-comic-fit-mode')
+ // Re-apply zoom-based sizing
+ for (let i = 0; i < this.pageElements.length; i++) {
+ const pageEl = this.pageElements[i]
+ const dims = this.pageDims.get(i + 1)
+ if (dims) {
+ const w = dims.w * this.state.zoom
+ const h = dims.h * this.state.zoom
+ pageEl.style.width = `${w}px`
+ pageEl.style.height = `${h}px`
+ const img = pageEl.querySelector('img') as HTMLImageElement | null
+ if (img) { img.style.width = `${w}px`; img.style.height = `${h}px` }
+ } else {
+ pageEl.style.width = `${800 * this.state.zoom}px`
+ pageEl.style.height = `${1200 * this.state.zoom}px`
+ }
+ }
+ }
+
+ return this.fitMode
+ }
+
+ // ---------------------------------------------------------------------------
+ // Cleanup
+ // ---------------------------------------------------------------------------
+
+ protected onDestroy(): void {
+ this.observer?.disconnect()
+ this.debouncedScroll?.cancel()
+ for (const url of this.blobUrls) URL.revokeObjectURL(url)
+ this.pageElements = []
+ this.pages = []
+ this.blobUrls = []
+ this.pageDims.clear()
+ this.loadedPages.clear()
+ }
+}
diff --git a/packages/core/src/renderers/index.ts b/packages/core/src/renderers/index.ts
index 3a3c911..7b49954 100644
--- a/packages/core/src/renderers/index.ts
+++ b/packages/core/src/renderers/index.ts
@@ -8,6 +8,7 @@ export { OdsRenderer } from './ods.js'
export { CsvRenderer } from './csv.js'
export { CodeRenderer } from './code.js'
export { TextRenderer } from './text.js'
+export { ComicRenderer } from './comic.js'
import { registry } from '../registry.js'
import { PdfRenderer } from './pdf.js'
@@ -20,6 +21,7 @@ import { OdsRenderer } from './ods.js'
import { CsvRenderer } from './csv.js'
import { CodeRenderer } from './code.js'
import { TextRenderer } from './text.js'
+import { ComicRenderer } from './comic.js'
/**
* Register all built-in renderers with the format registry.
@@ -36,4 +38,5 @@ export function registerBuiltinRenderers(): void {
registry.register('csv', () => new CsvRenderer())
registry.register('code', () => new CodeRenderer())
registry.register('text', () => new TextRenderer())
+ registry.register('comic', () => new ComicRenderer())
}
diff --git a/packages/core/src/renderers/text.ts b/packages/core/src/renderers/text.ts
index 80cb7dc..a714276 100644
--- a/packages/core/src/renderers/text.ts
+++ b/packages/core/src/renderers/text.ts
@@ -10,6 +10,8 @@ export class TextRenderer extends BaseRenderer {
readonly format: DocumentFormat = 'text'
private textContainer!: HTMLElement
+ // Text starts wrapped (pre-wrap in CSS). toggled = wrap is OFF.
+ private wrapDisabled = false
protected async onMount(viewport: HTMLElement, options: PolyRenderOptions): Promise {
this.showLoading('Loading text…')
@@ -63,6 +65,14 @@ export class TextRenderer extends BaseRenderer {
return undefined
}
+ // Returns true when wrap is active (the "on" state), false when off.
+ // Text starts wrapped, so the initial active state is true.
+ toggleWrap(): boolean {
+ this.wrapDisabled = !this.wrapDisabled
+ this.textContainer.classList.toggle('dv-no-wrap', this.wrapDisabled)
+ return !this.wrapDisabled
+ }
+
protected onDestroy(): void {
// No external resources to clean up
}
diff --git a/packages/core/src/styles.css b/packages/core/src/styles.css
index b5a877a..f2c2a96 100644
--- a/packages/core/src/styles.css
+++ b/packages/core/src/styles.css
@@ -200,6 +200,16 @@
pointer-events: none;
}
+.dv-toolbar-btn--active {
+ background: var(--dv-accent-subtle);
+ color: var(--dv-accent);
+}
+
+.dv-toolbar-btn--active:hover {
+ background: var(--dv-accent-subtle);
+ color: var(--dv-accent-hover);
+}
+
.dv-toolbar-btn svg {
width: 16px;
height: 16px;
@@ -726,6 +736,12 @@
max-width: none;
}
+.dv-text-container.dv-no-wrap {
+ white-space: pre;
+ word-wrap: normal;
+ overflow-x: auto;
+}
+
/* ==========================================================================
EPUB Renderer
========================================================================== */
@@ -795,3 +811,39 @@
color: var(--dv-text-muted);
font-size: var(--dv-font-size-sm);
}
+
+/* ==========================================================================
+ Comic Book Archive Renderer
+ ========================================================================== */
+
+.dv-comic-pages {
+ background: #111;
+}
+
+.dv-comic-page {
+ /* Comic images are often full-bleed; use a dark background to match
+ page edges instead of the white document background */
+ background: #000;
+}
+
+.dv-comic-page img {
+ display: block;
+ /* Images fill the page element exactly; dimensions are set in JS */
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ image-rendering: auto;
+}
+
+/* Fit mode: each page scales independently to fill the container width.
+ Inline px sizes are cleared in JS when this class is applied. */
+.dv-comic-fit-mode .dv-comic-page {
+ width: 100% !important;
+ height: auto !important;
+ min-height: 40px; /* keep placeholder visible while image loads */
+}
+
+.dv-comic-fit-mode .dv-comic-page img {
+ height: auto !important;
+ object-fit: unset;
+}
diff --git a/packages/core/src/toolbar.ts b/packages/core/src/toolbar.ts
index e16e799..32d4165 100644
--- a/packages/core/src/toolbar.ts
+++ b/packages/core/src/toolbar.ts
@@ -10,6 +10,7 @@ export interface ToolbarActions {
onFitWidth(): void
onFullscreen(): void
onDownload?(): void
+ onWrapToggle?(): void
}
export interface ToolbarHandle {
@@ -17,6 +18,8 @@ export interface ToolbarHandle {
element: HTMLElement
/** Update displayed state (page, total, zoom). */
updateState(state: PolyRenderState): void
+ /** Set the active (pressed) state of the wrap toggle button. */
+ setWrapActive(active: boolean): void
/** Destroy and clean up listeners. */
destroy(): void
}
@@ -40,6 +43,7 @@ export function createToolbar(
let zoomLabel: HTMLSpanElement | null = null
let prevBtn: HTMLButtonElement | null = null
let nextBtn: HTMLButtonElement | null = null
+ let wrapBtn: HTMLButtonElement | null = null
// --- Navigation group ---
if (config.navigation !== false) {
@@ -112,6 +116,15 @@ export function createToolbar(
fitWidthBtn.addEventListener('click', actions.onFitWidth)
zoomGroup.append(zoomOutBtn, zoomLabel, zoomInBtn, fitWidthBtn)
+
+ if (actions.onWrapToggle && config.wrapToggle !== false) {
+ wrapBtn = el('button', 'dv-toolbar-btn')
+ wrapBtn.title = 'Toggle word wrap / fit pages'
+ wrapBtn.appendChild(svgIcon(icons.wrapToggle))
+ wrapBtn.addEventListener('click', actions.onWrapToggle)
+ zoomGroup.appendChild(wrapBtn)
+ }
+
toolbar.appendChild(zoomGroup)
}
@@ -143,6 +156,14 @@ export function createToolbar(
if (prevBtn) prevBtn.disabled = state.currentPage <= 1
if (nextBtn) nextBtn.disabled = state.currentPage >= state.totalPages
},
+ setWrapActive(active: boolean) {
+ if (wrapBtn) {
+ wrapBtn.classList.toggle('dv-toolbar-btn--active', active)
+ wrapBtn.title = active
+ ? 'Disable word wrap / fit pages'
+ : 'Toggle word wrap / fit pages'
+ }
+ },
destroy() {
toolbar.remove()
},
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 30d0c1b..1d943cc 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -194,6 +194,7 @@ export interface PolyRenderOptions {
epub?: EpubOptions
odt?: OdtOptions
ods?: OdsOptions
+ comic?: ComicOptions
}
export interface PdfOptions {
@@ -254,6 +255,28 @@ export interface OdsOptions {
header?: boolean
}
+export interface ComicOptions {
+ /**
+ * Image formats to include from the archive. Defaults to all natively
+ * supported browser formats (png, jpg, gif, bmp, webp, avif).
+ * Add 'tiff' together with `tiffSupport: true` to enable TIFF decoding.
+ * Add 'jxl' together with `jxlFallback: true` to enable JPEG XL decoding.
+ */
+ imageFormats?: Array<'png' | 'jpg' | 'gif' | 'bmp' | 'webp' | 'avif' | 'tiff' | 'jxl'>
+ /**
+ * Enable JPEG XL fallback decoding via the `@jsquash/jxl` peer dependency.
+ * Install it with: npm install @jsquash/jxl
+ * Default: false.
+ */
+ jxlFallback?: boolean
+ /**
+ * Enable TIFF image decoding via the `utif` peer dependency.
+ * Install it with: npm install utif
+ * Default: false.
+ */
+ tiffSupport?: boolean
+}
+
export interface ToolbarConfig {
/** Show page navigation controls. Default true. */
navigation?: boolean
@@ -267,6 +290,9 @@ export interface ToolbarConfig {
fullscreen?: boolean
/** Toolbar position. Default 'top'. */
position?: 'top' | 'bottom'
+ /** Show word-wrap / fit-to-width toggle button. Shown automatically for
+ * code, text, and comic formats; set to `false` to hide it explicitly. */
+ wrapToggle?: boolean
}
@@ -352,6 +378,7 @@ export type DocumentFormat =
| 'html'
| 'json'
| 'xml'
+ | 'comic' // comic book archive (.cbz, .cbr, .cb7, .cbt)
| 'pages' // pre-rendered page images (no original document)
| 'chunked-pdf' // chunked PDF streaming
| (string & {}) // open union — consumers can register custom formats
@@ -399,6 +426,13 @@ export interface Renderer {
/** Get current zoom as a numeric scale factor. */
getZoom(): number
+ /**
+ * Toggle word wrap (text/code formats) or fit-to-width mode (comic format).
+ * Called by the toolbar wrap-toggle button.
+ * Returns `true` when the feature is now active, `false` when inactive.
+ */
+ toggleWrap?(): boolean
+
/** Perform a text search within the document. Returns match count. */
search?(query: string): Promise
diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts
index 0471a96..4cf2215 100644
--- a/packages/core/src/utils.ts
+++ b/packages/core/src/utils.ts
@@ -45,6 +45,7 @@ export const icons = {
fitWidth: 'M1 4h14M1 12h14M4 1v3M4 12v3M12 1v3M12 12v3',
fullscreen: 'M2 5V2h3M11 2h3v3M14 11v3h-3M5 14H2v-3',
download: 'M8 2v8M4 7l4 4 4-4M3 13h10',
+ wrapToggle: 'M2 4h12M2 9h6M13 7v2a2 2 0 0 1-2 2H5m2-2L5 11l2 2',
} as const
/** Remove all child nodes from an element. */
@@ -58,6 +59,12 @@ export function clearElement(el: HTMLElement): void {
// ---------------------------------------------------------------------------
const EXTENSION_MAP: Record = {
+ // Comic book archives
+ cbz: 'comic',
+ cbr: 'comic',
+ cb7: 'comic',
+ cbt: 'comic',
+ cba: 'comic',
pdf: 'pdf',
epub: 'epub',
docx: 'docx',
@@ -97,6 +104,13 @@ const EXTENSION_MAP: Record = {
}
const MIME_MAP: Record = {
+ // Comic book archives
+ 'application/vnd.comicbook+zip': 'comic',
+ 'application/vnd.comicbook-rar': 'comic',
+ 'application/x-cbr': 'comic',
+ 'application/x-cbz': 'comic',
+ 'application/x-cb7': 'comic',
+ 'application/x-cbt': 'comic',
'application/pdf': 'pdf',
'application/epub+zip': 'epub',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
diff --git a/packages/react/README.md b/packages/react/README.md
index 7e4bc34..d596429 100644
--- a/packages/react/README.md
+++ b/packages/react/README.md
@@ -2,7 +2,7 @@
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.
-Supports PDF, EPUB, DOCX, ODT, ODS, CSV/TSV, source code, and plain text.
+Supports PDF, EPUB, DOCX, ODT, ODS, CSV/TSV, source code, plain text, and comic book archives (.cbz, .cbr, .cb7, .cbt).
## Installation
@@ -16,10 +16,18 @@ Install peer dependencies only for the formats you need:
npm install pdfjs-dist # PDF
npm install epubjs # EPUB
npm install docx-preview # DOCX
-npm install jszip # ODT
+npm install jszip # ODT, CBZ comic archives
npm install xlsx # ODS
npm install papaparse # CSV/TSV
npm install highlight.js # Code, Markdown, JSON, XML/HTML
+
+# Comic book archives — additional optional backends:
+npm install node-unrar-js # CBR (.cbr, RAR-compressed comics)
+npm install 7z-wasm # CB7 (.cb7, 7-Zip-compressed comics)
+
+# Comic book archives — optional exotic image format decoders:
+npm install @jsquash/jxl # JPEG XL images inside archives
+npm install utif # TIFF images inside archives
```
## Quick Start
@@ -56,7 +64,7 @@ Drop-in component. Props mirror `PolyRenderOptions` from `@polyrender/core`.
| `style` | `React.CSSProperties` | — | Styles for the wrapper div (set width/height here) |
| `initialPage` | `number` | `1` | Starting page |
| `zoom` | `number \| 'fit-width' \| 'fit-page' \| 'auto'` | — | Initial zoom |
-| `toolbar` | `boolean \| ToolbarConfig` | `true` | Toolbar visibility/config |
+| `toolbar` | `boolean \| ToolbarConfig` | `true` | Toolbar visibility/config. `ToolbarConfig` fields: `navigation`, `zoom`, `wrapToggle`, `fullscreen`, `info`, `download`, `position` |
| `showPageNumbers` | `boolean` | — | Show page numbers |
| `onReady` | `(info: DocumentInfo) => void` | — | Fired when document is loaded |
| `onPageChange` | `(page, total) => void` | — | Fired on page navigation |
@@ -67,6 +75,7 @@ Drop-in component. Props mirror `PolyRenderOptions` from `@polyrender/core`.
| `epub` | `EpubOptions` | — | EPUB-specific options |
| `code` | `CodeOptions` | — | Code-specific options |
| `csv` | `CsvOptions` | — | CSV/TSV-specific options |
+| `comic` | `ComicOptions` | — | Comic archive options (JXL/TIFF decoders, format filter) |
### Imperative Control via Ref
diff --git a/packages/react/package.json b/packages/react/package.json
index 6051cda..f0edc2b 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -21,9 +21,9 @@
"types": "./dist/index.d.ts",
"exports": {
".": {
+ "types": "./dist/index.d.ts",
"import": "./dist/index.js",
- "require": "./dist/index.cjs",
- "types": "./dist/index.d.ts"
+ "require": "./dist/index.cjs"
}
},
"files": [
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d053099..f4af9b4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -14,6 +14,12 @@ importers:
examples/basic:
dependencies:
+ 7z-wasm:
+ specifier: '>=1.0.0'
+ version: 1.2.0
+ '@jsquash/jxl':
+ specifier: '>=1.0.0'
+ version: 1.3.0
'@polyrender/core':
specifier: workspace:*
version: link:../../packages/core
@@ -29,12 +35,18 @@ importers:
jszip:
specifier: '>=3.0.0'
version: 3.10.1
+ node-unrar-js:
+ specifier: '>=2.0.0'
+ version: 2.0.2
papaparse:
specifier: '>=5.0.0'
version: 5.5.3
pdfjs-dist:
specifier: '>=4.0.0'
version: 5.5.207
+ utif:
+ specifier: '>=3.0.0'
+ version: 3.1.0
xlsx:
specifier: '>=0.18.0'
version: 0.18.5
@@ -48,6 +60,12 @@ importers:
examples/vanilla:
dependencies:
+ 7z-wasm:
+ specifier: '>=1.0.0'
+ version: 1.2.0
+ '@jsquash/jxl':
+ specifier: '>=1.0.0'
+ version: 1.3.0
'@polyrender/core':
specifier: workspace:*
version: link:../../packages/core
@@ -63,12 +81,18 @@ importers:
jszip:
specifier: '>=3.0.0'
version: 3.10.1
+ node-unrar-js:
+ specifier: '>=2.0.0'
+ version: 2.0.2
papaparse:
specifier: '>=5.0.0'
version: 5.5.3
pdfjs-dist:
specifier: '>=4.0.0'
version: 5.5.207
+ utif:
+ specifier: '>=3.0.0'
+ version: 3.1.0
xlsx:
specifier: '>=0.18.0'
version: 0.18.5
@@ -82,6 +106,12 @@ importers:
packages/core:
dependencies:
+ 7z-wasm:
+ specifier: '>=1.0.0'
+ version: 1.2.0
+ '@jsquash/jxl':
+ specifier: '>=1.0.0'
+ version: 1.3.0
docx-preview:
specifier: '>=0.3.0'
version: 0.3.7
@@ -94,12 +124,18 @@ importers:
jszip:
specifier: '>=3.0.0'
version: 3.10.1
+ node-unrar-js:
+ specifier: '>=2.0.0'
+ version: 2.0.2
papaparse:
specifier: '>=5.0.0'
version: 5.5.3
pdfjs-dist:
specifier: '>=4.0.0'
version: 5.5.207
+ utif:
+ specifier: '>=3.0.0'
+ version: 3.1.0
xlsx:
specifier: '>=0.18.0'
version: 0.18.5
@@ -138,6 +174,11 @@ importers:
packages:
+ 7z-wasm@1.2.0:
+ resolution: {integrity: sha512-AZjtWXFleAe5nQ27JYISs70cICjO0zZr4LU45I9pXKcrhG0qiEWgq3Vjwr9JHHihFLuqgc7uSHYVn/z3vXCjdw==}
+ engines: {node: '>=8.0.0'}
+ hasBin: true
+
'@esbuild/aix-ppc64@0.25.12':
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
engines: {node: '>=18'}
@@ -463,6 +504,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@jsquash/jxl@1.3.0':
+ resolution: {integrity: sha512-IVOPTneyOd9eBAuow+FnCYIP6wkIc7CjrCDnZGiqAPyJ63vMVxv3zoWiucGiZVh6u9vi/l40AkcS9QwkoVSAqA==}
+
'@napi-rs/canvas-android-arm64@0.1.96':
resolution: {integrity: sha512-ew1sPrN3dGdZ3L4FoohPfnjq0f9/Jk7o+wP7HkQZokcXgIUD6FIyICEWGhMYzv53j63wUcPvZeAwgewX58/egg==}
engines: {node: '>= 10'}
@@ -905,6 +949,10 @@ packages:
node-readable-to-web-readable-stream@0.4.2:
resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==}
+ node-unrar-js@2.0.2:
+ resolution: {integrity: sha512-hLNmoJzqaKJnod8yiTVGe9hnlNRHotUi0CreSv/8HtfRi/3JnRC8DvsmKfeGGguRjTEulhZK6zXX5PXoVuDZ2w==}
+ engines: {node: '>=10.0.0'}
+
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -980,6 +1028,10 @@ packages:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
+ readline-sync@1.4.10:
+ resolution: {integrity: sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==}
+ engines: {node: '>= 0.8.0'}
+
resolve-from@5.0.0:
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
engines: {node: '>=8'}
@@ -1069,6 +1121,9 @@ packages:
ufo@1.6.3:
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
+ utif@3.1.0:
+ resolution: {integrity: sha512-WEo4D/xOvFW53K5f5QTaTbbiORcm2/pCL9P6qmJnup+17eYfKaEhDeX9PeQkuyEoIxlbGklDuGl8xwuXYMrrXQ==}
+
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -1112,6 +1167,9 @@ packages:
yaml:
optional: true
+ wasm-feature-detect@1.8.0:
+ resolution: {integrity: sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==}
+
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
@@ -1127,6 +1185,10 @@ packages:
snapshots:
+ 7z-wasm@1.2.0:
+ dependencies:
+ readline-sync: 1.4.10
+
'@esbuild/aix-ppc64@0.25.12':
optional: true
@@ -1297,6 +1359,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@jsquash/jxl@1.3.0':
+ dependencies:
+ wasm-feature-detect: 1.8.0
+
'@napi-rs/canvas-android-arm64@0.1.96':
optional: true
@@ -1680,6 +1746,8 @@ snapshots:
node-readable-to-web-readable-stream@0.4.2:
optional: true
+ node-unrar-js@2.0.2: {}
+
object-assign@4.1.1: {}
pako@1.0.11: {}
@@ -1743,6 +1811,8 @@ snapshots:
readdirp@4.1.2: {}
+ readline-sync@1.4.10: {}
+
resolve-from@5.0.0: {}
rollup@4.59.0:
@@ -1859,6 +1929,10 @@ snapshots:
ufo@1.6.3: {}
+ utif@3.1.0:
+ dependencies:
+ pako: 1.0.11
+
util-deprecate@1.0.2: {}
vite@6.4.1:
@@ -1872,6 +1946,8 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
+ wasm-feature-detect@1.8.0: {}
+
wmf@1.0.2: {}
word@0.3.0: {}