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: {}