diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..770f53d --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2026 Aria Quinlan + +This software is provided ‘as-is’, without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not +claim that you wrote the original software. If you use this software +in a product, an acknowledgment in the product documentation would be +appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not be +misrepresented as being the original software. + +3. This notice may not be removed or altered from any source +distribution. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..cee26ea --- /dev/null +++ b/README.md @@ -0,0 +1,404 @@ +# DocView + +A framework-agnostic, universal document renderer for the browser. Render PDFs, EPUBs, DOCX files, CSVs, source code, and plain text — with optional support for pre-rendered page images and chunked streaming for large documents. + +**Core** (`@docview/core`) is a vanilla TypeScript library with zero framework dependencies. **React** (`@docview/react`) provides a thin wrapper component and hook. Both are designed for drop-in use in any web project. + +## Features + +- **Multi-format rendering** — PDF, EPUB, DOCX, CSV/TSV, source code (100+ languages), plain text +- **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 +- **TypeScript-first** — Complete type definitions for all APIs + +## Installation + +```bash +# Core (vanilla JS) +npm install @docview/core + +# React wrapper +npm install @docview/react + +# Install peer dependencies for the formats you need: +npm install pdfjs-dist # PDF +npm install epubjs # EPUB +npm install docx-preview # DOCX +npm install papaparse # CSV/TSV +npm install highlight.js # Code syntax highlighting +``` + +You only need to install peer dependencies for the formats you plan to render. Unused formats won't add to your bundle. + +## Quick Start + +### Vanilla JS + +```typescript +import { DocView } from '@docview/core' +import '@docview/core/styles.css' + +const viewer = new DocView(document.getElementById('viewer')!, { + source: { type: 'url', url: '/document.pdf' }, + theme: 'dark', + toolbar: true, + onReady: (info) => { + console.log(`Loaded: ${info.pageCount} pages`) + }, + onPageChange: (page, total) => { + console.log(`Page ${page} of ${total}`) + }, +}) + +// Imperative control +viewer.goToPage(5) +viewer.setZoom('fit-width') + +// Clean up +viewer.destroy() +``` + +### React + +```tsx +import { DocumentViewer } from '@docview/react' +import '@docview/core/styles.css' + +function App() { + return ( + console.log(`${info.pageCount} pages`)} + onPageChange={(page, total) => console.log(`${page}/${total}`)} + /> + ) +} +``` + +### React with Ref + +```tsx +import { useRef } from 'react' +import { DocumentViewer, type DocumentViewerRef } from '@docview/react' +import '@docview/core/styles.css' + +function App() { + const viewerRef = useRef(null) + + return ( + <> + + + + ) +} +``` + +### React Hook (headless) + +```tsx +import { useDocumentRenderer } from '@docview/react' +import '@docview/core/styles.css' + +function CustomViewer({ url }: { url: string }) { + const { containerRef, state, goToPage, setZoom } = useDocumentRenderer({ + source: { type: 'url', url }, + theme: 'dark', + toolbar: false, // Hide built-in toolbar, build your own + }) + + return ( +
+
+
+ + {state.currentPage} / {state.totalPages} + + +
+
+ ) +} +``` + +## Document Sources + +DocView accepts four types of document sources: + +### File (binary data) + +```typescript +// From a File input +const file = inputElement.files[0] +source = { type: 'file', data: file, filename: file.name } + +// From an ArrayBuffer +source = { type: 'file', data: arrayBuffer, mimeType: 'application/pdf' } + +// From a Uint8Array +source = { type: 'file', data: uint8Array, filename: 'doc.pdf' } +``` + +### URL + +```typescript +source = { type: 'url', url: 'https://example.com/doc.pdf' } + +// With custom headers (e.g., auth) +source = { + type: 'url', + url: '/api/documents/123.pdf', + fetchOptions: { headers: { Authorization: 'Bearer ...' } }, +} +``` + +### Pre-rendered Pages (for browsing without the original document) + +```typescript +// Direct data +source = { + type: 'pages', + pages: [ + { pageNumber: 1, imageUrl: '/pages/1.webp', width: 1654, height: 2339 }, + { pageNumber: 2, imageUrl: '/pages/2.webp', width: 1654, height: 2339 }, + ], +} + +// Lazy fetch adapter (loads pages on demand as user scrolls) +source = { + type: 'pages', + pages: { + totalPages: 500, + fetchPage: async (pageNumber) => ({ + pageNumber, + imageUrl: `/api/pages/${pageNumber}.webp`, + width: 1654, + height: 2339, + }), + }, +} +``` + +### Chunked PDF (streaming large documents) + +```typescript +source = { + type: 'chunked', + totalPages: 500, + // PDF chunks for full-fidelity rendering + chunks: { + totalChunks: 10, + totalPages: 500, + fetchChunk: async (index) => { + const res = await fetch(`/api/chunks/${index}.pdf`) + return { + data: await res.arrayBuffer(), + pageStart: index * 50 + 1, + pageEnd: Math.min((index + 1) * 50, 500), + } + }, + getChunkIndexForPage: (page) => Math.floor((page - 1) / 50), + }, + // Optional: fast browse images while chunks load + browsePages: { + totalPages: 500, + fetchPage: async (pageNumber) => ({ + pageNumber, + imageUrl: `/api/browse/${pageNumber}.webp`, + width: 1654, + height: 2339, + }), + }, +} +``` + +## Theming + +DocView uses CSS custom properties for all visual styling. Override any `--dv-*` variable to customize: + +```css +/* Custom theme */ +.my-viewer .docview { + --dv-bg: #1e1e2e; + --dv-surface: #2a2a3e; + --dv-text: #cdd6f4; + --dv-accent: #89b4fa; + --dv-border: #45475a; + --dv-page-shadow: 0 2px 12px rgba(0, 0, 0, 0.4); + --dv-font-sans: 'JetBrains Mono', monospace; +} +``` + +Built-in themes: `dark` (default) and `light`. Set via the `theme` prop/option, or `'system'` to auto-detect from `prefers-color-scheme`. + +### Key CSS Variables + +| Variable | Description | +|----------|-------------| +| `--dv-bg` | Background color | +| `--dv-surface` | Toolbar and panel backgrounds | +| `--dv-text` | Primary text color | +| `--dv-text-secondary` | Secondary/muted text | +| `--dv-accent` | Accent color (links, focus rings) | +| `--dv-border` | Border color | +| `--dv-page-bg` | Document page background | +| `--dv-page-shadow` | Document page drop shadow | +| `--dv-font-sans` | Sans-serif font stack | +| `--dv-font-mono` | Monospace font stack | +| `--dv-radius` | Border radius | +| `--dv-toolbar-height` | Toolbar height | + +See `styles.css` for the complete list. + +## Format-Specific Options + +### PDF + +```typescript +{ + pdf: { + workerSrc: '/pdf.worker.min.js', // pdf.js worker URL + cMapUrl: '/cmaps/', // Character map directory + textLayer: true, // Enable text selection (default true) + annotationLayer: false, // Show PDF annotations + } +} +``` + +### Code + +```typescript +{ + code: { + language: 'typescript', // Force language (auto-detected from extension) + lineNumbers: true, // Show line numbers (default true) + wordWrap: false, // Enable word wrapping (default false) + tabSize: 2, // Tab width in spaces (default 2) + } +} +``` + +### CSV + +```typescript +{ + csv: { + delimiter: ',', // Field delimiter (auto-detected) + header: true, // First row is header (default true) + maxRows: 10000, // Max rows to render (default 10000) + sortable: true, // Enable column sorting (default true) + } +} +``` + +### EPUB + +```typescript +{ + epub: { + flow: 'paginated', // 'paginated' or 'scrolled' (default 'paginated') + fontSize: 16, // Font size in pixels (default 16) + fontFamily: 'Georgia', // Font override + } +} +``` + +## Custom Renderers + +Register a renderer for any format: + +```typescript +import { DocView, BaseRenderer, type DocViewOptions, type DocumentFormat } from '@docview/core' + +class MarkdownRenderer extends BaseRenderer { + readonly format: DocumentFormat = 'custom-markdown' + + protected async onMount(viewport: HTMLElement, options: DocViewOptions) { + // Your rendering logic here + const text = await this.loadText(options.source) + const html = myMarkdownLib.render(text) + viewport.innerHTML = html + this.setReady({ format: 'custom-markdown', pageCount: 1 }) + } + + protected onDestroy() {} +} + +// Register globally +DocView.registerRenderer('custom-markdown', () => new MarkdownRenderer()) + +// Use it +new DocView(container, { + source: { type: 'url', url: '/readme.md' }, + format: 'custom-markdown', +}) +``` + +## Supported Formats + +| Format | Peer Dependency | Auto-detected Extensions | +|--------|----------------|-------------------------| +| PDF | `pdfjs-dist` | `.pdf` | +| EPUB | `epubjs` | `.epub` | +| DOCX | `docx-preview` | `.docx`, `.doc` | +| CSV/TSV | `papaparse` | `.csv`, `.tsv` | +| Code | `highlight.js` | `.js`, `.ts`, `.py`, `.rs`, `.go`, `.java`, `.c`, `.cpp`, +80 more | +| Text | _(none)_ | `.txt` | +| Markdown | `highlight.js` | `.md` (rendered as syntax-highlighted code) | +| JSON | `highlight.js` | `.json` | +| XML/HTML | `highlight.js` | `.xml`, `.html`, `.svg` | +| Pages | _(none)_ | N/A (explicit `type: 'pages'`) | +| Chunked PDF | `pdfjs-dist` | N/A (explicit `type: 'chunked'`) | + +## Browser Support + +- Chrome/Edge 88+ +- Firefox 78+ +- Safari 15.4+ (OffscreenCanvas support for Web Worker rendering) + +## Project Structure + +``` +packages/ +├── core/ @docview/core — Framework-agnostic TypeScript core +│ ├── src/ +│ │ ├── types.ts # All interfaces and types +│ │ ├── docview.ts # Main DocView class +│ │ ├── renderer.ts # Abstract base renderer +│ │ ├── registry.ts # Format → renderer factory mapping +│ │ ├── toolbar.ts # Built-in toolbar DOM builder +│ │ ├── utils.ts # Format detection, data conversion, DOM helpers +│ │ ├── styles.css # CSS variables theme system +│ │ └── renderers/ +│ │ ├── pdf.ts # PDF (pdfjs-dist) +│ │ ├── browse-pages.ts # Pre-rendered page images +│ │ ├── chunked-pdf.ts # Chunked PDF streaming +│ │ ├── epub.ts # EPUB (epubjs) +│ │ ├── docx.ts # DOCX (docx-preview) +│ │ ├── csv.ts # CSV/TSV (papaparse) +│ │ ├── code.ts # Code (highlight.js) +│ │ └── text.ts # Plain text +│ └── package.json +└── react/ @docview/react — React wrapper + ├── src/ + │ ├── DocumentViewer.tsx # Drop-in component + │ ├── useDocumentRenderer.ts # Headless hook + │ └── index.ts + └── package.json +``` + +## License + +MIT diff --git a/examples/basic/LICENSE b/examples/basic/LICENSE new file mode 100644 index 0000000..770f53d --- /dev/null +++ b/examples/basic/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2026 Aria Quinlan + +This software is provided ‘as-is’, without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not +claim that you wrote the original software. If you use this software +in a product, an acknowledgment in the product documentation would be +appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not be +misrepresented as being the original software. + +3. This notice may not be removed or altered from any source +distribution. \ No newline at end of file diff --git a/examples/basic/index.html b/examples/basic/index.html new file mode 100644 index 0000000..cef0472 --- /dev/null +++ b/examples/basic/index.html @@ -0,0 +1,79 @@ + + + + + + DocView — Basic Example + + + +

DocView — Basic Example

+

Drop-in document viewer. Pick a file below to view it.

+
+ +
+
+ + + diff --git a/examples/basic/package.json b/examples/basic/package.json new file mode 100644 index 0000000..c5bd204 --- /dev/null +++ b/examples/basic/package.json @@ -0,0 +1,25 @@ +{ + "name": "@docview/example-basic", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@docview/core": "workspace:*", + "pdfjs-dist": ">=4.0.0", + "epubjs": ">=0.3.0", + "docx-preview": ">=0.3.0", + "papaparse": ">=5.0.0", + "highlight.js": ">=11.0.0", + "jszip": ">=3.0.0", + "xlsx": ">=0.18.0" + }, + "devDependencies": { + "typescript": "^5.5.0", + "vite": "^6.0.0" + } +} \ No newline at end of file diff --git a/examples/basic/src/main.ts b/examples/basic/src/main.ts new file mode 100644 index 0000000..f4dcbf6 --- /dev/null +++ b/examples/basic/src/main.ts @@ -0,0 +1,39 @@ +import { DocView } from '@docview/core' +import '../../../packages/core/src/styles.css' +import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?url' + +const viewerEl = document.getElementById('viewer')! +const fileInput = document.getElementById('file-input') as HTMLInputElement + +let viewer: DocView | null = null + +fileInput.addEventListener('change', () => { + const file = fileInput.files?.[0] + if (!file) return + + // Destroy previous viewer if one exists + if (viewer) { + viewer.destroy() + viewer = null + } + + // Create a new DocView instance with the selected file + viewer = new DocView(viewerEl, { + source: { + type: 'file', + data: file, + filename: file.name, + }, + theme: 'dark', + toolbar: true, + pdf: { + workerSrc: pdfjsWorker, + }, + onReady: (info) => { + console.log(`Loaded "${file.name}" — ${info.pageCount} page(s), format: ${info.format}`) + }, + onError: (err) => { + console.error('DocView error:', err) + }, + }) +}) diff --git a/examples/basic/src/vite-env.d.ts b/examples/basic/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/examples/basic/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/basic/tsconfig.json b/examples/basic/tsconfig.json new file mode 100644 index 0000000..200adeb --- /dev/null +++ b/examples/basic/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "noEmit": true + }, + "include": ["src"] +} diff --git a/examples/basic/vite.config.ts b/examples/basic/vite.config.ts new file mode 100644 index 0000000..f180c8e --- /dev/null +++ b/examples/basic/vite.config.ts @@ -0,0 +1,67 @@ +import { defineConfig, type Plugin } from 'vite' + +/** + * The core library's `requirePeerDep` does `import(moduleName)` where + * `moduleName` is a variable — Vite cannot statically analyze that, so the + * import fails at runtime in the browser. + * + * This plugin rewrites the variable import into a lookup of static `import()` + * calls that Vite CAN resolve and pre-bundle. + */ +function resolvePeerDeps(): Plugin { + const peerDeps = [ + 'pdfjs-dist', + 'epubjs', + 'docx-preview', + 'papaparse', + 'highlight.js', + 'jszip', + 'xlsx', + ] + + // Build a code snippet that maps module names → static imports. + // highlight.js needs `.default` unwrapping for ESM compatibility. + const cases = peerDeps + .map((d) => ` case '${d}': return import('${d}').then(m => m.default || m);`) + .join('\n') + + const replacement = [ + '(async (name) => { switch(name) {', + cases, + ' default: throw new Error(`Unknown peer dep: ${name}`);', + ' }})(moduleName)', + ].join('\n') + + return { + name: 'resolve-docview-peer-deps', + enforce: 'pre', + transform(code: string, id: string) { + // Only transform the @docview/core bundle + if (!id.includes('docview')) return + if (!code.includes('moduleName')) return + + // Replace `await import(moduleName)` or `await import(\n moduleName\n)` + const result = code.replace( + /await\s+import\(\s*(?:\/\*.*?\*\/\s*)?moduleName\s*\)/g, + `await ${replacement}`, + ) + + if (result !== code) return result + }, + } +} + +export default defineConfig({ + plugins: [resolvePeerDeps()], + optimizeDeps: { + include: [ + 'pdfjs-dist', + 'epubjs', + 'docx-preview', + 'papaparse', + 'highlight.js', + 'jszip', + 'xlsx', + ], + }, +}) diff --git a/examples/vanilla/LICENSE b/examples/vanilla/LICENSE new file mode 100644 index 0000000..770f53d --- /dev/null +++ b/examples/vanilla/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2026 Aria Quinlan + +This software is provided ‘as-is’, without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not +claim that you wrote the original software. If you use this software +in a product, an acknowledgment in the product documentation would be +appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not be +misrepresented as being the original software. + +3. This notice may not be removed or altered from any source +distribution. \ No newline at end of file diff --git a/examples/vanilla/build.js b/examples/vanilla/build.js new file mode 100644 index 0000000..3fb07db --- /dev/null +++ b/examples/vanilla/build.js @@ -0,0 +1,99 @@ +/** + * Minimal esbuild build script — replaces Vite entirely. + * + * The only "magic" is an esbuild plugin that rewrites the variable-based + * dynamic `import(moduleName)` in @docview/core's `requirePeerDep` into + * static import() calls that esbuild can bundle. + * + * This is the same fundamental fix needed in Vite, webpack, or any other + * bundler — browsers cannot resolve bare specifiers from `import(variable)`. + */ +import { build, context } from 'esbuild' +import { cpSync, existsSync, mkdirSync } from 'fs' +import { resolve, dirname } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const isWatch = process.argv.includes('--watch') + +/** Plugin to resolve @docview/core's dynamic peer-dep imports. */ +const resolvePeerDeps = { + name: 'resolve-peer-deps', + setup(build) { + build.onLoad({ filter: /docview.*\.(js|ts)$/ }, async (args) => { + const fs = await import('fs') + let contents = fs.readFileSync(args.path, 'utf8') + + if (contents.includes('moduleName') && contents.includes('import(')) { + // Map of peer dep names → static imports + const peerDeps = [ + 'pdfjs-dist', 'epubjs', 'docx-preview', + 'papaparse', 'highlight.js', 'jszip', 'xlsx', + ] + const cases = peerDeps + .map(d => ` case '${d}': return import('${d}').then(m => m.default || m);`) + .join('\n') + + const replacement = [ + '(async (name) => { switch(name) {', + cases, + " default: throw new Error(`Unknown peer dep: ${name}`);", + ' }})(moduleName)', + ].join('\n') + + contents = contents.replace( + /await\s+import\(\s*(?:\/\*.*?\*\/\s*)?moduleName\s*\)/g, + `await ${replacement}`, + ) + } + + return { contents, loader: args.path.endsWith('.ts') ? 'ts' : 'js' } + }) + }, +} + +// Ensure dist directory exists +const distDir = resolve(__dirname, 'dist') +if (!existsSync(distDir)) mkdirSync(distDir, { recursive: true }) + +// Copy index.html to dist +cpSync(resolve(__dirname, 'index.html'), resolve(distDir, 'index.html')) + +// Copy styles.css to dist +const stylesPath = resolve(__dirname, '../../packages/core/src/styles.css') +if (existsSync(stylesPath)) { + cpSync(stylesPath, resolve(distDir, 'styles.css')) +} + +// Copy pdfjs worker to dist +const workerGlob = resolve(__dirname, 'node_modules/pdfjs-dist/build') +if (existsSync(workerGlob)) { + const workerFiles = ['pdf.worker.min.mjs', 'pdf.worker.mjs', 'pdf.worker.min.js'] + for (const wf of workerFiles) { + const src = resolve(workerGlob, wf) + if (existsSync(src)) { + cpSync(src, resolve(distDir, wf)) + break + } + } +} + +const buildOptions = { + entryPoints: [resolve(__dirname, 'src/main.ts')], + bundle: true, + format: 'esm', + outdir: distDir, + sourcemap: true, + target: 'es2022', + plugins: [resolvePeerDeps], + logLevel: 'info', +} + +if (isWatch) { + const ctx = await context(buildOptions) + await ctx.watch() + console.log('Watching for changes...') +} else { + await build(buildOptions) + console.log(`\nBuild complete! Serve with:\n npx serve dist\n`) +} diff --git a/examples/vanilla/index.html b/examples/vanilla/index.html new file mode 100644 index 0000000..0b7eca5 --- /dev/null +++ b/examples/vanilla/index.html @@ -0,0 +1,80 @@ + + + + + + DocView — Vanilla TS Example + + + + +

DocView — Vanilla TS Example

+

No Vite. Just TypeScript + esbuild. Pick a file below to view it.

+
+ +
+
+ + + diff --git a/examples/vanilla/package.json b/examples/vanilla/package.json new file mode 100644 index 0000000..7f5de4e --- /dev/null +++ b/examples/vanilla/package.json @@ -0,0 +1,25 @@ +{ + "name": "@docview/example-vanilla", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "node build.js", + "serve": "node build.js && npx -y serve dist", + "dev": "node build.js --watch" + }, + "dependencies": { + "@docview/core": "workspace:*", + "pdfjs-dist": ">=4.0.0", + "epubjs": ">=0.3.0", + "docx-preview": ">=0.3.0", + "papaparse": ">=5.0.0", + "highlight.js": ">=11.0.0", + "jszip": ">=3.0.0", + "xlsx": ">=0.18.0" + }, + "devDependencies": { + "typescript": "^5.5.0", + "esbuild": "^0.25.0" + } +} diff --git a/examples/vanilla/src/main.ts b/examples/vanilla/src/main.ts new file mode 100644 index 0000000..98a76c8 --- /dev/null +++ b/examples/vanilla/src/main.ts @@ -0,0 +1,38 @@ +import { DocView } from '@docview/core' + +const viewerEl = document.getElementById('viewer')! +const fileInput = document.getElementById('file-input') as HTMLInputElement + +let viewer: DocView | null = null + +fileInput.addEventListener('change', () => { + const file = fileInput.files?.[0] + if (!file) return + + // Destroy previous viewer if one exists + if (viewer) { + viewer.destroy() + viewer = null + } + + // Create a new DocView instance with the selected file + viewer = new DocView(viewerEl, { + source: { + type: 'file', + data: file, + filename: file.name, + }, + theme: 'dark', + toolbar: true, + pdf: { + // In the bundled output, the worker is a sibling file in dist/ + workerSrc: './pdf.worker.min.mjs', + }, + onReady: (info) => { + console.log(`Loaded "${file.name}" — ${info.pageCount} page(s), format: ${info.format}`) + }, + onError: (err) => { + console.error('DocView error:', err) + }, + }) +}) diff --git a/examples/vanilla/tsconfig.json b/examples/vanilla/tsconfig.json new file mode 100644 index 0000000..200adeb --- /dev/null +++ b/examples/vanilla/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "noEmit": true + }, + "include": ["src"] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0a7146d --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "docview-monorepo", + "private": true, + "version": "0.0.0", + "description": "A framework-agnostic, universal document renderer with optional chunked loading", + "scripts": { + "build": "pnpm -r build", + "dev": "pnpm -r --parallel dev", + "clean": "pnpm -r clean", + "lint": "pnpm -r lint", + "typecheck": "pnpm -r typecheck" + }, + "devDependencies": { + "typescript": "^5.5.0" + }, + "engines": { + "node": ">=18", + "pnpm": ">=9" + } +} \ No newline at end of file diff --git a/packages/core/LICENSE b/packages/core/LICENSE new file mode 100644 index 0000000..770f53d --- /dev/null +++ b/packages/core/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2026 Aria Quinlan + +This software is provided ‘as-is’, without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not +claim that you wrote the original software. If you use this software +in a product, an acknowledgment in the product documentation would be +appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not be +misrepresented as being the original software. + +3. This notice may not be removed or altered from any source +distribution. \ No newline at end of file diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..b68367e --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,80 @@ +{ + "name": "@docview/core", + "version": "0.1.0", + "description": "Framework-agnostic universal document renderer — PDF, EPUB, DOCX, CSV, code, and plain text with optional chunked/paged loading", + "license": "Zlib", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + }, + "./styles.css": "./dist/styles.css" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "sideEffects": [ + "*.css" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "tsup": "^8.0.0", + "typescript": "^5.5.0" + }, + "peerDependencies": { + "pdfjs-dist": ">=4.0.0", + "epubjs": ">=0.3.0", + "docx-preview": ">=0.3.0", + "papaparse": ">=5.0.0", + "highlight.js": ">=11.0.0", + "jszip": ">=3.0.0", + "xlsx": ">=0.18.0" + }, + "peerDependenciesMeta": { + "pdfjs-dist": { + "optional": true + }, + "epubjs": { + "optional": true + }, + "docx-preview": { + "optional": true + }, + "papaparse": { + "optional": true + }, + "highlight.js": { + "optional": true + }, + "jszip": { + "optional": true + }, + "xlsx": { + "optional": true + } + }, + "keywords": [ + "pdf", + "epub", + "docx", + "csv", + "document", + "viewer", + "renderer", + "reader", + "chunked", + "framework-agnostic" + ] +} \ No newline at end of file diff --git a/packages/core/src/docview.ts b/packages/core/src/docview.ts new file mode 100644 index 0000000..c42f8fb --- /dev/null +++ b/packages/core/src/docview.ts @@ -0,0 +1,308 @@ +import type { + DocViewOptions, + DocViewState, + DocumentFormat, + Renderer, + RendererFactory, + DocViewEventMap, + DocViewEventType, + ToolbarConfig, +} from './types.js' +import { DocViewError } from './types.js' +import { registry } from './registry.js' +import { detectFormat, getRendererFormat, clearElement } from './utils.js' +import { createToolbar, type ToolbarHandle } from './toolbar.js' +import { registerBuiltinRenderers } from './renderers/index.js' + +// Register built-in renderers on first import +let registered = false +function ensureRegistered() { + if (!registered) { + registerBuiltinRenderers() + registered = true + } +} + +/** + * DocView — Universal Document Viewer + * + * Framework-agnostic entry point. Creates a document viewer inside a container + * element, auto-detecting the format and loading the appropriate renderer. + * + * @example + * ```ts + * import { DocView } from '@docview/core' + * import '@docview/core/styles.css' + * + * const viewer = new DocView(document.getElementById('viewer')!, { + * source: { type: 'url', url: '/document.pdf' }, + * theme: 'dark', + * onReady: (info) => console.log('Loaded:', info.pageCount, 'pages'), + * }) + * + * // Navigate + * viewer.goToPage(5) + * + * // Clean up + * viewer.destroy() + * ``` + */ +export class DocView { + private container: HTMLElement + private options: DocViewOptions + private renderer: Renderer | null = null + private toolbar: ToolbarHandle | null = null + private root: HTMLElement + private listeners = new Map void>>() + private destroyed = false + + constructor(container: HTMLElement, options: DocViewOptions) { + ensureRegistered() + + this.container = container + this.options = { ...options } + + // Create root element + this.root = document.createElement('div') + this.root.className = `docview${options.className ? ` ${options.className}` : ''}` + this.root.setAttribute('data-theme', this.resolveTheme(options.theme)) + container.appendChild(this.root) + + // Initialize asynchronously + this.init().catch((err) => { + const error = err instanceof DocViewError + ? err + : new DocViewError('UNKNOWN', String(err), err) + options.onError?.(error) + this.emit('error', error) + }) + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /** Navigate to a specific page (1-indexed). */ + goToPage(page: number): void { + this.renderer?.goToPage(page) + this.updateToolbar() + } + + /** Get the current page number. */ + getCurrentPage(): number { + return this.renderer?.getCurrentPage() ?? 1 + } + + /** Get the total page count. */ + getPageCount(): number { + return this.renderer?.getPageCount() ?? 0 + } + + /** Set zoom level. */ + setZoom(zoom: number | 'fit-width' | 'fit-page'): void { + this.renderer?.setZoom(zoom) + this.updateToolbar() + } + + /** Get current zoom as a numeric scale. */ + getZoom(): number { + return this.renderer?.getZoom() ?? 1 + } + + /** Get current viewer state. */ + getState(): DocViewState { + if (!this.renderer) { + return { + loading: true, + error: null, + currentPage: 1, + totalPages: 0, + zoom: 1, + documentInfo: null, + } + } + return { + loading: false, + error: null, + currentPage: this.renderer.getCurrentPage(), + totalPages: this.renderer.getPageCount(), + zoom: this.renderer.getZoom(), + documentInfo: null, // Would need to store from onReady + } + } + + /** Update options (theme, zoom, etc.) without re-mounting. */ + async update(changed: Partial): Promise { + Object.assign(this.options, changed) + + if (changed.theme) { + this.root.setAttribute('data-theme', this.resolveTheme(changed.theme)) + } + if (changed.className !== undefined) { + this.root.className = `docview${changed.className ? ` ${changed.className}` : ''}` + } + + await this.renderer?.update(changed) + } + + /** Subscribe to events. Returns an unsubscribe function. */ + on( + event: K, + callback: (data: DocViewEventMap[K]) => void, + ): () => void { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()) + } + const cb = callback as (data: unknown) => void + this.listeners.get(event)!.add(cb) + return () => this.listeners.get(event)?.delete(cb) + } + + /** Destroy the viewer and clean up all resources. */ + destroy(): void { + if (this.destroyed) return + this.destroyed = true + + this.toolbar?.destroy() + this.renderer?.destroy() + this.root.remove() + this.listeners.clear() + this.emit('destroy', undefined as never) + } + + /** Register a custom renderer for a format. */ + static registerRenderer(format: DocumentFormat, factory: RendererFactory): void { + ensureRegistered() + registry.register(format, factory) + } + + /** Get all registered format names. */ + static getFormats(): DocumentFormat[] { + ensureRegistered() + return registry.formats() + } + + // --------------------------------------------------------------------------- + // Internal + // --------------------------------------------------------------------------- + + private async init(): Promise { + // Detect format + const explicitFormat = this.options.format + const detectedFormat = detectFormat(this.options.source) + const format = explicitFormat ?? detectedFormat + + if (!format) { + throw new DocViewError( + 'FORMAT_DETECTION_FAILED', + 'Could not detect the document format. Provide a `format` option or ensure ' + + 'the source has a recognizable filename, URL extension, or MIME type.', + ) + } + + // Resolve renderer format (e.g., 'markdown' -> 'code', 'tsv' -> 'csv') + const rendererFormat = getRendererFormat(format) + + // Create renderer + const renderer = registry.create(rendererFormat) + if (!renderer) { + throw new DocViewError( + 'FORMAT_UNSUPPORTED', + `No renderer registered for format "${rendererFormat}". ` + + `Available formats: ${registry.formats().join(', ')}`, + ) + } + + this.renderer = renderer + + // Wire up options callbacks to also emit events + const originalOnReady = this.options.onReady + this.options.onReady = (info) => { + originalOnReady?.(info) + this.emit('ready', info) + this.updateToolbar() + } + + const originalOnPageChange = this.options.onPageChange + this.options.onPageChange = (page, total) => { + originalOnPageChange?.(page, total) + this.emit('pagechange', { page, totalPages: total }) + this.updateToolbar() + } + + const originalOnZoomChange = this.options.onZoomChange + this.options.onZoomChange = (zoom) => { + originalOnZoomChange?.(zoom) + this.emit('zoomchange', { zoom }) + this.updateToolbar() + } + + const originalOnError = this.options.onError + this.options.onError = (err) => { + originalOnError?.(err) + this.emit('error', err) + } + + // Create toolbar (before renderer mount, so it appears above the viewport) + const toolbarOpt = this.options.toolbar + if (toolbarOpt !== false) { + const config: ToolbarConfig = toolbarOpt === true || toolbarOpt === undefined + ? {} // Default config + : toolbarOpt + + this.toolbar = createToolbar(config, { + onPrevPage: () => this.goToPage(this.getCurrentPage() - 1), + onNextPage: () => this.goToPage(this.getCurrentPage() + 1), + onPageInput: (p) => this.goToPage(p), + onZoomIn: () => this.setZoom(this.getZoom() * 1.2), + onZoomOut: () => this.setZoom(this.getZoom() / 1.2), + onFitWidth: () => this.setZoom('fit-width'), + onFullscreen: () => this.toggleFullscreen(), + }, this.getState()) + + if (config.position === 'bottom') { + this.root.appendChild(this.toolbar.element) + } else { + this.root.insertBefore(this.toolbar.element, this.root.firstChild) + } + } + + // Create renderer container + const rendererContainer = document.createElement('div') + rendererContainer.style.display = 'contents' + this.root.appendChild(rendererContainer) + + // Mount renderer + await renderer.mount(rendererContainer, this.options) + } + + private resolveTheme(theme?: 'light' | 'dark' | 'system'): string { + if (theme === 'system') { + return window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light' + } + return theme ?? 'dark' + } + + private updateToolbar(): void { + this.toolbar?.updateState(this.getState()) + } + + private toggleFullscreen(): void { + if (document.fullscreenElement === this.root) { + document.exitFullscreen() + } else { + this.root.requestFullscreen?.() + } + } + + private emit(event: K, data: DocViewEventMap[K]): void { + const callbacks = this.listeners.get(event) + if (callbacks) { + for (const cb of callbacks) { + try { cb(data) } catch { /* swallow listener errors */ } + } + } + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..f93f80b --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,83 @@ +// Main entry point +export { DocView } from './docview.js' + +// Types +export type { + // Sources + DocumentSource, + FileSource, + UrlSource, + PagesSource, + ChunkedSource, + + // Data + PageData, + ChunkData, + TextLayerData, + TextItem, + + // Fetch Adapters + PageFetchAdapter, + ChunkFetchAdapter, + TextFetchAdapter, + + // Options + DocViewOptions, + PdfOptions, + CodeOptions, + CsvOptions, + EpubOptions, + OdtOptions, + OdsOptions, + ToolbarConfig, + + // State & Info + DocumentInfo, + DocViewState, + DocumentFormat, + + // Renderer interface (for custom renderers) + Renderer, + RendererFactory, + + // Events + DocViewEventMap, + DocViewEventType, + + // Errors + DocViewErrorCode, +} from './types.js' + +export { DocViewError } from './types.js' + +// Registry (for custom renderer registration) +export { registry } from './registry.js' + +// Base renderer (for building custom renderers) +export { BaseRenderer } from './renderer.js' + +// Built-in renderers (for direct use or extension) +export { + PdfRenderer, + BrowsePagesRenderer, + ChunkedPdfRenderer, + EpubRenderer, + DocxRenderer, + OdtRenderer, + OdsRenderer, + CsvRenderer, + CodeRenderer, + TextRenderer, +} from './renderers/index.js' + +// Utilities (for custom renderer authors) +export { + detectFormat, + getRendererFormat, + getLanguageFromExtension, + getExtension, + toArrayBuffer, + toBlob, + toText, + fetchAsBuffer, +} from './utils.js' diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts new file mode 100644 index 0000000..d664cbf --- /dev/null +++ b/packages/core/src/registry.ts @@ -0,0 +1,39 @@ +import type { DocumentFormat, Renderer, RendererFactory } from './types.js' + +/** + * Registry mapping document formats to their renderer factories. + * Built-in renderers are registered by default. Consumers can register + * custom renderers for new formats via `DocView.registerRenderer()`. + */ +class FormatRegistry { + private factories = new Map() + + /** Register a renderer factory for a format. Overwrites any existing registration. */ + register(format: DocumentFormat, factory: RendererFactory): void { + this.factories.set(format, factory) + } + + /** Create a renderer for the given format. Returns null if no renderer is registered. */ + create(format: DocumentFormat): Renderer | null { + const factory = this.factories.get(format) + return factory ? factory() : null + } + + /** Check if a renderer is registered for the given format. */ + has(format: DocumentFormat): boolean { + return this.factories.has(format) + } + + /** Get all registered format names. */ + formats(): DocumentFormat[] { + return [...this.factories.keys()] + } + + /** Remove a renderer registration. */ + unregister(format: DocumentFormat): void { + this.factories.delete(format) + } +} + +/** Singleton format registry. */ +export const registry = new FormatRegistry() diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts new file mode 100644 index 0000000..e09c35d --- /dev/null +++ b/packages/core/src/renderer.ts @@ -0,0 +1,167 @@ +import type { + Renderer, + DocViewOptions, + DocumentFormat, + DocumentInfo, + DocViewState, +} from './types.js' +import { DocViewError } from './types.js' +import { el, clearElement } from './utils.js' + +/** + * Abstract base class for format renderers. Provides common state management, + * DOM scaffolding, and helper methods. Concrete renderers extend this and + * implement the abstract methods. + * + * Subclasses must implement: + * - `onMount(viewport, options)` — render content into the viewport element + * - `onDestroy()` — clean up format-specific resources + * - `format` getter + */ +export abstract class BaseRenderer implements Renderer { + abstract readonly format: DocumentFormat + + protected container!: HTMLElement + protected viewport!: HTMLElement + protected options!: DocViewOptions + protected state: DocViewState = { + loading: true, + error: null, + currentPage: 1, + totalPages: 1, + zoom: 1, + documentInfo: null, + } + + async mount(container: HTMLElement, options: DocViewOptions): Promise { + this.container = container + this.options = options + this.state.currentPage = options.initialPage ?? 1 + + // Create viewport element + this.viewport = el('div', 'dv-viewport') + this.viewport.setAttribute('role', 'document') + container.appendChild(this.viewport) + + // Delegate to subclass + try { + await this.onMount(this.viewport, options) + } catch (err) { + const error = err instanceof DocViewError + ? err + : new DocViewError('RENDER_FAILED', String(err), err) + this.state.error = error + this.state.loading = false + this.showError(error) + throw error + } + } + + async update(changed: Partial): Promise { + Object.assign(this.options, changed) + await this.onUpdate(changed) + } + + goToPage(page: number): void { + const clamped = Math.max(1, Math.min(page, this.state.totalPages)) + if (clamped === this.state.currentPage) return + this.state.currentPage = clamped + this.onPageChange(clamped) + this.options.onPageChange?.(clamped, this.state.totalPages) + } + + getPageCount(): number { + return this.state.totalPages + } + + getCurrentPage(): number { + return this.state.currentPage + } + + setZoom(zoom: number | 'fit-width' | 'fit-page'): void { + const resolved = typeof zoom === 'number' + ? zoom + : this.resolveZoomMode(zoom) + this.state.zoom = resolved + this.onZoomChange(resolved) + this.options.onZoomChange?.(resolved) + } + + getZoom(): number { + return this.state.zoom + } + + destroy(): void { + this.onDestroy() + clearElement(this.container) + } + + // --- Subclass hooks --- + + /** Render the document into the viewport. */ + protected abstract onMount(viewport: HTMLElement, options: DocViewOptions): Promise + + /** Clean up format-specific resources. */ + protected abstract onDestroy(): void + + /** React to option changes. Default: no-op. */ + protected async onUpdate(_changed: Partial): Promise {} + + /** Navigate to a page in the rendered content. Default: no-op. */ + protected onPageChange(_page: number): void {} + + /** Apply a zoom change. Default: no-op. */ + protected onZoomChange(_zoom: number): void {} + + // --- Helpers available to subclasses --- + + /** Resolve 'fit-width' or 'fit-page' to a numeric scale based on viewport size. */ + protected resolveZoomMode(_mode: 'fit-width' | 'fit-page'): number { + // Default implementation — subclasses with page dimensions override this + return 1 + } + + /** Show a loading spinner in the viewport. */ + protected showLoading(message = 'Loading document…'): HTMLElement { + const loading = el('div', 'dv-loading') + loading.innerHTML = `
${message}` + this.viewport.appendChild(loading) + this.state.loading = true + this.options.onLoadingChange?.(true) + return loading + } + + /** Remove loading state. */ + protected hideLoading(): void { + const loading = this.viewport.querySelector('.dv-loading') + if (loading) loading.remove() + this.state.loading = false + this.options.onLoadingChange?.(false) + } + + /** Show an error message in the viewport. */ + protected showError(error: DocViewError): void { + clearElement(this.viewport) + const errorEl = el('div', 'dv-error') + errorEl.innerHTML = ` +
${error.code}
+
${error.message}
+ ` + this.viewport.appendChild(errorEl) + } + + /** Mark the document as ready and fire the onReady callback. */ + protected setReady(info: DocumentInfo): void { + this.state.documentInfo = info + this.state.totalPages = info.pageCount + this.state.loading = false + this.hideLoading() + this.options.onReady?.(info) + this.options.onLoadingChange?.(false) + } + + /** Fire page change callback (call after updating state.currentPage). */ + protected emitPageChange(): void { + this.options.onPageChange?.(this.state.currentPage, this.state.totalPages) + } +} diff --git a/packages/core/src/renderers/browse-pages.ts b/packages/core/src/renderers/browse-pages.ts new file mode 100644 index 0000000..d18dc33 --- /dev/null +++ b/packages/core/src/renderers/browse-pages.ts @@ -0,0 +1,268 @@ +import type { + DocViewOptions, + DocumentFormat, + PageData, + PageFetchAdapter, + PagesSource, + TextLayerData, + TextFetchAdapter, +} from '../types.js' +import { DocViewError } from '../types.js' +import { BaseRenderer } from '../renderer.js' +import { el, clamp, debounce } from '../utils.js' + +/** + * Renders pre-rendered page images (WebP, JPEG, PNG) with lazy loading, + * scroll-based page tracking, and optional text layer overlay. + * + * This is the primary viewer for chunked documents in "browse" mode — + * fast, lightweight, works on mobile. + */ +export class BrowsePagesRenderer extends BaseRenderer { + readonly format: DocumentFormat = 'pages' + + private pagesContainer!: HTMLElement + private pageElements: HTMLElement[] = [] + private pages: PageData[] = [] + private fetchAdapter: PageFetchAdapter | null = null + private textData: TextLayerData[] | null = null + private textFetcher: TextFetchAdapter | null = null + private loadedPages = new Set() + private observer: IntersectionObserver | null = null + private debouncedScroll: ReturnType | null = null + + protected async onMount(viewport: HTMLElement, options: DocViewOptions): Promise { + const loadingEl = this.showLoading('Loading pages…') + const source = options.source as PagesSource + + // Resolve pages — direct data or fetch adapter + if (Array.isArray(source.pages)) { + this.pages = source.pages.sort((a, b) => a.pageNumber - b.pageNumber) + } else { + this.fetchAdapter = source.pages + } + + // Resolve text layer + if (source.textLayer) { + if (Array.isArray(source.textLayer)) { + this.textData = source.textLayer + } else { + this.textFetcher = source.textLayer + } + } + + const totalPages = this.fetchAdapter?.totalPages ?? this.pages.length + if (totalPages === 0) { + throw new DocViewError('RENDER_FAILED', 'No pages provided.') + } + + // Create pages container + this.pagesContainer = el('div', 'dv-pages') + viewport.appendChild(this.pagesContainer) + + // Create placeholder elements + for (let i = 1; i <= totalPages; i++) { + const pageEl = el('div', 'dv-page dv-browse-page') + pageEl.dataset.page = String(i) + + // Set placeholder dimensions from direct data if available + const pageData = this.pages.find((p) => p.pageNumber === i) + if (pageData) { + const width = pageData.width * this.state.zoom + const height = pageData.height * this.state.zoom + this.setPageSize(pageEl, width, height) + this.createPlaceholder(pageEl, width, height) + } else { + // Default A4-ish placeholder + this.setPageSize(pageEl, 595 * this.state.zoom, 842 * this.state.zoom) + this.createPlaceholder(pageEl, 595 * this.state.zoom, 842 * this.state.zoom) + } + + 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 pageNum = parseInt((entry.target as HTMLElement).dataset.page!, 10) + this.loadPage(pageNum) + } + } + }, + { root: viewport, rootMargin: '100% 0px' }, + ) + + for (const pageEl of this.pageElements) { + this.observer.observe(pageEl) + } + + // Scroll tracking + this.debouncedScroll = debounce(() => this.updateCurrentPage(), 100) + viewport.addEventListener('scroll', this.debouncedScroll) + + this.setReady({ + format: 'pages', + pageCount: totalPages, + }) + + if (options.initialPage && options.initialPage > 1) { + this.goToPage(options.initialPage) + } + } + + private setPageSize(pageEl: HTMLElement, width: number, height: number): void { + pageEl.style.width = `${width}px` + pageEl.style.height = `${height}px` + } + + private createPlaceholder(pageEl: HTMLElement, width: number, height: number): void { + const placeholder = el('div', 'dv-browse-page-placeholder') + placeholder.style.width = `${width}px` + placeholder.style.height = `${height}px` + placeholder.textContent = pageEl.dataset.page || '' + pageEl.appendChild(placeholder) + } + + private async loadPage(pageNum: number): Promise { + if (this.loadedPages.has(pageNum)) return + this.loadedPages.add(pageNum) + + const pageEl = this.pageElements[pageNum - 1] + if (!pageEl) return + + try { + // Get page data + let pageData: PageData | undefined + if (this.fetchAdapter) { + pageData = await this.fetchAdapter.fetchPage(pageNum) + } else { + pageData = this.pages.find((p) => p.pageNumber === pageNum) + } + + if (!pageData) { + this.loadedPages.delete(pageNum) + return + } + + // Create image + const img = document.createElement('img') + img.alt = `Page ${pageNum}` + img.loading = 'lazy' + img.draggable = false + + if (pageData.imageUrl) { + img.src = pageData.imageUrl + } else if (pageData.imageBlob) { + img.src = URL.createObjectURL(pageData.imageBlob) + img.addEventListener('load', () => URL.revokeObjectURL(img.src), { once: true }) + } + + const width = pageData.width * this.state.zoom + const height = pageData.height * this.state.zoom + img.style.width = `${width}px` + img.style.height = `${height}px` + + this.setPageSize(pageEl, width, height) + pageEl.innerHTML = '' + pageEl.appendChild(img) + + // Text layer overlay + await this.addTextLayer(pageEl, pageNum, pageData) + } catch { + this.loadedPages.delete(pageNum) + } + } + + private async addTextLayer( + pageEl: HTMLElement, + pageNum: number, + pageData: PageData, + ): Promise { + let textLayerData: TextLayerData | undefined + + if (this.textData) { + textLayerData = this.textData.find((t) => t.pageNumber === pageNum) + } else if (this.textFetcher) { + try { + textLayerData = await this.textFetcher.fetchPageText(pageNum) + } catch { + return + } + } + + if (!textLayerData?.items.length) return + + const layer = el('div', 'dv-text-layer') + for (const item of textLayerData.items) { + const span = document.createElement('span') + span.textContent = item.str + span.style.left = `${item.x * 100}%` + span.style.top = `${item.y * 100}%` + span.style.width = `${item.width * 100}%` + span.style.height = `${item.height * 100}%` + if (item.fontSize) { + span.style.fontSize = `${item.fontSize * this.state.zoom}px` + } + layer.appendChild(span) + } + pageEl.appendChild(layer) + } + + private updateCurrentPage(): void { + const viewportRect = this.viewport.getBoundingClientRect() + const viewportMid = viewportRect.top + viewportRect.height / 2 + + for (let i = 0; i < this.pageElements.length; i++) { + const rect = this.pageElements[i].getBoundingClientRect() + if (rect.top <= viewportMid && rect.bottom >= viewportMid) { + const newPage = i + 1 + if (newPage !== this.state.currentPage) { + this.state.currentPage = newPage + this.emitPageChange() + } + return + } + } + } + + protected onPageChange(page: number): void { + const pageEl = this.pageElements[page - 1] + if (pageEl) { + pageEl.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + } + + protected onZoomChange(zoom: number): void { + this.state.zoom = clamp(zoom, 0.25, 5) + // Re-render loaded pages at new size + for (let i = 0; i < this.pageElements.length; i++) { + const pageEl = this.pageElements[i] + const pageData = this.pages[i] + if (pageData) { + const w = pageData.width * this.state.zoom + const h = pageData.height * this.state.zoom + this.setPageSize(pageEl, w, h) + const img = pageEl.querySelector('img') + if (img) { + img.style.width = `${w}px` + img.style.height = `${h}px` + } + } + } + } + + protected onDestroy(): void { + this.observer?.disconnect() + this.debouncedScroll?.cancel() + this.fetchAdapter?.dispose?.() + this.textFetcher?.dispose?.() + this.pageElements = [] + this.pages = [] + this.loadedPages.clear() + } +} diff --git a/packages/core/src/renderers/chunked-pdf.ts b/packages/core/src/renderers/chunked-pdf.ts new file mode 100644 index 0000000..828cd25 --- /dev/null +++ b/packages/core/src/renderers/chunked-pdf.ts @@ -0,0 +1,314 @@ +import type { + DocViewOptions, + DocumentFormat, + ChunkedSource, + ChunkData, + ChunkFetchAdapter, + PageFetchAdapter, +} from '../types.js' +import { DocViewError } from '../types.js' +import { BaseRenderer } from '../renderer.js' +import { el, clamp, debounce, requirePeerDep, toArrayBuffer } from '../utils.js' + +/** + * Streams a large PDF by loading independent PDF chunks on demand. + * Optionally displays pre-rendered browse page images as fast placeholders + * while the full-fidelity PDF chunks load in the background. + * + * Flow: + * 1. Show browse page images immediately (if available) + * 2. When user views a page range, fetch the PDF chunk covering those pages + * 3. Replace the browse image with the pdf.js-rendered canvas + * 4. Prefetch adjacent chunks in the background + */ +export class ChunkedPdfRenderer extends BaseRenderer { + readonly format: DocumentFormat = 'chunked-pdf' + + private pagesContainer!: HTMLElement + private pageElements: HTMLElement[] = [] + private totalPages = 0 + + // Chunk management + private chunks: ChunkData[] = [] + private chunkAdapter: ChunkFetchAdapter | null = null + private loadedChunks = new Set() + private loadingChunks = new Set() + + // Browse page fallback + private browseAdapter: PageFetchAdapter | null = null + private browsePages: Map = new Map() // pageNum -> objectURL + + private observer: IntersectionObserver | null = null + private debouncedScroll: ReturnType | null = null + private pdfjsLib: unknown = null + + protected async onMount(viewport: HTMLElement, options: DocViewOptions): Promise { + const loadingEl = this.showLoading('Loading document…') + + const source = options.source as ChunkedSource + this.totalPages = source.totalPages + + // Resolve chunk source + if (Array.isArray(source.chunks)) { + this.chunks = source.chunks.sort((a, b) => a.pageStart - b.pageStart) + } else { + this.chunkAdapter = source.chunks + } + + // Resolve browse pages (optional fast fallback) + if (source.browsePages && !Array.isArray(source.browsePages)) { + this.browseAdapter = source.browsePages + } + + // Try to load pdfjs for chunk rendering + try { + this.pdfjsLib = await requirePeerDep('pdfjs-dist', 'PDF') + } catch { + // If pdfjs not available, we can still show browse pages + if (!this.browseAdapter && !source.browsePages) { + throw new DocViewError( + 'PEER_DEPENDENCY_MISSING', + 'Chunked PDF rendering requires pdfjs-dist. Install it with: npm install pdfjs-dist', + ) + } + } + + // Create pages container + this.pagesContainer = el('div', 'dv-pages') + viewport.appendChild(this.pagesContainer) + + // Create page placeholders + for (let i = 1; i <= this.totalPages; i++) { + const pageEl = el('div', 'dv-page dv-browse-page') + pageEl.dataset.page = String(i) + // Default A4 placeholder + const w = 595 * this.state.zoom + const h = 842 * this.state.zoom + pageEl.style.width = `${w}px` + pageEl.style.height = `${h}px` + + const placeholder = el('div', 'dv-browse-page-placeholder') + placeholder.style.width = `${w}px` + placeholder.style.height = `${h}px` + placeholder.textContent = String(i) + pageEl.appendChild(placeholder) + + this.pageElements.push(pageEl) + this.pagesContainer.appendChild(pageEl) + } + + loadingEl.remove() + + // Lazy loading via intersection observer + this.observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + const pageNum = parseInt((entry.target as HTMLElement).dataset.page!, 10) + this.loadPageContent(pageNum) + } + } + }, + { root: viewport, rootMargin: '150% 0px' }, + ) + + for (const pageEl of this.pageElements) { + this.observer.observe(pageEl) + } + + // Scroll tracking + this.debouncedScroll = debounce(() => this.updateCurrentPage(), 100) + viewport.addEventListener('scroll', this.debouncedScroll) + + this.setReady({ + format: 'chunked-pdf', + pageCount: this.totalPages, + }) + + if (options.initialPage && options.initialPage > 1) { + this.goToPage(options.initialPage) + } + } + + /** + * Load content for a page. Strategy: + * 1. Show browse page image immediately (fast) + * 2. Load the PDF chunk in background (slow, high quality) + * 3. Replace image with PDF canvas when chunk is ready + */ + private async loadPageContent(pageNum: number): Promise { + // Step 1: Show browse page quickly + if (this.browseAdapter && !this.browsePages.has(pageNum)) { + try { + const pageData = await this.browseAdapter.fetchPage(pageNum) + const pageEl = this.pageElements[pageNum - 1] + if (pageEl && pageData) { + const img = document.createElement('img') + img.alt = `Page ${pageNum}` + img.draggable = false + + if (pageData.imageUrl) { + img.src = pageData.imageUrl + } else if (pageData.imageBlob) { + const url = URL.createObjectURL(pageData.imageBlob) + img.src = url + this.browsePages.set(pageNum, url) + } + + const w = pageData.width * this.state.zoom + const h = pageData.height * this.state.zoom + img.style.width = `${w}px` + img.style.height = `${h}px` + pageEl.style.width = `${w}px` + pageEl.style.height = `${h}px` + pageEl.innerHTML = '' + pageEl.appendChild(img) + } + } catch { + // Browse page load failed — non-fatal, will try PDF chunk + } + } + + // Step 2: Load PDF chunk for this page (if pdfjs available) + if (this.pdfjsLib) { + const chunkIndex = this.getChunkIndexForPage(pageNum) + if (chunkIndex >= 0 && !this.loadedChunks.has(chunkIndex) && !this.loadingChunks.has(chunkIndex)) { + this.loadingChunks.add(chunkIndex) + try { + await this.loadAndRenderChunk(chunkIndex) + this.loadedChunks.add(chunkIndex) + } catch { + // Chunk load failed — browse images remain as fallback + } finally { + this.loadingChunks.delete(chunkIndex) + } + } + } + } + + private getChunkIndexForPage(pageNum: number): number { + if (this.chunkAdapter) { + return this.chunkAdapter.getChunkIndexForPage(pageNum) + } + return this.chunks.findIndex( + (c) => pageNum >= c.pageStart && pageNum <= c.pageEnd, + ) + } + + private async loadAndRenderChunk(chunkIndex: number): Promise { + let chunkData: ChunkData + if (this.chunkAdapter) { + chunkData = await this.chunkAdapter.fetchChunk(chunkIndex) + } else { + chunkData = this.chunks[chunkIndex] + } + if (!chunkData) return + + const pdfjsLib = this.pdfjsLib as { + getDocument(params: { data: ArrayBuffer }): { promise: Promise<{ + numPages: number + getPage(n: number): Promise<{ + getViewport(p: { scale: number }): { width: number; height: number } + render(p: { canvasContext: CanvasRenderingContext2D; viewport: { width: number; height: number } }): { promise: Promise } + cleanup(): void + }> + destroy(): void + }> } + } + + const data = await toArrayBuffer(chunkData.data instanceof Blob ? chunkData.data : chunkData.data) + const pdf = await pdfjsLib.getDocument({ data }).promise + + // Render each page in the chunk + for (let localPage = 1; localPage <= pdf.numPages; localPage++) { + const globalPage = chunkData.pageStart + localPage - 1 + if (globalPage > this.totalPages) break + + const page = await pdf.getPage(localPage) + const viewport = page.getViewport({ scale: this.state.zoom }) + const pageEl = this.pageElements[globalPage - 1] + if (!pageEl) continue + + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d')! + const dpr = window.devicePixelRatio || 1 + canvas.width = viewport.width * dpr + canvas.height = viewport.height * dpr + canvas.style.width = `${viewport.width}px` + canvas.style.height = `${viewport.height}px` + ctx.scale(dpr, dpr) + + await page.render({ canvasContext: ctx, viewport }).promise + page.cleanup() + + // Replace browse image with canvas + pageEl.style.width = `${viewport.width}px` + pageEl.style.height = `${viewport.height}px` + pageEl.innerHTML = '' + pageEl.appendChild(canvas) + + // Revoke browse page object URL if we had one + const browseUrl = this.browsePages.get(globalPage) + if (browseUrl) { + URL.revokeObjectURL(browseUrl) + this.browsePages.delete(globalPage) + } + } + + pdf.destroy() + } + + private updateCurrentPage(): void { + const viewportRect = this.viewport.getBoundingClientRect() + const viewportMid = viewportRect.top + viewportRect.height / 2 + + for (let i = 0; i < this.pageElements.length; i++) { + const rect = this.pageElements[i].getBoundingClientRect() + if (rect.top <= viewportMid && rect.bottom >= viewportMid) { + const newPage = i + 1 + if (newPage !== this.state.currentPage) { + this.state.currentPage = newPage + this.emitPageChange() + } + return + } + } + } + + protected onPageChange(page: number): void { + const pageEl = this.pageElements[page - 1] + if (pageEl) { + pageEl.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + } + + protected onZoomChange(zoom: number): void { + this.state.zoom = clamp(zoom, 0.25, 5) + // Would need to re-render — simplified: just rescale existing elements + for (const pageEl of this.pageElements) { + const canvas = pageEl.querySelector('canvas') + const img = pageEl.querySelector('img') + if (canvas || img) { + // For a full implementation, re-render at new scale + // For now, CSS transform as fast approximation + pageEl.style.transform = '' + } + } + } + + protected onDestroy(): void { + this.observer?.disconnect() + this.debouncedScroll?.cancel() + this.chunkAdapter?.dispose?.() + this.browseAdapter?.dispose?.() + // Revoke all object URLs + for (const url of this.browsePages.values()) { + URL.revokeObjectURL(url) + } + this.browsePages.clear() + this.pageElements = [] + this.chunks = [] + this.loadedChunks.clear() + this.loadingChunks.clear() + } +} diff --git a/packages/core/src/renderers/code.ts b/packages/core/src/renderers/code.ts new file mode 100644 index 0000000..cd52510 --- /dev/null +++ b/packages/core/src/renderers/code.ts @@ -0,0 +1,146 @@ +import type { DocViewOptions, DocumentFormat } from '../types.js' +import { BaseRenderer } from '../renderer.js' +import { el, toText, fetchAsBuffer, requirePeerDep, getLanguageFromExtension } from '../utils.js' + +interface HighlightJS { + highlight(code: string, options: { language: string }): { value: string } + highlightAuto(code: string): { value: string; language: string } + getLanguage(name: string): unknown +} + +/** + * Renders source code and structured text (JSON, XML, YAML, Markdown, HTML) + * with syntax highlighting via highlight.js, line numbers, and optional word wrap. + * + * Falls back to plain text rendering if highlight.js is not installed. + */ +export class CodeRenderer extends BaseRenderer { + readonly format: DocumentFormat = 'code' + + private codeContainer!: HTMLElement + private hljs: HighlightJS | null = null + + protected async onMount(viewport: HTMLElement, options: DocViewOptions): Promise { + this.showLoading('Loading file…') + + // Try to load highlight.js (optional peer dep) + try { + this.hljs = await requirePeerDep('highlight.js', 'code') + } catch { + this.hljs = null // Fallback to plain text + } + + const text = await this.loadText(options) + this.hideLoading() + + const codeOpts = options.code ?? {} + const showLineNumbers = codeOpts.lineNumbers !== false + const wordWrap = codeOpts.wordWrap === true + + // Detect language + const language = codeOpts.language + ?? this.detectLanguage(options) + ?? undefined + + // Highlight + let highlightedHtml: string + if (this.hljs && language && this.hljs.getLanguage(language)) { + highlightedHtml = this.hljs.highlight(text, { language }).value + } else if (this.hljs) { + const auto = this.hljs.highlightAuto(text) + highlightedHtml = auto.value + } else { + highlightedHtml = this.escapeHtml(text) + } + + // Build DOM + this.codeContainer = el('div', 'dv-code-container') + viewport.appendChild(this.codeContainer) + + const lines = text.split('\n') + + // Line numbers gutter + if (showLineNumbers) { + const gutter = el('div', 'dv-code-gutter') + gutter.setAttribute('aria-hidden', 'true') + for (let i = 1; i <= lines.length; i++) { + const lineNum = el('div', 'dv-code-gutter-line') + lineNum.textContent = String(i) + gutter.appendChild(lineNum) + } + this.codeContainer.appendChild(gutter) + } + + // Code body + const body = el('pre', `dv-code-body${wordWrap ? ' dv-word-wrap' : ''}`) + const codeEl = document.createElement('code') + if (language) codeEl.className = `language-${language}` + codeEl.innerHTML = highlightedHtml + if (codeOpts.tabSize) { + body.style.tabSize = String(codeOpts.tabSize) + } + body.appendChild(codeEl) + this.codeContainer.appendChild(body) + + this.setReady({ + format: 'code', + pageCount: 1, + filename: this.getFilename(options), + }) + } + + private async loadText(options: DocViewOptions): Promise { + const source = options.source + if (source.type === 'file') return toText(source.data) + if (source.type === 'url') { + const buffer = await fetchAsBuffer(source.url, source.fetchOptions) + return new TextDecoder('utf-8').decode(buffer) + } + return '' + } + + private detectLanguage(options: DocViewOptions): string | null { + // From explicit format + const format = options.format + if (format && format !== 'code') { + const map: Record = { + json: 'json', + xml: 'xml', + html: 'html', + markdown: 'markdown', + md: 'markdown', + } + if (map[format]) return map[format] + } + + // From filename + const source = options.source + const name = ('filename' in source ? source.filename : undefined) + ?? (source.type === 'url' ? source.url : undefined) + if (name) { + const lang = getLanguageFromExtension(name) + if (lang) return lang + } + + return null + } + + private escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + } + + private getFilename(options: DocViewOptions): string | undefined { + const source = options.source + if ('filename' in source && source.filename) return source.filename + if (source.type === 'url') return source.url.split('/').pop()?.split('?')[0] + return undefined + } + + protected onDestroy(): void { + this.hljs = null + } +} diff --git a/packages/core/src/renderers/csv.ts b/packages/core/src/renderers/csv.ts new file mode 100644 index 0000000..57782ee --- /dev/null +++ b/packages/core/src/renderers/csv.ts @@ -0,0 +1,180 @@ +import type { DocViewOptions, DocumentFormat } from '../types.js' +import { BaseRenderer } from '../renderer.js' +import { el, toText, fetchAsBuffer, requirePeerDep } from '../utils.js' + +interface PapaParse { + parse(input: string, config?: { + delimiter?: string + header?: boolean + preview?: number + dynamicTyping?: boolean + skipEmptyLines?: boolean + }): { + data: Record[] | string[][] + meta: { fields?: string[]; delimiter: string } + errors: { message: string }[] + } +} + +/** + * Renders CSV and TSV files as a scrollable, sortable table. + * Uses PapaParse for robust parsing and renders a lightweight + * HTML table with sticky headers and striped rows. + */ +export class CsvRenderer extends BaseRenderer { + readonly format: DocumentFormat = 'csv' + + private tableContainer!: HTMLElement + private headers: string[] = [] + private rows: string[][] = [] + private sortCol = -1 + private sortAsc = true + + protected async onMount(viewport: HTMLElement, options: DocViewOptions): Promise { + this.showLoading('Parsing data…') + + const Papa = await requirePeerDep('papaparse', 'CSV') + const text = await this.loadText(options) + + const csvOpts = options.csv ?? {} + const useHeader = csvOpts.header !== false + const maxRows = csvOpts.maxRows ?? 10000 + const sortable = csvOpts.sortable !== false + + const result = Papa.parse(text, { + delimiter: csvOpts.delimiter || undefined, + header: false, // We'll handle headers ourselves for more control + preview: maxRows + 1, // +1 for header row + skipEmptyLines: true, + }) + + const rawRows = result.data as string[][] + if (rawRows.length === 0) { + this.hideLoading() + this.setReady({ format: 'csv', pageCount: 1 }) + viewport.appendChild(el('div', 'dv-loading')) + viewport.querySelector('.dv-loading')!.textContent = 'No data found.' + return + } + + if (useHeader && rawRows.length > 1) { + this.headers = rawRows[0].map((h, i) => h || `Column ${i + 1}`) + this.rows = rawRows.slice(1, maxRows + 1) + } else { + this.headers = rawRows[0].map((_, i) => `Column ${i + 1}`) + this.rows = rawRows.slice(0, maxRows) + } + + this.hideLoading() + + // Build table + this.tableContainer = el('div', 'dv-table-container') + viewport.appendChild(this.tableContainer) + this.renderTable(sortable) + + this.setReady({ + format: 'csv', + pageCount: 1, + filename: this.getFilename(options), + }) + } + + private renderTable(sortable: boolean): void { + const table = el('table', 'dv-table') + + // Header + const thead = document.createElement('thead') + const headerRow = document.createElement('tr') + + // Row number column + const rowNumTh = document.createElement('th') + rowNumTh.className = 'dv-table-row-number' + rowNumTh.textContent = '#' + headerRow.appendChild(rowNumTh) + + this.headers.forEach((header, colIdx) => { + const th = document.createElement('th') + th.textContent = header + if (sortable) { + th.dataset.sortable = 'true' + th.addEventListener('click', () => this.sortByColumn(colIdx)) + if (this.sortCol === colIdx) { + th.textContent = `${header} ${this.sortAsc ? '↑' : '↓'}` + } + } + headerRow.appendChild(th) + }) + thead.appendChild(headerRow) + table.appendChild(thead) + + // Body + const tbody = document.createElement('tbody') + this.rows.forEach((row, rowIdx) => { + const tr = document.createElement('tr') + + // Row number + const rowNumTd = document.createElement('td') + rowNumTd.className = 'dv-table-row-number' + rowNumTd.textContent = String(rowIdx + 1) + tr.appendChild(rowNumTd) + + for (let colIdx = 0; colIdx < this.headers.length; colIdx++) { + const td = document.createElement('td') + td.textContent = row[colIdx] ?? '' + td.title = row[colIdx] ?? '' + tr.appendChild(td) + } + tbody.appendChild(tr) + }) + table.appendChild(tbody) + + this.tableContainer.innerHTML = '' + this.tableContainer.appendChild(table) + } + + private sortByColumn(colIdx: number): void { + if (this.sortCol === colIdx) { + this.sortAsc = !this.sortAsc + } else { + this.sortCol = colIdx + this.sortAsc = true + } + + this.rows.sort((a, b) => { + const va = a[colIdx] ?? '' + const vb = b[colIdx] ?? '' + // Try numeric comparison + const na = parseFloat(va) + const nb = parseFloat(vb) + if (!isNaN(na) && !isNaN(nb)) { + return this.sortAsc ? na - nb : nb - na + } + // Fall back to string comparison + return this.sortAsc ? va.localeCompare(vb) : vb.localeCompare(va) + }) + + this.renderTable(true) + } + + private async loadText(options: DocViewOptions): Promise { + const source = options.source + if (source.type === 'file') return toText(source.data) + if (source.type === 'url') { + const buffer = await fetchAsBuffer(source.url, source.fetchOptions) + return new TextDecoder('utf-8').decode(buffer) + } + return '' + } + + private getFilename(options: DocViewOptions): string | undefined { + const source = options.source + if ('filename' in source && source.filename) return source.filename + if (source.type === 'url') return source.url.split('/').pop()?.split('?')[0] + return undefined + } + + protected onDestroy(): void { + this.headers = [] + this.rows = [] + } +} diff --git a/packages/core/src/renderers/docx.ts b/packages/core/src/renderers/docx.ts new file mode 100644 index 0000000..a4ed857 --- /dev/null +++ b/packages/core/src/renderers/docx.ts @@ -0,0 +1,91 @@ +import type { DocViewOptions, DocumentFormat } from '../types.js' +import { BaseRenderer } from '../renderer.js' +import { el, toArrayBuffer, fetchAsBuffer, requirePeerDep } from '../utils.js' + +interface DocxPreview { + renderAsync( + data: ArrayBuffer | Blob, + container: HTMLElement, + styleContainer?: HTMLElement | null, + options?: { + className?: string + inWrapper?: boolean + ignoreWidth?: boolean + ignoreHeight?: boolean + ignoreFonts?: boolean + breakPages?: boolean + ignoreLastRenderedPageBreak?: boolean + experimental?: boolean + trimXmlDeclaration?: boolean + useBase64URL?: boolean + renderHeaders?: boolean + renderFooters?: boolean + renderFootnotes?: boolean + renderEndnotes?: boolean + }, + ): Promise +} + +/** + * Renders DOCX files using docx-preview, preserving layout, styles, + * tables, headers/footers, and embedded images with high fidelity. + */ +export class DocxRenderer extends BaseRenderer { + readonly format: DocumentFormat = 'docx' + + private docxContainer!: HTMLElement + + protected async onMount(viewport: HTMLElement, options: DocViewOptions): Promise { + this.showLoading('Rendering document…') + + const docxPreview = await requirePeerDep('docx-preview', 'DOCX') + + const data = await this.loadData(options) + + this.hideLoading() + + this.docxContainer = el('div', 'dv-docx-container') + viewport.appendChild(this.docxContainer) + + await docxPreview.renderAsync(data, this.docxContainer, null, { + className: 'docx', + inWrapper: true, + ignoreWidth: false, + ignoreHeight: false, + breakPages: true, + renderHeaders: true, + renderFooters: true, + renderFootnotes: true, + renderEndnotes: true, + useBase64URL: true, + }) + + // Count rendered page sections for page count + const pages = this.docxContainer.querySelectorAll('section.docx') + const pageCount = Math.max(1, pages.length) + + this.setReady({ + format: 'docx', + pageCount, + filename: this.getFilename(options), + }) + } + + private async loadData(options: DocViewOptions): Promise { + const source = options.source + if (source.type === 'file') return toArrayBuffer(source.data) + if (source.type === 'url') return fetchAsBuffer(source.url, source.fetchOptions) + throw new Error('DOCX renderer requires a file or url source.') + } + + private getFilename(options: DocViewOptions): string | undefined { + const source = options.source + if ('filename' in source && source.filename) return source.filename + if (source.type === 'url') return source.url.split('/').pop()?.split('?')[0] + return undefined + } + + protected onDestroy(): void { + // docx-preview doesn't expose a destroy method — DOM cleanup is sufficient + } +} diff --git a/packages/core/src/renderers/epub.ts b/packages/core/src/renderers/epub.ts new file mode 100644 index 0000000..b539c2a --- /dev/null +++ b/packages/core/src/renderers/epub.ts @@ -0,0 +1,182 @@ +import type { DocViewOptions, DocumentFormat } from '../types.js' +import { BaseRenderer } from '../renderer.js' +import { el, toArrayBuffer, fetchAsBuffer, requirePeerDep } from '../utils.js' + +interface EpubJSModule { + default?: new (options?: { openAs?: string }) => EpubBook +} + +interface EpubBook { + open(input: ArrayBuffer | string): Promise + renderTo(element: HTMLElement, options?: { + width?: string | number + height?: string | number + flow?: string + spread?: string + }): EpubRendition + loaded: { + metadata: Promise<{ title?: string; creator?: string }> + spine: Promise<{ items: { index: number }[] }> + } + destroy(): void +} + +interface EpubRendition { + display(target?: string | number): Promise + next(): Promise + prev(): Promise + on(event: string, callback: (...args: any[]) => void): void + themes: { + fontSize(size: string): void + font(family: string): void + override(name: string, value: string): void + } + currentLocation(): EpubLocation | null + destroy(): void +} + +interface EpubLocation { + start: { index: number; displayed: { page: number; total: number } } + end: { index: number } +} + +/** + * Renders EPUB files using epub.js with paginated or scrolled reading modes, + * theme integration, and keyboard navigation. + */ +export class EpubRenderer extends BaseRenderer { + readonly format: DocumentFormat = 'epub' + + private book: EpubBook | null = null + private rendition: EpubRendition | null = null + private epubContainer!: HTMLElement + private keyHandler: ((e: KeyboardEvent) => void) | null = null + + protected async onMount(viewport: HTMLElement, options: DocViewOptions): Promise { + this.showLoading('Loading book…') + + const epubjs = await requirePeerDep('epubjs', 'EPUB') + // Handle both ESM default-unwrapped and wrapped module shapes + const EpubBook = (typeof epubjs === 'function' ? epubjs : epubjs.default) as new (options?: { openAs?: string }) => EpubBook + + this.book = new EpubBook() + + // Load content + const source = options.source + if (source.type === 'file') { + const buffer = await toArrayBuffer(source.data) + await this.book.open(buffer) + } else if (source.type === 'url') { + await this.book.open(source.url) + } else { + throw new Error('EPUB renderer requires a file or url source.') + } + + this.hideLoading() + + // Create container + this.epubContainer = el('div', 'dv-epub-container') + viewport.appendChild(this.epubContainer) + + // epub.js requires concrete pixel dimensions to render + const rect = viewport.getBoundingClientRect() + const width = Math.floor(rect.width) || 600 + const height = Math.floor(rect.height - 4) || 400 // -4 for breathing room + + // Render + const epubOpts = options.epub ?? {} + const flow = epubOpts.flow ?? 'paginated' + + this.rendition = this.book.renderTo(this.epubContainer, { + width, + height, + flow, + spread: 'none', + }) + + // Apply theme + if (epubOpts.fontSize) { + this.rendition.themes.fontSize(`${epubOpts.fontSize}px`) + } + if (epubOpts.fontFamily) { + this.rendition.themes.font(epubOpts.fontFamily) + } + + // Theme colors from CSS variables + const isDark = options.theme !== 'light' + if (isDark) { + this.rendition.themes.override('color', '#e6edf3') + this.rendition.themes.override('background', '#0d1117') + } + + // Track location changes + this.rendition.on('relocated', (location: EpubLocation) => { + if (location?.start) { + this.state.currentPage = location.start.displayed.page + this.state.totalPages = location.start.displayed.total + this.emitPageChange() + } + }) + + await this.rendition.display() + + // Keyboard navigation + this.keyHandler = (e: KeyboardEvent) => { + if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { + this.rendition?.next() + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { + this.rendition?.prev() + } + } + document.addEventListener('keydown', this.keyHandler) + + // Get metadata + let title: string | undefined + let author: string | undefined + try { + const meta = await this.book.loaded.metadata + title = meta.title || undefined + author = meta.creator || undefined + } catch { + // Non-fatal + } + + // Estimate page count from spine + let pageCount = 1 + try { + const spine = await this.book.loaded.spine + pageCount = spine.items.length + } catch { + // Non-fatal + } + + this.setReady({ + format: 'epub', + pageCount, + title, + author, + filename: this.getFilename(options), + }) + } + + protected onPageChange(page: number): void { + this.rendition?.display(String(page - 1)) + } + + private getFilename(options: DocViewOptions): string | undefined { + const source = options.source + if ('filename' in source && source.filename) return source.filename + if (source.type === 'url') return source.url.split('/').pop()?.split('?')[0] + return undefined + } + + protected onDestroy(): void { + if (this.keyHandler) { + document.removeEventListener('keydown', this.keyHandler) + } + this.rendition?.destroy() + this.book?.destroy() + this.rendition = null + this.book = null + } +} diff --git a/packages/core/src/renderers/index.ts b/packages/core/src/renderers/index.ts new file mode 100644 index 0000000..3a3c911 --- /dev/null +++ b/packages/core/src/renderers/index.ts @@ -0,0 +1,39 @@ +export { PdfRenderer } from './pdf.js' +export { BrowsePagesRenderer } from './browse-pages.js' +export { ChunkedPdfRenderer } from './chunked-pdf.js' +export { EpubRenderer } from './epub.js' +export { DocxRenderer } from './docx.js' +export { OdtRenderer } from './odt.js' +export { OdsRenderer } from './ods.js' +export { CsvRenderer } from './csv.js' +export { CodeRenderer } from './code.js' +export { TextRenderer } from './text.js' + +import { registry } from '../registry.js' +import { PdfRenderer } from './pdf.js' +import { BrowsePagesRenderer } from './browse-pages.js' +import { ChunkedPdfRenderer } from './chunked-pdf.js' +import { EpubRenderer } from './epub.js' +import { DocxRenderer } from './docx.js' +import { OdtRenderer } from './odt.js' +import { OdsRenderer } from './ods.js' +import { CsvRenderer } from './csv.js' +import { CodeRenderer } from './code.js' +import { TextRenderer } from './text.js' + +/** + * Register all built-in renderers with the format registry. + * Called automatically when the library is imported. + */ +export function registerBuiltinRenderers(): void { + registry.register('pdf', () => new PdfRenderer()) + registry.register('pages', () => new BrowsePagesRenderer()) + registry.register('chunked-pdf', () => new ChunkedPdfRenderer()) + registry.register('epub', () => new EpubRenderer()) + registry.register('docx', () => new DocxRenderer()) + registry.register('odt', () => new OdtRenderer()) + registry.register('ods', () => new OdsRenderer()) + registry.register('csv', () => new CsvRenderer()) + registry.register('code', () => new CodeRenderer()) + registry.register('text', () => new TextRenderer()) +} diff --git a/packages/core/src/renderers/ods.ts b/packages/core/src/renderers/ods.ts new file mode 100644 index 0000000..8aaffc2 --- /dev/null +++ b/packages/core/src/renderers/ods.ts @@ -0,0 +1,233 @@ +import type { DocViewOptions, DocumentFormat } from '../types.js' +import { BaseRenderer } from '../renderer.js' +import { el, toArrayBuffer, fetchAsBuffer, requirePeerDep } from '../utils.js' + +interface XLSXLib { + read(data: ArrayBuffer, opts?: { type?: string }): XLSXWorkbook + utils: { + sheet_to_json(sheet: XLSXWorksheet, opts?: { header?: 1 | string; raw?: boolean }): T[] + } +} + +interface XLSXWorkbook { + SheetNames: string[] + Sheets: Record +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface XLSXWorksheet {} + +/** + * Renders ODS (OpenDocument Spreadsheet) files using the SheetJS (xlsx) + * library. Parses sheets into arrays and renders sortable HTML tables, + * with a tab bar for multi-sheet workbooks. + * + * Peer dependency: xlsx + */ +export class OdsRenderer extends BaseRenderer { + readonly format: DocumentFormat = 'ods' + + private odsContainer!: HTMLElement + private tabBar!: HTMLElement + private tableContainer!: HTMLElement + private sheets: { name: string; headers: string[]; rows: string[][] }[] = [] + private activeSheet = 0 + private sortCol = -1 + private sortAsc = true + + protected async onMount(viewport: HTMLElement, options: DocViewOptions): Promise { + this.showLoading('Parsing spreadsheet…') + + const XLSX = await requirePeerDep('xlsx', 'ODS') + const data = await this.loadData(options) + + const workbook = XLSX.read(data, { type: 'array' }) + + const odsOpts = options.ods ?? {} + const maxRows = odsOpts.maxRows ?? 10000 + const useHeader = odsOpts.header !== false + + // Parse each sheet + for (const name of workbook.SheetNames) { + const sheet = workbook.Sheets[name] + const rawRows = XLSX.utils.sheet_to_json(sheet, { + header: 1, + raw: false, + }) + + let headers: string[] + let rows: string[][] + + if (rawRows.length === 0) { + headers = [] + rows = [] + } else if (useHeader && rawRows.length > 1) { + headers = rawRows[0].map((h, i) => (h != null && String(h)) || `Column ${i + 1}`) + rows = rawRows.slice(1, maxRows + 1) + } else { + const colCount = Math.max(...rawRows.slice(0, 100).map((r) => r.length), 0) + headers = Array.from({ length: colCount }, (_, i) => `Column ${i + 1}`) + rows = rawRows.slice(0, maxRows) + } + + this.sheets.push({ name, headers, rows }) + } + + this.hideLoading() + + // Build container + this.odsContainer = el('div', 'dv-ods-container') + viewport.appendChild(this.odsContainer) + + // Tab bar (only if more than one sheet) + if (this.sheets.length > 1) { + this.tabBar = el('div', 'dv-ods-tabs') + this.odsContainer.appendChild(this.tabBar) + this.renderTabs() + } + + // Table + this.tableContainer = el('div', 'dv-table-container') + this.odsContainer.appendChild(this.tableContainer) + this.renderTable(odsOpts.sortable !== false) + + this.setReady({ + format: 'ods', + pageCount: this.sheets.length, + filename: this.getFilename(options), + }) + } + + private renderTabs(): void { + this.tabBar.innerHTML = '' + this.sheets.forEach((sheet, idx) => { + const tab = el('button', `dv-ods-tab${idx === this.activeSheet ? ' dv-ods-tab-active' : ''}`) + tab.textContent = sheet.name + tab.addEventListener('click', () => { + this.activeSheet = idx + this.sortCol = -1 // Reset sort on sheet change + this.sortAsc = true + this.renderTabs() + this.renderTable(true) + this.state.currentPage = idx + 1 + this.emitPageChange() + }) + this.tabBar.appendChild(tab) + }) + } + + private renderTable(sortable: boolean): void { + const sheet = this.sheets[this.activeSheet] + if (!sheet || sheet.headers.length === 0) { + this.tableContainer.innerHTML = + '
No data in this sheet.
' + return + } + + const table = el('table', 'dv-table') + + // Header + const thead = document.createElement('thead') + const headerRow = document.createElement('tr') + + // Row number column + const rowNumTh = document.createElement('th') + rowNumTh.className = 'dv-table-row-number' + rowNumTh.textContent = '#' + headerRow.appendChild(rowNumTh) + + sheet.headers.forEach((header, colIdx) => { + const th = document.createElement('th') + th.textContent = header + if (sortable) { + th.dataset.sortable = 'true' + th.addEventListener('click', () => this.sortByColumn(colIdx)) + if (this.sortCol === colIdx) { + th.textContent = `${header} ${this.sortAsc ? '↑' : '↓'}` + } + } + headerRow.appendChild(th) + }) + thead.appendChild(headerRow) + table.appendChild(thead) + + // Body + const tbody = document.createElement('tbody') + sheet.rows.forEach((row, rowIdx) => { + const tr = document.createElement('tr') + + const rowNumTd = document.createElement('td') + rowNumTd.className = 'dv-table-row-number' + rowNumTd.textContent = String(rowIdx + 1) + tr.appendChild(rowNumTd) + + for (let colIdx = 0; colIdx < sheet.headers.length; colIdx++) { + const td = document.createElement('td') + const val = row[colIdx] != null ? String(row[colIdx]) : '' + td.textContent = val + td.title = val + tr.appendChild(td) + } + tbody.appendChild(tr) + }) + table.appendChild(tbody) + + this.tableContainer.innerHTML = '' + this.tableContainer.appendChild(table) + } + + private sortByColumn(colIdx: number): void { + const sheet = this.sheets[this.activeSheet] + if (!sheet) return + + if (this.sortCol === colIdx) { + this.sortAsc = !this.sortAsc + } else { + this.sortCol = colIdx + this.sortAsc = true + } + + sheet.rows.sort((a, b) => { + const va = a[colIdx] != null ? String(a[colIdx]) : '' + const vb = b[colIdx] != null ? String(b[colIdx]) : '' + const na = parseFloat(va) + const nb = parseFloat(vb) + if (!isNaN(na) && !isNaN(nb)) { + return this.sortAsc ? na - nb : nb - na + } + return this.sortAsc ? va.localeCompare(vb) : vb.localeCompare(va) + }) + + this.renderTable(true) + } + + protected onPageChange(page: number): void { + // Navigate to sheet by page number + const idx = page - 1 + if (idx >= 0 && idx < this.sheets.length && idx !== this.activeSheet) { + this.activeSheet = idx + this.sortCol = -1 + this.sortAsc = true + if (this.tabBar) this.renderTabs() + this.renderTable(true) + } + } + + private async loadData(options: DocViewOptions): Promise { + const source = options.source + if (source.type === 'file') return toArrayBuffer(source.data) + if (source.type === 'url') return fetchAsBuffer(source.url, source.fetchOptions) + throw new Error('ODS renderer requires a file or url source.') + } + + private getFilename(options: DocViewOptions): string | undefined { + const source = options.source + if ('filename' in source && source.filename) return source.filename + if (source.type === 'url') return source.url.split('/').pop()?.split('?')[0] + return undefined + } + + protected onDestroy(): void { + this.sheets = [] + } +} diff --git a/packages/core/src/renderers/odt.ts b/packages/core/src/renderers/odt.ts new file mode 100644 index 0000000..a731a2f --- /dev/null +++ b/packages/core/src/renderers/odt.ts @@ -0,0 +1,285 @@ +import type { DocViewOptions, DocumentFormat } from '../types.js' +import { BaseRenderer } from '../renderer.js' +import { el, toArrayBuffer, fetchAsBuffer, requirePeerDep } from '../utils.js' + +interface JSZip { + loadAsync(data: ArrayBuffer | Uint8Array | Blob): Promise +} + +interface JSZipInstance { + file(name: string): JSZipFile | null + files: Record +} + +interface JSZipFile { + async(type: 'string'): Promise + async(type: 'arraybuffer'): Promise +} + +/** + * Renders ODT (OpenDocument Text) files by extracting content.xml from the + * ZIP archive and converting ODF XML tags to styled HTML. + * + * Peer dependency: jszip + */ +export class OdtRenderer extends BaseRenderer { + readonly format: DocumentFormat = 'odt' + + private odtContainer!: HTMLElement + + protected async onMount(viewport: HTMLElement, options: DocViewOptions): Promise { + this.showLoading('Rendering ODT document…') + + const JSZipLib = await requirePeerDep('jszip', 'ODT') + const data = await this.loadData(options) + + // Parse the ZIP archive + const zip = await JSZipLib.loadAsync(data) + + // Extract content.xml (the main document content) + const contentFile = zip.file('content.xml') + if (!contentFile) { + throw new Error('Invalid ODT file: missing content.xml') + } + const contentXml = await contentFile.async('string') + + // Extract styles.xml for additional styling info + const stylesFile = zip.file('styles.xml') + const stylesXml = stylesFile ? await stylesFile.async('string') : '' + + this.hideLoading() + + // Build the rendered view + this.odtContainer = el('div', 'dv-odt-container') + viewport.appendChild(this.odtContainer) + + // Apply font overrides + const odtOpts = options.odt ?? {} + if (odtOpts.fontSize) { + this.odtContainer.style.fontSize = `${odtOpts.fontSize}px` + } + if (odtOpts.fontFamily) { + this.odtContainer.style.fontFamily = odtOpts.fontFamily + } + + // Parse and render the XML content + const html = this.convertOdfToHtml(contentXml, stylesXml) + this.odtContainer.innerHTML = html + + this.setReady({ + format: 'odt', + pageCount: 1, + filename: this.getFilename(options), + }) + } + + private convertOdfToHtml(contentXml: string, _stylesXml: string): string { + const parser = new DOMParser() + const doc = parser.parseFromString(contentXml, 'application/xml') + + // Find the office:body > office:text element + const body = doc.getElementsByTagName('office:text')[0] + ?? doc.getElementsByTagName('office:body')[0] + + if (!body) { + return '

No content found in document.

' + } + + // Parse automatic-styles for style name → CSS mapping + const styleMap = this.parseAutomaticStyles(doc) + + return this.convertNode(body, styleMap) + } + + private parseAutomaticStyles(doc: Document): Map { + const map = new Map() + const autoStyles = doc.getElementsByTagName('office:automatic-styles')[0] + if (!autoStyles) return map + + const styles = autoStyles.getElementsByTagName('style:style') + for (let i = 0; i < styles.length; i++) { + const style = styles[i] + const name = style.getAttribute('style:name') + if (!name) continue + + const cssProps: string[] = [] + + // Parse text-properties + const textProps = style.getElementsByTagName('style:text-properties') + for (let j = 0; j < textProps.length; j++) { + const tp = textProps[j] + const bold = tp.getAttribute('fo:font-weight') + const italic = tp.getAttribute('fo:font-style') + const fontSize = tp.getAttribute('fo:font-size') + const color = tp.getAttribute('fo:color') + const underline = tp.getAttribute('style:text-underline-style') + const fontFamily = tp.getAttribute('style:font-name') ?? tp.getAttribute('fo:font-family') + const bgColor = tp.getAttribute('fo:background-color') + const strikethrough = tp.getAttribute('style:text-line-through-style') + + if (bold === 'bold') cssProps.push('font-weight:bold') + if (italic === 'italic') cssProps.push('font-style:italic') + if (fontSize) cssProps.push(`font-size:${fontSize}`) + if (color && color !== 'transparent') cssProps.push(`color:${color}`) + if (underline && underline !== 'none') cssProps.push('text-decoration:underline') + if (strikethrough && strikethrough !== 'none') cssProps.push('text-decoration:line-through') + if (fontFamily) cssProps.push(`font-family:${fontFamily}`) + if (bgColor && bgColor !== 'transparent') cssProps.push(`background-color:${bgColor}`) + } + + // Parse paragraph-properties + const paraProps = style.getElementsByTagName('style:paragraph-properties') + for (let j = 0; j < paraProps.length; j++) { + const pp = paraProps[j] + const align = pp.getAttribute('fo:text-align') + const marginTop = pp.getAttribute('fo:margin-top') + const marginBottom = pp.getAttribute('fo:margin-bottom') + const marginLeft = pp.getAttribute('fo:margin-left') + const lineHeight = pp.getAttribute('fo:line-height') + + if (align) { + const cssAlign = align === 'start' ? 'left' : align === 'end' ? 'right' : align + cssProps.push(`text-align:${cssAlign}`) + } + if (marginTop) cssProps.push(`margin-top:${marginTop}`) + if (marginBottom) cssProps.push(`margin-bottom:${marginBottom}`) + if (marginLeft) cssProps.push(`margin-left:${marginLeft}`) + if (lineHeight) cssProps.push(`line-height:${lineHeight}`) + } + + if (cssProps.length > 0) { + map.set(name, cssProps.join(';')) + } + } + + return map + } + + private convertNode(node: Node, styleMap: Map): string { + if (node.nodeType === Node.TEXT_NODE) { + return this.escapeHtml(node.textContent ?? '') + } + + if (node.nodeType !== Node.ELEMENT_NODE) return '' + const elem = node as Element + const tag = elem.tagName + + switch (tag) { + case 'text:p': { + const style = this.getStyleAttr(elem, styleMap) + const content = this.convertChildren(elem, styleMap) + return `${content || ' '}

` + } + + case 'text:h': { + const level = elem.getAttribute('text:outline-level') ?? '1' + const lvl = Math.min(Math.max(parseInt(level, 10), 1), 6) + const style = this.getStyleAttr(elem, styleMap) + const content = this.convertChildren(elem, styleMap) + return `${content}` + } + + case 'text:span': { + const style = this.getStyleAttr(elem, styleMap) + return `${this.convertChildren(elem, styleMap)}` + } + + case 'text:a': { + const href = elem.getAttribute('xlink:href') ?? '#' + return `${this.convertChildren(elem, styleMap)}` + } + + case 'text:list': + return `
    ${this.convertChildren(elem, styleMap)}
` + + case 'text:list-item': + return `
  • ${this.convertChildren(elem, styleMap)}
  • ` + + case 'text:line-break': + return '
    ' + + case 'text:tab': + return ' ' + + case 'text:s': { + const count = parseInt(elem.getAttribute('text:c') ?? '1', 10) + return ' '.repeat(count) + } + + case 'text:soft-page-break': + return '
    ' + + case 'table:table': { + const content = this.convertChildren(elem, styleMap) + return `${content}
    ` + } + + case 'table:table-header-rows': + return `${this.convertChildren(elem, styleMap)}` + + case 'table:table-row': + return `${this.convertChildren(elem, styleMap)}` + + case 'table:table-cell': { + const colspan = elem.getAttribute('table:number-columns-spanned') + const rowspan = elem.getAttribute('table:number-rows-spanned') + const attrs: string[] = [] + if (colspan && colspan !== '1') attrs.push(`colspan="${colspan}"`) + if (rowspan && rowspan !== '1') attrs.push(`rowspan="${rowspan}"`) + const attrStr = attrs.length > 0 ? ' ' + attrs.join(' ') : '' + return `${this.convertChildren(elem, styleMap)}` + } + + case 'draw:frame': + case 'draw:image': + // Skip embedded images (would require extracting from ZIP) + return '[image]' + + default: + // Recurse into unknown elements + return this.convertChildren(elem, styleMap) + } + } + + private convertChildren(elem: Element, styleMap: Map): string { + const parts: string[] = [] + for (let i = 0; i < elem.childNodes.length; i++) { + parts.push(this.convertNode(elem.childNodes[i], styleMap)) + } + return parts.join('') + } + + private getStyleAttr(elem: Element, styleMap: Map): string { + const styleName = elem.getAttribute('text:style-name') + if (styleName && styleMap.has(styleName)) { + return ` style="${styleMap.get(styleName)!}"` + } + return '' + } + + private escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + } + + private async loadData(options: DocViewOptions): Promise { + const source = options.source + if (source.type === 'file') return toArrayBuffer(source.data) + if (source.type === 'url') return fetchAsBuffer(source.url, source.fetchOptions) + throw new Error('ODT renderer requires a file or url source.') + } + + private getFilename(options: DocViewOptions): string | undefined { + const source = options.source + if ('filename' in source && source.filename) return source.filename + if (source.type === 'url') return source.url.split('/').pop()?.split('?')[0] + return undefined + } + + protected onDestroy(): void { + // DOM cleanup is sufficient + } +} diff --git a/packages/core/src/renderers/pdf.ts b/packages/core/src/renderers/pdf.ts new file mode 100644 index 0000000..895b50e --- /dev/null +++ b/packages/core/src/renderers/pdf.ts @@ -0,0 +1,360 @@ +import type { DocViewOptions, DocumentFormat, DocumentInfo } from '../types.js' +import { DocViewError } from '../types.js' +import { BaseRenderer } from '../renderer.js' +import { + el, + toArrayBuffer, + fetchAsBuffer, + requirePeerDep, + clamp, + debounce, +} from '../utils.js' + +interface PdfjsLib { + getDocument(params: { + data?: ArrayBuffer + url?: string + cMapUrl?: string + cMapPacked?: boolean + enableXfa?: boolean + }): { promise: Promise } + GlobalWorkerOptions: { workerSrc: string } +} + +interface PdfjsDocument { + numPages: number + getPage(num: number): Promise + getMetadata(): Promise<{ info?: { Title?: string; Author?: string } }> + destroy(): void +} + +interface PdfjsPage { + getViewport(params: { scale: number }): { width: number; height: number } + render(params: { + canvasContext: CanvasRenderingContext2D + viewport: { width: number; height: number } + }): { promise: Promise } + getTextContent(): Promise<{ items: PdfjsTextItem[] }> + cleanup(): void +} + +interface PdfjsTextItem { + str: string + transform: number[] + width: number + height: number +} + +export class PdfRenderer extends BaseRenderer { + readonly format: DocumentFormat = 'pdf' + + private pdfDoc: PdfjsDocument | null = null + private pagesContainer!: HTMLElement + private pageElements: HTMLElement[] = [] + private pageCanvases: HTMLCanvasElement[] = [] + private renderedPages = new Set() + private baseScale = 1 + private scrollObserver: IntersectionObserver | null = null + private resizeObserver: ResizeObserver | null = null + private debouncedRender: ReturnType | null = null + + protected async onMount(viewport: HTMLElement, options: DocViewOptions): Promise { + const loadingEl = this.showLoading('Loading PDF…') + + // Load pdfjs-dist + const pdfjsLib = await requirePeerDep('pdfjs-dist', 'PDF') + + // Configure worker + if (options.pdf?.workerSrc) { + pdfjsLib.GlobalWorkerOptions.workerSrc = options.pdf.workerSrc + } + + // Load the document + const data = await this.loadSource(options) + const loadingParams: Parameters[0] = {} + + if (typeof data === 'string') { + loadingParams.url = data + } else { + loadingParams.data = data + } + + if (options.pdf?.cMapUrl) { + loadingParams.cMapUrl = options.pdf.cMapUrl + loadingParams.cMapPacked = true + } + + this.pdfDoc = await pdfjsLib.getDocument(loadingParams).promise + + // Get metadata + let title: string | undefined + let author: string | undefined + try { + const meta = await this.pdfDoc.getMetadata() + title = meta.info?.Title || undefined + author = meta.info?.Author || undefined + } catch { + // Metadata extraction can fail on some PDFs — non-fatal + } + + // Create pages container + this.pagesContainer = el('div', 'dv-pages') + viewport.appendChild(this.pagesContainer) + + // Create placeholder elements for every page + const numPages = this.pdfDoc.numPages + for (let i = 1; i <= numPages; i++) { + const pageEl = el('div', 'dv-page') + pageEl.dataset.page = String(i) + this.pageElements.push(pageEl) + this.pagesContainer.appendChild(pageEl) + } + + // Determine initial scale + const firstPage = await this.pdfDoc.getPage(1) + const initialViewport = firstPage.getViewport({ scale: 1 }) + const containerWidth = viewport.clientWidth - 48 // padding + this.baseScale = containerWidth / initialViewport.width + + const zoomOption = options.zoom ?? 'fit-width' + if (typeof zoomOption === 'number') { + this.state.zoom = zoomOption + } else { + this.state.zoom = this.baseScale + } + + // Set placeholder sizes + for (let i = 0; i < numPages; i++) { + const pageEl = this.pageElements[i] + pageEl.style.width = `${initialViewport.width * this.state.zoom}px` + pageEl.style.height = `${initialViewport.height * this.state.zoom}px` + pageEl.style.background = 'var(--dv-page-bg)' + } + + loadingEl.remove() + + // Set up intersection observer for lazy page rendering + this.scrollObserver = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + const pageNum = parseInt( + (entry.target as HTMLElement).dataset.page!, + 10, + ) + this.renderPage(pageNum) + } + } + }, + { root: viewport, rootMargin: '200% 0px' }, + ) + + for (const pageEl of this.pageElements) { + this.scrollObserver.observe(pageEl) + } + + // Track current page on scroll + this.debouncedRender = debounce(() => this.updateCurrentPage(), 100) + viewport.addEventListener('scroll', this.debouncedRender) + + // Resize observer for fit-width recalculation + this.resizeObserver = new ResizeObserver(() => { + const newWidth = viewport.clientWidth - 48 + if (Math.abs(newWidth - containerWidth) > 10) { + this.baseScale = newWidth / initialViewport.width + } + }) + this.resizeObserver.observe(viewport) + + // Ready + this.setReady({ + format: 'pdf', + pageCount: numPages, + title, + author, + filename: this.getFilename(options), + }) + + // Navigate to initial page + if (options.initialPage && options.initialPage > 1) { + this.goToPage(options.initialPage) + } + } + + private async loadSource( + options: DocViewOptions, + ): Promise { + const source = options.source + if (source.type === 'url') { + // pdfjs can handle URL directly for range requests + return source.url + } + if (source.type === 'file') { + return toArrayBuffer(source.data) + } + throw new DocViewError( + 'SOURCE_LOAD_FAILED', + 'PDF renderer requires a file or url source.', + ) + } + + private async renderPage(pageNum: number): Promise { + if (!this.pdfDoc || this.renderedPages.has(pageNum)) return + this.renderedPages.add(pageNum) + + const page = await this.pdfDoc.getPage(pageNum) + const viewport = page.getViewport({ scale: this.state.zoom }) + const pageEl = this.pageElements[pageNum - 1] + + // Create canvas + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d')! + const dpr = window.devicePixelRatio || 1 + canvas.width = viewport.width * dpr + canvas.height = viewport.height * dpr + canvas.style.width = `${viewport.width}px` + canvas.style.height = `${viewport.height}px` + ctx.scale(dpr, dpr) + + pageEl.style.width = `${viewport.width}px` + pageEl.style.height = `${viewport.height}px` + pageEl.innerHTML = '' + pageEl.appendChild(canvas) + this.pageCanvases[pageNum - 1] = canvas + + // Render + await page.render({ canvasContext: ctx, viewport }).promise + + // Text layer + const textLayerEnabled = this.options.pdf?.textLayer !== false + if (textLayerEnabled) { + try { + const textContent = await page.getTextContent() + this.renderTextLayer(pageEl, textContent.items, viewport) + } catch { + // Text extraction can fail — non-fatal + } + } + + page.cleanup() + } + + private renderTextLayer( + pageEl: HTMLElement, + items: PdfjsTextItem[], + viewport: { width: number; height: number }, + ): void { + const layer = el('div', 'dv-text-layer') + for (const item of items) { + if (!item.str.trim()) continue + const span = document.createElement('span') + span.textContent = item.str + // item.transform: [scaleX, skewY, skewX, scaleY, translateX, translateY] + const tx = item.transform[4] + const ty = item.transform[5] + const fontSize = Math.sqrt( + item.transform[0] * item.transform[0] + + item.transform[1] * item.transform[1], + ) + span.style.left = `${(tx / viewport.width) * 100}%` + span.style.bottom = `${(ty / viewport.height) * 100}%` + span.style.fontSize = `${fontSize * this.state.zoom}px` + span.style.fontFamily = 'sans-serif' + layer.appendChild(span) + } + pageEl.appendChild(layer) + } + + private updateCurrentPage(): void { + const viewportRect = this.viewport.getBoundingClientRect() + const viewportMid = viewportRect.top + viewportRect.height / 2 + + for (let i = 0; i < this.pageElements.length; i++) { + const rect = this.pageElements[i].getBoundingClientRect() + if (rect.top <= viewportMid && rect.bottom >= viewportMid) { + const newPage = i + 1 + if (newPage !== this.state.currentPage) { + this.state.currentPage = newPage + this.emitPageChange() + } + return + } + } + } + + protected onPageChange(page: number): void { + const pageEl = this.pageElements[page - 1] + if (pageEl) { + pageEl.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + } + + protected onZoomChange(zoom: number): void { + const clamped = clamp(zoom, 0.25, 5) + this.state.zoom = clamped + + // Re-render all visible pages at new zoom + this.renderedPages.clear() + for (let i = 0; i < this.pageElements.length; i++) { + const pageEl = this.pageElements[i] + pageEl.innerHTML = '' + // Update placeholder size (approximate from first page ratio) + if (this.pdfDoc) { + const scale = clamped + // We'll just trigger re-render via intersection observer + pageEl.style.width = '' + pageEl.style.height = '' + } + } + + // Re-render by re-observing (intersection observer will trigger) + if (this.scrollObserver && this.pdfDoc) { + this.reRenderVisiblePages() + } + } + + private async reRenderVisiblePages(): Promise { + if (!this.pdfDoc) return + const firstPage = await this.pdfDoc.getPage(1) + const vp = firstPage.getViewport({ scale: this.state.zoom }) + + for (const pageEl of this.pageElements) { + pageEl.style.width = `${vp.width}px` + pageEl.style.height = `${vp.height}px` + } + + // The intersection observer will re-trigger rendering for visible pages + for (const pageEl of this.pageElements) { + this.scrollObserver!.unobserve(pageEl) + this.scrollObserver!.observe(pageEl) + } + } + + protected resolveZoomMode(mode: 'fit-width' | 'fit-page'): number { + return this.baseScale * (mode === 'fit-page' ? 0.95 : 1) + } + + private getFilename(options: DocViewOptions): string | undefined { + const src = options.source + if ('filename' in src && src.filename) return src.filename + if (src.type === 'url') { + const segments = src.url.split('/').filter(Boolean) + return segments.pop()?.split('?')[0] + } + return undefined + } + + protected onDestroy(): void { + this.scrollObserver?.disconnect() + this.resizeObserver?.disconnect() + this.debouncedRender?.cancel() + if (this.viewport) { + this.viewport.removeEventListener('scroll', this.debouncedRender as EventListener) + } + this.pdfDoc?.destroy() + this.pdfDoc = null + this.pageElements = [] + this.pageCanvases = [] + this.renderedPages.clear() + } +} diff --git a/packages/core/src/renderers/text.ts b/packages/core/src/renderers/text.ts new file mode 100644 index 0000000..ae3b79d --- /dev/null +++ b/packages/core/src/renderers/text.ts @@ -0,0 +1,69 @@ +import type { DocViewOptions, DocumentFormat } from '../types.js' +import { BaseRenderer } from '../renderer.js' +import { el, toText, fetchAsBuffer } from '../utils.js' + +/** + * Renders plain text files with optional line numbers. + * Uses proportional font for prose (.txt) and monospace for other text files. + */ +export class TextRenderer extends BaseRenderer { + readonly format: DocumentFormat = 'text' + + private textContainer!: HTMLElement + + protected async onMount(viewport: HTMLElement, options: DocViewOptions): Promise { + this.showLoading('Loading text…') + + const text = await this.loadText(options) + + this.hideLoading() + + // Determine if monospace + const ext = this.getExtension(options) + const isMonospace = ext !== 'txt' && ext !== 'text' + + this.textContainer = el('div', `dv-text-container${isMonospace ? ' dv-monospace' : ''}`) + this.textContainer.textContent = text + viewport.appendChild(this.textContainer) + + const lineCount = text.split('\n').length + this.setReady({ + format: 'text', + pageCount: 1, + filename: this.getFilename(options), + }) + } + + private async loadText(options: DocViewOptions): Promise { + const source = options.source + if (source.type === 'file') { + return toText(source.data) + } + if (source.type === 'url') { + const buffer = await fetchAsBuffer(source.url, source.fetchOptions) + return new TextDecoder('utf-8').decode(buffer) + } + return '' + } + + private getExtension(options: DocViewOptions): string { + const source = options.source + const name = ('filename' in source ? source.filename : undefined) + ?? (source.type === 'url' ? source.url : '') + const dot = name.lastIndexOf('.') + return dot >= 0 ? name.slice(dot + 1).toLowerCase() : '' + } + + private getFilename(options: DocViewOptions): string | undefined { + const source = options.source + if ('filename' in source && source.filename) return source.filename + if (source.type === 'url') { + return source.url.split('/').pop()?.split('?')[0] + } + return undefined + } + + protected onDestroy(): void { + // No external resources to clean up + } +} diff --git a/packages/core/src/styles.css b/packages/core/src/styles.css new file mode 100644 index 0000000..3b7a87d --- /dev/null +++ b/packages/core/src/styles.css @@ -0,0 +1,699 @@ +/* ========================================================================== + DocView — CSS Variables Theme System + Override any --dv-* variable to customize the viewer appearance. + ========================================================================== */ + +/* --- Base / Dark Theme (default) --- */ +.docview { + /* Typography */ + --dv-font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --dv-font-mono: 'SF Mono', SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; + --dv-font-size-sm: 12px; + --dv-font-size: 14px; + --dv-font-size-lg: 16px; + --dv-line-height: 1.5; + + /* Spacing & Radii */ + --dv-radius-sm: 4px; + --dv-radius: 6px; + --dv-radius-lg: 10px; + --dv-spacing-xs: 4px; + --dv-spacing-sm: 8px; + --dv-spacing: 12px; + --dv-spacing-lg: 16px; + --dv-spacing-xl: 24px; + + /* Transitions */ + --dv-transition-fast: 100ms ease; + --dv-transition: 150ms ease; + --dv-transition-slow: 250ms ease; + + /* Colors — Dark */ + --dv-bg: #0d1117; + --dv-surface: #161b22; + --dv-surface-raised: #1c2129; + --dv-border: #30363d; + --dv-border-subtle: #21262d; + --dv-text: #e6edf3; + --dv-text-secondary: #8b949e; + --dv-text-muted: #484f58; + --dv-accent: #58a6ff; + --dv-accent-hover: #79b8ff; + --dv-accent-subtle: rgba(88, 166, 255, 0.15); + --dv-error: #f85149; + --dv-warning: #d29922; + + /* Document Page */ + --dv-page-bg: #ffffff; + --dv-page-shadow: 0 1px 3px rgba(0, 0, 0, 0.3), 0 4px 12px rgba(0, 0, 0, 0.2); + --dv-page-gap: 12px; + + /* Toolbar */ + --dv-toolbar-bg: #161b22; + --dv-toolbar-border: #30363d; + --dv-toolbar-height: 44px; + + /* Scrollbar */ + --dv-scrollbar-size: 8px; + --dv-scrollbar-thumb: #484f58; + --dv-scrollbar-thumb-hover: #6e7681; + --dv-scrollbar-track: transparent; + + /* Table (CSV) */ + --dv-table-header-bg: #161b22; + --dv-table-row-hover: rgba(136, 146, 158, 0.08); + --dv-table-stripe: rgba(136, 146, 158, 0.04); + + /* Code */ + --dv-code-bg: #0d1117; + --dv-code-gutter: #484f58; + --dv-code-gutter-bg: #0d1117; + --dv-code-highlight-line: rgba(88, 166, 255, 0.1); +} + +/* --- Light Theme --- */ +.docview[data-theme='light'] { + --dv-bg: #ffffff; + --dv-surface: #f6f8fa; + --dv-surface-raised: #ffffff; + --dv-border: #d0d7de; + --dv-border-subtle: #e1e4e8; + --dv-text: #1f2328; + --dv-text-secondary: #656d76; + --dv-text-muted: #8b949e; + --dv-accent: #0969da; + --dv-accent-hover: #0550ae; + --dv-accent-subtle: rgba(9, 105, 218, 0.08); + --dv-error: #cf222e; + --dv-warning: #9a6700; + --dv-page-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.04); + --dv-toolbar-bg: #f6f8fa; + --dv-toolbar-border: #d0d7de; + --dv-scrollbar-thumb: #c1c8cf; + --dv-scrollbar-thumb-hover: #afb8c1; + --dv-table-header-bg: #f6f8fa; + --dv-table-row-hover: rgba(31, 35, 40, 0.04); + --dv-table-stripe: rgba(31, 35, 40, 0.02); + --dv-code-bg: #f6f8fa; + --dv-code-gutter: #8b949e; + --dv-code-gutter-bg: #f6f8fa; + --dv-code-highlight-line: rgba(9, 105, 218, 0.08); +} + + +/* ========================================================================== + Root Container + ========================================================================== */ + +.docview { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 200px; + overflow: hidden; + background: var(--dv-bg); + color: var(--dv-text); + font-family: var(--dv-font-sans); + font-size: var(--dv-font-size); + line-height: var(--dv-line-height); + border-radius: var(--dv-radius); + border: 1px solid var(--dv-border); +} + + +/* ========================================================================== + Toolbar + ========================================================================== */ + +.dv-toolbar { + display: flex; + align-items: center; + gap: var(--dv-spacing-sm); + height: var(--dv-toolbar-height); + min-height: var(--dv-toolbar-height); + padding: 0 var(--dv-spacing); + background: var(--dv-toolbar-bg); + border-bottom: 1px solid var(--dv-toolbar-border); + user-select: none; + flex-shrink: 0; + z-index: 10; +} + +.dv-toolbar[data-position='bottom'] { + border-bottom: none; + border-top: 1px solid var(--dv-toolbar-border); + order: 1; +} + +.dv-toolbar-group { + display: flex; + align-items: center; + gap: var(--dv-spacing-xs); +} + +.dv-toolbar-spacer { + flex: 1; +} + +.dv-toolbar-separator { + width: 1px; + height: 20px; + background: var(--dv-border); + margin: 0 var(--dv-spacing-xs); +} + +.dv-toolbar-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: var(--dv-radius-sm); + background: transparent; + color: var(--dv-text-secondary); + cursor: pointer; + transition: background var(--dv-transition-fast), color var(--dv-transition-fast); + flex-shrink: 0; +} + +.dv-toolbar-btn:hover { + background: var(--dv-accent-subtle); + color: var(--dv-text); +} + +.dv-toolbar-btn:active { + background: var(--dv-border); +} + +.dv-toolbar-btn[disabled] { + opacity: 0.35; + cursor: default; + pointer-events: none; +} + +.dv-toolbar-btn svg { + width: 16px; + height: 16px; +} + +.dv-toolbar-label { + font-size: var(--dv-font-size-sm); + color: var(--dv-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; +} + +.dv-page-input { + width: 48px; + height: 28px; + padding: 0 var(--dv-spacing-xs); + border: 1px solid var(--dv-border); + border-radius: var(--dv-radius-sm); + background: var(--dv-bg); + color: var(--dv-text); + font-family: var(--dv-font-mono); + font-size: var(--dv-font-size-sm); + text-align: center; + outline: none; +} + +.dv-page-input:focus { + border-color: var(--dv-accent); + box-shadow: 0 0 0 2px var(--dv-accent-subtle); +} + + +/* ========================================================================== + Viewport (Scrollable Document Area) + ========================================================================== */ + +.dv-viewport { + flex: 1; + overflow: auto; + position: relative; + background: var(--dv-bg); +} + +/* Custom scrollbar */ +.dv-viewport::-webkit-scrollbar { + width: var(--dv-scrollbar-size); + height: var(--dv-scrollbar-size); +} + +.dv-viewport::-webkit-scrollbar-thumb { + background: var(--dv-scrollbar-thumb); + border-radius: 999px; +} + +.dv-viewport::-webkit-scrollbar-thumb:hover { + background: var(--dv-scrollbar-thumb-hover); +} + +.dv-viewport::-webkit-scrollbar-track { + background: var(--dv-scrollbar-track); +} + +.dv-viewport { + scrollbar-width: thin; + scrollbar-color: var(--dv-scrollbar-thumb) var(--dv-scrollbar-track); +} + + +/* ========================================================================== + Page Container (for paginated renderers: PDF, images, etc.) + ========================================================================== */ + +.dv-pages { + display: flex; + flex-direction: column; + align-items: center; + padding: var(--dv-spacing-lg); + gap: var(--dv-page-gap); + min-height: 100%; +} + +.dv-page { + position: relative; + background: var(--dv-page-bg); + box-shadow: var(--dv-page-shadow); + border-radius: 2px; + overflow: hidden; + flex-shrink: 0; +} + +.dv-page canvas { + display: block; +} + +.dv-page img { + display: block; + width: 100%; + height: auto; +} + +/* Text layer overlay for copy/paste over rendered pages */ +.dv-text-layer { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; + opacity: 0.25; + line-height: 1; + pointer-events: all; +} + +.dv-text-layer span { + position: absolute; + white-space: pre; + color: transparent; + cursor: text; +} + +.dv-text-layer span::selection { + background: var(--dv-accent); + color: transparent; + opacity: 0.4; +} + + +/* ========================================================================== + Loading & Error States + ========================================================================== */ + +.dv-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--dv-spacing); + padding: var(--dv-spacing-xl); + min-height: 200px; + color: var(--dv-text-secondary); + font-size: var(--dv-font-size); +} + +.dv-spinner { + width: 24px; + height: 24px; + border: 2px solid var(--dv-border); + border-top-color: var(--dv-accent); + border-radius: 50%; + animation: dv-spin 0.6s linear infinite; +} + +@keyframes dv-spin { + to { transform: rotate(360deg); } +} + +.dv-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--dv-spacing-sm); + padding: var(--dv-spacing-xl); + min-height: 200px; + color: var(--dv-error); + text-align: center; +} + +.dv-error-code { + font-family: var(--dv-font-mono); + font-size: var(--dv-font-size-sm); + color: var(--dv-text-muted); +} + +.dv-error-message { + font-size: var(--dv-font-size); + max-width: 400px; +} + + +/* ========================================================================== + Table Renderer (CSV/TSV) + ========================================================================== */ + +.dv-table-container { + overflow: auto; + flex: 1; + font-size: var(--dv-font-size-sm); +} + +.dv-table { + width: 100%; + border-collapse: collapse; + border-spacing: 0; + font-family: var(--dv-font-mono); + font-size: var(--dv-font-size-sm); +} + +.dv-table thead { + position: sticky; + top: 0; + z-index: 5; +} + +.dv-table th { + background: var(--dv-table-header-bg); + border-bottom: 2px solid var(--dv-border); + padding: var(--dv-spacing-sm) var(--dv-spacing); + text-align: left; + font-weight: 600; + color: var(--dv-text); + white-space: nowrap; + cursor: default; + user-select: none; +} + +.dv-table th[data-sortable='true'] { + cursor: pointer; +} + +.dv-table th[data-sortable='true']:hover { + background: var(--dv-surface-raised); +} + +.dv-table td { + padding: var(--dv-spacing-xs) var(--dv-spacing); + border-bottom: 1px solid var(--dv-border-subtle); + color: var(--dv-text); + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dv-table tbody tr:nth-child(even) { + background: var(--dv-table-stripe); +} + +.dv-table tbody tr:hover { + background: var(--dv-table-row-hover); +} + +.dv-table-row-number { + color: var(--dv-text-muted); + min-width: 48px; + text-align: right; + padding-right: var(--dv-spacing) !important; + user-select: none; +} + + +/* ========================================================================== + Code Renderer + ========================================================================== */ + +.dv-code-container { + display: flex; + flex: 1; + overflow: auto; + font-family: var(--dv-font-mono); + font-size: var(--dv-font-size); + line-height: 1.6; + background: var(--dv-code-bg); + tab-size: 2; +} + +.dv-code-gutter { + position: sticky; + left: 0; + display: flex; + flex-direction: column; + padding: var(--dv-spacing) 0; + text-align: right; + color: var(--dv-code-gutter); + background: var(--dv-code-gutter-bg); + user-select: none; + flex-shrink: 0; + border-right: 1px solid var(--dv-border-subtle); + z-index: 1; +} + +.dv-code-gutter-line { + padding: 0 var(--dv-spacing) 0 var(--dv-spacing-lg); + min-width: 48px; +} + +.dv-code-body { + flex: 1; + padding: var(--dv-spacing); + overflow-x: auto; + white-space: pre; +} + +.dv-code-body.dv-word-wrap { + white-space: pre-wrap; + word-break: break-all; +} + +/* --- Built-in Syntax Highlighting (Dark) --- */ +.docview .dv-code-body .hljs-keyword, +.docview .dv-code-body .hljs-selector-tag, +.docview .dv-code-body .hljs-built_in, +.docview .dv-code-body .hljs-type { color: #ff7b72; } + +.docview .dv-code-body .hljs-string, +.docview .dv-code-body .hljs-addition { color: #a5d6ff; } + +.docview .dv-code-body .hljs-number, +.docview .dv-code-body .hljs-literal { color: #79c0ff; } + +.docview .dv-code-body .hljs-comment, +.docview .dv-code-body .hljs-quote { color: #8b949e; font-style: italic; } + +.docview .dv-code-body .hljs-function, +.docview .dv-code-body .hljs-title { color: #d2a8ff; } + +.docview .dv-code-body .hljs-title.function_ { color: #d2a8ff; } +.docview .dv-code-body .hljs-title.class_ { color: #f0883e; } + +.docview .dv-code-body .hljs-variable, +.docview .dv-code-body .hljs-template-variable { color: #ffa657; } + +.docview .dv-code-body .hljs-attr, +.docview .dv-code-body .hljs-attribute { color: #79c0ff; } + +.docview .dv-code-body .hljs-tag { color: #7ee787; } + +.docview .dv-code-body .hljs-name { color: #7ee787; } + +.docview .dv-code-body .hljs-regexp { color: #a5d6ff; } + +.docview .dv-code-body .hljs-symbol, +.docview .dv-code-body .hljs-bullet { color: #ffa657; } + +.docview .dv-code-body .hljs-meta, +.docview .dv-code-body .hljs-meta .hljs-keyword { color: #79c0ff; } + +.docview .dv-code-body .hljs-deletion { color: #ffa198; background: rgba(255, 129, 130, 0.1); } +.docview .dv-code-body .hljs-addition { background: rgba(63, 185, 80, 0.1); } + +.docview .dv-code-body .hljs-params { color: #e6edf3; } + +.docview .dv-code-body .hljs-operator { color: #ff7b72; } + +.docview .dv-code-body .hljs-property { color: #79c0ff; } + +.docview .dv-code-body .hljs-punctuation { color: #8b949e; } + +.docview .dv-code-body .hljs-emphasis { font-style: italic; } +.docview .dv-code-body .hljs-strong { font-weight: bold; } + +.docview .dv-code-body .hljs-section { color: #79c0ff; font-weight: bold; } + +.docview .dv-code-body .hljs-link { color: #58a6ff; text-decoration: underline; } + +/* --- Syntax Highlighting (Light Override) --- */ +.docview[data-theme='light'] .dv-code-body .hljs-keyword, +.docview[data-theme='light'] .dv-code-body .hljs-selector-tag, +.docview[data-theme='light'] .dv-code-body .hljs-built_in, +.docview[data-theme='light'] .dv-code-body .hljs-type { color: #cf222e; } + +.docview[data-theme='light'] .dv-code-body .hljs-string, +.docview[data-theme='light'] .dv-code-body .hljs-addition { color: #0a3069; } + +.docview[data-theme='light'] .dv-code-body .hljs-number, +.docview[data-theme='light'] .dv-code-body .hljs-literal { color: #0550ae; } + +.docview[data-theme='light'] .dv-code-body .hljs-comment, +.docview[data-theme='light'] .dv-code-body .hljs-quote { color: #6e7781; font-style: italic; } + +.docview[data-theme='light'] .dv-code-body .hljs-function, +.docview[data-theme='light'] .dv-code-body .hljs-title { color: #8250df; } + +.docview[data-theme='light'] .dv-code-body .hljs-title.function_ { color: #8250df; } +.docview[data-theme='light'] .dv-code-body .hljs-title.class_ { color: #953800; } + +.docview[data-theme='light'] .dv-code-body .hljs-variable, +.docview[data-theme='light'] .dv-code-body .hljs-template-variable { color: #953800; } + +.docview[data-theme='light'] .dv-code-body .hljs-attr, +.docview[data-theme='light'] .dv-code-body .hljs-attribute { color: #0550ae; } + +.docview[data-theme='light'] .dv-code-body .hljs-tag { color: #116329; } +.docview[data-theme='light'] .dv-code-body .hljs-name { color: #116329; } + +.docview[data-theme='light'] .dv-code-body .hljs-regexp { color: #0a3069; } + +.docview[data-theme='light'] .dv-code-body .hljs-symbol, +.docview[data-theme='light'] .dv-code-body .hljs-bullet { color: #953800; } + +.docview[data-theme='light'] .dv-code-body .hljs-meta, +.docview[data-theme='light'] .dv-code-body .hljs-meta .hljs-keyword { color: #0550ae; } + +.docview[data-theme='light'] .dv-code-body .hljs-deletion { color: #82071e; background: rgba(255, 129, 130, 0.1); } +.docview[data-theme='light'] .dv-code-body .hljs-addition { background: rgba(63, 185, 80, 0.1); } + +.docview[data-theme='light'] .dv-code-body .hljs-params { color: #1f2328; } +.docview[data-theme='light'] .dv-code-body .hljs-operator { color: #cf222e; } +.docview[data-theme='light'] .dv-code-body .hljs-property { color: #0550ae; } +.docview[data-theme='light'] .dv-code-body .hljs-punctuation { color: #6e7781; } +.docview[data-theme='light'] .dv-code-body .hljs-section { color: #0550ae; font-weight: bold; } +.docview[data-theme='light'] .dv-code-body .hljs-link { color: #0969da; text-decoration: underline; } + + +/* ========================================================================== + Text Renderer + ========================================================================== */ + +.dv-text-container { + flex: 1; + overflow: auto; + padding: var(--dv-spacing-lg); + font-family: var(--dv-font-sans); + font-size: var(--dv-font-size-lg); + line-height: 1.7; + color: var(--dv-text); + white-space: pre-wrap; + word-wrap: break-word; + max-width: 72ch; + margin: 0 auto; +} + +.dv-text-container.dv-monospace { + font-family: var(--dv-font-mono); + font-size: var(--dv-font-size); + max-width: none; +} + + +/* ========================================================================== + EPUB Renderer + ========================================================================== */ + +.dv-epub-container { + flex: 1; + overflow: hidden; + position: relative; +} + +.dv-epub-container iframe { + border: none; + width: 100%; + height: 100%; +} + + +/* ========================================================================== + DOCX Renderer + ========================================================================== */ + +.dv-docx-container { + flex: 1; + overflow: auto; + padding: var(--dv-spacing-lg); + background: var(--dv-bg); +} + +/* docx-preview renders into a wrapper div — scope some resets */ +.dv-docx-container .docx-wrapper { + background: transparent !important; + padding: 0 !important; +} + +.dv-docx-container .docx-wrapper > section.docx { + background: var(--dv-page-bg) !important; + box-shadow: var(--dv-page-shadow) !important; + margin: 0 auto var(--dv-page-gap) !important; + border-radius: 2px; +} + + +/* ========================================================================== + Page Image Renderer (browse pages / pre-rendered) + ========================================================================== */ + +.dv-browse-page { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.dv-browse-page img { + display: block; + max-width: 100%; + height: auto; + background: var(--dv-page-bg); + box-shadow: var(--dv-page-shadow); + border-radius: 2px; +} + +.dv-browse-page-placeholder { + display: flex; + align-items: center; + justify-content: center; + background: var(--dv-surface); + border-radius: 2px; + color: var(--dv-text-muted); + font-size: var(--dv-font-size-sm); +} diff --git a/packages/core/src/toolbar.ts b/packages/core/src/toolbar.ts new file mode 100644 index 0000000..7201d34 --- /dev/null +++ b/packages/core/src/toolbar.ts @@ -0,0 +1,150 @@ +import type { ToolbarConfig, DocViewState } from './types.js' +import { el, svgIcon, icons } from './utils.js' + +export interface ToolbarActions { + onPrevPage(): void + onNextPage(): void + onPageInput(page: number): void + onZoomIn(): void + onZoomOut(): void + onFitWidth(): void + onFullscreen(): void + onDownload?(): void +} + +export interface ToolbarHandle { + /** Root toolbar element. */ + element: HTMLElement + /** Update displayed state (page, total, zoom). */ + updateState(state: DocViewState): void + /** Destroy and clean up listeners. */ + destroy(): void +} + +/** + * Build a toolbar DOM element wired to the provided actions. + * Returns a handle for updating state and destroying. + */ +export function createToolbar( + config: ToolbarConfig, + actions: ToolbarActions, + initialState: DocViewState, +): ToolbarHandle { + const toolbar = el('div', 'dv-toolbar') + if (config.position === 'bottom') { + toolbar.setAttribute('data-position', 'bottom') + } + + let pageInput: HTMLInputElement | null = null + let pageLabel: HTMLSpanElement | null = null + let zoomLabel: HTMLSpanElement | null = null + let prevBtn: HTMLButtonElement | null = null + let nextBtn: HTMLButtonElement | null = null + + // --- Navigation group --- + if (config.navigation !== false) { + const navGroup = el('div', 'dv-toolbar-group') + + prevBtn = el('button', 'dv-toolbar-btn') + prevBtn.title = 'Previous page' + prevBtn.appendChild(svgIcon(icons.chevronLeft)) + prevBtn.addEventListener('click', actions.onPrevPage) + + pageInput = el('input', 'dv-page-input') as HTMLInputElement + pageInput.type = 'text' + pageInput.inputMode = 'numeric' + pageInput.value = String(initialState.currentPage) + pageInput.title = 'Go to page' + pageInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + const val = parseInt(pageInput!.value, 10) + if (!isNaN(val)) actions.onPageInput(val) + } + }) + pageInput.addEventListener('blur', () => { + pageInput!.value = String(initialState.currentPage) + }) + + pageLabel = el('span', 'dv-toolbar-label') + pageLabel.textContent = `/ ${initialState.totalPages}` + + nextBtn = el('button', 'dv-toolbar-btn') + nextBtn.title = 'Next page' + nextBtn.appendChild(svgIcon(icons.chevronRight)) + nextBtn.addEventListener('click', actions.onNextPage) + + navGroup.append(prevBtn, pageInput, pageLabel, nextBtn) + toolbar.appendChild(navGroup) + } + + // --- Info (filename/format) --- + if (config.info !== false && initialState.documentInfo?.filename) { + const sep = el('div', 'dv-toolbar-separator') + const infoLabel = el('span', 'dv-toolbar-label') + infoLabel.textContent = initialState.documentInfo.filename + infoLabel.title = initialState.documentInfo.filename + toolbar.append(sep, infoLabel) + } + + // Spacer + toolbar.appendChild(el('div', 'dv-toolbar-spacer')) + + // --- Zoom group --- + if (config.zoom !== false) { + const zoomGroup = el('div', 'dv-toolbar-group') + + const zoomOutBtn = el('button', 'dv-toolbar-btn') + zoomOutBtn.title = 'Zoom out' + zoomOutBtn.appendChild(svgIcon(icons.zoomOut)) + zoomOutBtn.addEventListener('click', actions.onZoomOut) + + zoomLabel = el('span', 'dv-toolbar-label') + zoomLabel.textContent = `${Math.round(initialState.zoom * 100)}%` + + const zoomInBtn = el('button', 'dv-toolbar-btn') + zoomInBtn.title = 'Zoom in' + zoomInBtn.appendChild(svgIcon(icons.zoomIn)) + zoomInBtn.addEventListener('click', actions.onZoomIn) + + const fitWidthBtn = el('button', 'dv-toolbar-btn') + fitWidthBtn.title = 'Fit width' + fitWidthBtn.appendChild(svgIcon(icons.fitWidth)) + fitWidthBtn.addEventListener('click', actions.onFitWidth) + + zoomGroup.append(zoomOutBtn, zoomLabel, zoomInBtn, fitWidthBtn) + toolbar.appendChild(zoomGroup) + } + + // --- Fullscreen --- + if (config.fullscreen !== false) { + const sep = el('div', 'dv-toolbar-separator') + const fsBtn = el('button', 'dv-toolbar-btn') + fsBtn.title = 'Toggle fullscreen' + fsBtn.appendChild(svgIcon(icons.fullscreen)) + fsBtn.addEventListener('click', actions.onFullscreen) + toolbar.append(sep, fsBtn) + } + + // --- Download --- + if (config.download && actions.onDownload) { + const dlBtn = el('button', 'dv-toolbar-btn') + dlBtn.title = 'Download' + dlBtn.appendChild(svgIcon(icons.download)) + dlBtn.addEventListener('click', actions.onDownload) + toolbar.appendChild(dlBtn) + } + + return { + element: toolbar, + updateState(state: DocViewState) { + if (pageInput) pageInput.value = String(state.currentPage) + if (pageLabel) pageLabel.textContent = `/ ${state.totalPages}` + if (zoomLabel) zoomLabel.textContent = `${Math.round(state.zoom * 100)}%` + if (prevBtn) prevBtn.disabled = state.currentPage <= 1 + if (nextBtn) nextBtn.disabled = state.currentPage >= state.totalPages + }, + destroy() { + toolbar.remove() + }, + } +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 0000000..239f8e6 --- /dev/null +++ b/packages/core/src/types.ts @@ -0,0 +1,433 @@ +// --------------------------------------------------------------------------- +// Document Sources — what consumers pass in to tell DocView what to render +// --------------------------------------------------------------------------- + +/** A direct file provided as binary data. */ +export interface FileSource { + type: 'file' + /** The file content as a Blob, ArrayBuffer, or Uint8Array. */ + data: Blob | ArrayBuffer | Uint8Array + /** MIME type override. If omitted, detected from filename or data. */ + mimeType?: string + /** Original filename, used for format detection and display. */ + filename?: string +} + +/** A URL pointing to a remotely-hosted document. */ +export interface UrlSource { + type: 'url' + /** URL to fetch the document from. */ + url: string + /** MIME type override. If omitted, detected from URL extension or response headers. */ + mimeType?: string + /** Display filename. If omitted, derived from the URL path. */ + filename?: string + /** Custom fetch options (headers, credentials, etc.). */ + fetchOptions?: RequestInit +} + +/** Pre-rendered page images — for browsing without the original document. */ +export interface PagesSource { + type: 'pages' + /** Direct page data array, OR a fetch adapter for lazy loading. */ + pages: PageData[] | PageFetchAdapter + /** Optional text layer data for search and copy/paste over images. */ + textLayer?: TextLayerData[] | TextFetchAdapter +} + +/** + * Chunked document source — for streaming large files piece by piece. + * Can provide PDF chunks (for full-fidelity rendering) and/or browse page + * images (for fast initial display while chunks load). + */ +export interface ChunkedSource { + type: 'chunked' + /** PDF chunks for high-quality streaming, OR a fetch adapter. */ + chunks: ChunkData[] | ChunkFetchAdapter + /** Total page count across all chunks. */ + totalPages: number + /** Optional pre-rendered page images as fast fallback while chunks load. */ + browsePages?: PageData[] | PageFetchAdapter + /** Optional text layer for browse pages. */ + textLayer?: TextLayerData[] | TextFetchAdapter +} + +/** Union of all document source types. */ +export type DocumentSource = FileSource | UrlSource | PagesSource | ChunkedSource + + +// --------------------------------------------------------------------------- +// Page & Chunk Data — individual units of content +// --------------------------------------------------------------------------- + +/** A single pre-rendered page image. */ +export interface PageData { + /** 1-indexed page number. */ + pageNumber: number + /** URL to the page image (mutually exclusive with imageBlob). */ + imageUrl?: string + /** Blob containing the page image (mutually exclusive with imageUrl). */ + imageBlob?: Blob + /** Image width in pixels. */ + width: number + /** Image height in pixels. */ + height: number +} + +/** A chunk of a PDF or other paginated document. */ +export interface ChunkData { + /** Binary data of the chunk (a valid, renderable PDF for PDF chunks). */ + data: ArrayBuffer | Blob + /** First page in this chunk (1-indexed). */ + pageStart: number + /** Last page in this chunk (1-indexed, inclusive). */ + pageEnd: number +} + +/** Extracted text content for a single page, enabling search and copy/paste. */ +export interface TextLayerData { + /** 1-indexed page number this text belongs to. */ + pageNumber: number + /** Array of text items with position information. */ + items: TextItem[] +} + +/** A single text fragment with position data for overlay rendering. */ +export interface TextItem { + /** The text string. */ + str: string + /** X position as fraction of page width (0–1). */ + x: number + /** Y position as fraction of page height (0–1). */ + y: number + /** Width as fraction of page width. */ + width: number + /** Height as fraction of page height. */ + height: number + /** Font size in points (optional). */ + fontSize?: number +} + + +// --------------------------------------------------------------------------- +// Fetch Adapters — for lazy-loading pages and chunks on demand +// --------------------------------------------------------------------------- + +/** Adapter for lazily fetching individual page images. */ +export interface PageFetchAdapter { + /** Total number of pages in the document. */ + totalPages: number + /** Fetch a single page image by 1-indexed page number. */ + fetchPage(pageNumber: number): Promise + /** Optional batch fetch for a range of pages (inclusive). */ + fetchRange?(startPage: number, endPage: number): Promise + /** Optional: called when adapter is no longer needed, to clean up. */ + dispose?(): void +} + +/** Adapter for lazily fetching document chunks (e.g., split PDFs). */ +export interface ChunkFetchAdapter { + /** Total number of chunks. */ + totalChunks: number + /** Total number of pages across all chunks. */ + totalPages: number + /** Fetch a chunk by 0-indexed chunk index. */ + fetchChunk(index: number): Promise + /** Given a 1-indexed page number, return the chunk index that contains it. */ + getChunkIndexForPage(pageNumber: number): number + /** Optional: called when adapter is no longer needed. */ + dispose?(): void +} + +/** Adapter for lazily fetching text layer data. */ +export interface TextFetchAdapter { + /** Fetch text layer for a single page. */ + fetchPageText(pageNumber: number): Promise + /** Optional batch fetch. */ + fetchRange?(startPage: number, endPage: number): Promise + /** Optional dispose. */ + dispose?(): void +} + + +// --------------------------------------------------------------------------- +// Options — configuration for the DocView instance +// --------------------------------------------------------------------------- + +export interface DocViewOptions { + /** The document to render. */ + source: DocumentSource + /** Explicit format override. If omitted, auto-detected from source. */ + format?: DocumentFormat + /** Color theme. Defaults to 'dark'. */ + theme?: 'light' | 'dark' | 'system' + /** Additional CSS class(es) to add to the root container. */ + className?: string + /** Page to display initially (1-indexed). Defaults to 1. */ + initialPage?: number + /** Initial zoom level. Number = scale factor, string = fit mode. */ + zoom?: number | 'fit-width' | 'fit-page' | 'auto' + + /** Toolbar configuration. `true` = default toolbar, `false` = hidden. */ + toolbar?: boolean | ToolbarConfig + /** Show page number / total in the toolbar or overlay. */ + showPageNumbers?: boolean + + // --- Callbacks --- + + /** Called when the document is loaded and first page is rendered. */ + onReady?: (info: DocumentInfo) => void + /** Called when the visible page changes. */ + onPageChange?: (page: number, totalPages: number) => void + /** Called on unrecoverable errors. */ + onError?: (error: DocViewError) => void + /** Called when zoom level changes. */ + onZoomChange?: (zoom: number) => void + /** Called when loading state changes. */ + onLoadingChange?: (loading: boolean) => void + + // --- Format-specific options --- + + pdf?: PdfOptions + code?: CodeOptions + csv?: CsvOptions + epub?: EpubOptions + odt?: OdtOptions + ods?: OdsOptions +} + +export interface PdfOptions { + /** URL to the pdf.js worker script. If omitted, uses bundled worker or CDN. */ + workerSrc?: string + /** URL to the character map files directory. */ + cMapUrl?: string + /** Whether to render the transparent text layer for selection. Default true. */ + textLayer?: boolean + /** Whether to render the annotation layer. Default false. */ + annotationLayer?: boolean +} + +export interface CodeOptions { + /** Language identifier for syntax highlighting (e.g., 'typescript', 'python'). */ + language?: string + /** Show line numbers. Default true. */ + lineNumbers?: boolean + /** Enable word wrapping. Default false. */ + wordWrap?: boolean + /** Tab size in spaces. Default 2. */ + tabSize?: number +} + +export interface CsvOptions { + /** Field delimiter character. Default auto-detected, falling back to ','. */ + delimiter?: string + /** Whether the first row is a header. Default true. */ + header?: boolean + /** Maximum number of rows to render. Default 10000. */ + maxRows?: number + /** Enable column sorting. Default true. */ + sortable?: boolean +} + +export interface EpubOptions { + /** Flow mode: paginated (book-like) or scrolled. Default 'paginated'. */ + flow?: 'paginated' | 'scrolled' + /** Font size in pixels. Default 16. */ + fontSize?: number + /** Font family override. */ + fontFamily?: string +} + +export interface OdtOptions { + /** Font size in pixels to use as a base. Default 16. */ + fontSize?: number + /** Font family override. */ + fontFamily?: string +} + +export interface OdsOptions { + /** Maximum number of rows to render per sheet. Default 10000. */ + maxRows?: number + /** Enable column sorting. Default true. */ + sortable?: boolean + /** Whether the first row is a header. Default true. */ + header?: boolean +} + +export interface ToolbarConfig { + /** Show page navigation controls. Default true. */ + navigation?: boolean + /** Show zoom controls. Default true. */ + zoom?: boolean + /** Show format/filename display. Default true. */ + info?: boolean + /** Show download button (if source is downloadable). Default false. */ + download?: boolean + /** Show fullscreen toggle. Default true. */ + fullscreen?: boolean + /** Toolbar position. Default 'top'. */ + position?: 'top' | 'bottom' +} + + +// --------------------------------------------------------------------------- +// Document Info & State +// --------------------------------------------------------------------------- + +/** Information about a loaded document, provided via the onReady callback. */ +export interface DocumentInfo { + /** Detected or overridden format. */ + format: DocumentFormat + /** Total number of pages (or 1 for non-paginated formats). */ + pageCount: number + /** Document title if available from metadata. */ + title?: string + /** Document author if available from metadata. */ + author?: string + /** Original filename if known. */ + filename?: string + /** File size in bytes if known. */ + fileSize?: number +} + +/** Current viewer state, queryable from a DocView instance. */ +export interface DocViewState { + /** Whether the document is currently loading. */ + loading: boolean + /** Current error, if any. */ + error: DocViewError | null + /** Current 1-indexed page number. */ + currentPage: number + /** Total page count. */ + totalPages: number + /** Current zoom scale factor. */ + zoom: number + /** Loaded document info (null until onReady fires). */ + documentInfo: DocumentInfo | null +} + + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +export type DocViewErrorCode = + | 'FORMAT_UNSUPPORTED' + | 'FORMAT_DETECTION_FAILED' + | 'PEER_DEPENDENCY_MISSING' + | 'SOURCE_LOAD_FAILED' + | 'RENDER_FAILED' + | 'CHUNK_LOAD_FAILED' + | 'PAGE_OUT_OF_RANGE' + | 'UNKNOWN' + +export class DocViewError extends Error { + code: DocViewErrorCode + detail?: unknown + + constructor(code: DocViewErrorCode, message: string, detail?: unknown) { + super(message) + this.name = 'DocViewError' + this.code = code + this.detail = detail + } +} + + +// --------------------------------------------------------------------------- +// Format Types +// --------------------------------------------------------------------------- + +export type DocumentFormat = + | 'pdf' + | 'epub' + | 'docx' + | 'odt' + | 'ods' + | 'csv' + | 'tsv' + | 'code' + | 'text' + | 'markdown' + | 'html' + | 'json' + | 'xml' + | 'pages' // pre-rendered page images (no original document) + | 'chunked-pdf' // chunked PDF streaming + | (string & {}) // open union — consumers can register custom formats + + +// --------------------------------------------------------------------------- +// Renderer Interface — implemented by each format renderer +// --------------------------------------------------------------------------- + +/** + * The contract every format renderer must fulfill. Each renderer manages + * its own DOM subtree within the provided container element. The DocView + * orchestrator calls these methods in response to user actions and source + * changes. + */ +export interface Renderer { + /** Unique format identifier this renderer handles. */ + readonly format: DocumentFormat + + /** + * Initialize and render the document into the container. + * Called once after the renderer is created. The container is an empty div + * scoped to this renderer — the renderer owns its entire DOM subtree. + */ + mount(container: HTMLElement, options: DocViewOptions): Promise + + /** + * React to changed options (theme, zoom, etc.) without full re-mount. + * Only the changed fields will be present. + */ + update(changed: Partial): Promise + + /** Navigate to a specific page (1-indexed). */ + goToPage(page: number): void + + /** Get total page count. Returns 1 for non-paginated formats. */ + getPageCount(): number + + /** Get current page number (1-indexed). */ + getCurrentPage(): number + + /** Set zoom level. Accepts a scale factor or fit mode string. */ + setZoom(zoom: number | 'fit-width' | 'fit-page'): void + + /** Get current zoom as a numeric scale factor. */ + getZoom(): number + + /** Perform a text search within the document. Returns match count. */ + search?(query: string): Promise + + /** Navigate to the next/previous search result. */ + nextSearchResult?(direction: 'forward' | 'backward'): void + + /** Clean up all DOM, event listeners, and resources. */ + destroy(): void +} + +/** + * Factory function that creates a renderer instance. Registered in the + * format registry so DocView can instantiate the right renderer for each + * document format. + */ +export type RendererFactory = () => Renderer + + +// --------------------------------------------------------------------------- +// Events (for vanilla JS event-driven usage) +// --------------------------------------------------------------------------- + +export interface DocViewEventMap { + ready: DocumentInfo + pagechange: { page: number; totalPages: number } + zoomchange: { zoom: number } + error: DocViewError + loadingchange: { loading: boolean } + destroy: void +} + +export type DocViewEventType = keyof DocViewEventMap diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts new file mode 100644 index 0000000..c546b68 --- /dev/null +++ b/packages/core/src/utils.ts @@ -0,0 +1,305 @@ +import type { DocumentSource, DocumentFormat, DocViewError } from './types.js' +import { DocViewError as DVError } from './types.js' + +// --------------------------------------------------------------------------- +// DOM Helpers +// --------------------------------------------------------------------------- + +/** Create an element with optional class and attributes. */ +export function el( + tag: K, + className?: string, + attrs?: Record, +): HTMLElementTagNameMap[K] { + const element = document.createElement(tag) + if (className) element.className = className + if (attrs) { + for (const [k, v] of Object.entries(attrs)) { + element.setAttribute(k, v) + } + } + return element +} + +/** Create an SVG icon from a path string (16x16 viewBox). */ +export function svgIcon(pathD: string): SVGSVGElement { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + svg.setAttribute('viewBox', '0 0 16 16') + svg.setAttribute('fill', 'none') + svg.setAttribute('stroke', 'currentColor') + svg.setAttribute('stroke-width', '1.5') + svg.setAttribute('stroke-linecap', 'round') + svg.setAttribute('stroke-linejoin', 'round') + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') + path.setAttribute('d', pathD) + svg.appendChild(path) + return svg +} + +/** Common SVG icon paths (16x16 coordinate space). */ +export const icons = { + chevronLeft: 'M10 3 L5 8 L10 13', + chevronRight: 'M6 3 L11 8 L6 13', + zoomIn: 'M7.5 3v9M3 7.5h9M12.5 12.5 L15 15', + zoomOut: 'M3 7.5h9M12.5 12.5 L15 15', + fitWidth: 'M1 4h14M1 12h14M4 1v3M4 12v3M12 1v3M12 12v3', + fullscreen: 'M2 5V2h3M11 2h3v3M14 11v3h-3M5 14H2v-3', + download: 'M8 2v8M4 7l4 4 4-4M3 13h10', +} as const + +/** Remove all child nodes from an element. */ +export function clearElement(el: HTMLElement): void { + while (el.firstChild) el.removeChild(el.firstChild) +} + + +// --------------------------------------------------------------------------- +// Format Detection +// --------------------------------------------------------------------------- + +const EXTENSION_MAP: Record = { + pdf: 'pdf', + epub: 'epub', + docx: 'docx', + doc: 'docx', + odt: 'odt', + ods: 'ods', + csv: 'csv', + tsv: 'tsv', + txt: 'text', + text: 'text', + md: 'markdown', + markdown: 'markdown', + html: 'html', + htm: 'html', + json: 'json', + xml: 'xml', + svg: 'xml', + // Common code extensions + js: 'code', jsx: 'code', ts: 'code', tsx: 'code', mjs: 'code', cjs: 'code', + py: 'code', rb: 'code', rs: 'code', go: 'code', java: 'code', kt: 'code', + c: 'code', h: 'code', cpp: 'code', hpp: 'code', cc: 'code', + cs: 'code', swift: 'code', m: 'code', + php: 'code', pl: 'code', r: 'code', lua: 'code', zig: 'code', + sh: 'code', bash: 'code', zsh: 'code', fish: 'code', ps1: 'code', + sql: 'code', graphql: 'code', gql: 'code', + yaml: 'code', yml: 'code', toml: 'code', ini: 'code', env: 'code', + dockerfile: 'code', makefile: 'code', + css: 'code', scss: 'code', sass: 'code', less: 'code', + vue: 'code', svelte: 'code', astro: 'code', + hs: 'code', elm: 'code', ex: 'code', exs: 'code', erl: 'code', + clj: 'code', cljs: 'code', lisp: 'code', scm: 'code', + dart: 'code', scala: 'code', groovy: 'code', + proto: 'code', thrift: 'code', + tf: 'code', hcl: 'code', + sol: 'code', move: 'code', + wasm: 'code', wat: 'code', +} + +const MIME_MAP: Record = { + 'application/pdf': 'pdf', + 'application/epub+zip': 'epub', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', + 'application/msword': 'docx', + 'application/vnd.oasis.opendocument.text': 'odt', + 'application/vnd.oasis.opendocument.spreadsheet': 'ods', + 'text/csv': 'csv', + 'text/tab-separated-values': 'tsv', + 'text/plain': 'text', + 'text/markdown': 'markdown', + 'text/html': 'html', + 'application/json': 'json', + 'application/xml': 'xml', + 'text/xml': 'xml', + 'image/svg+xml': 'xml', + 'application/javascript': 'code', + 'text/javascript': 'code', + 'application/typescript': 'code', + 'text/x-python': 'code', + 'text/x-rust': 'code', + 'text/x-go': 'code', + 'text/x-java-source': 'code', + 'text/x-c': 'code', + 'text/x-c++src': 'code', + 'text/css': 'code', + 'text/x-yaml': 'code', + 'text/x-toml': 'code', + 'text/x-shellscript': 'code', + 'application/x-sh': 'code', + 'text/x-sql': 'code', +} + +/** Map file extension to highlight.js language identifier. */ +const EXTENSION_TO_LANGUAGE: Record = { + js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript', + ts: 'typescript', tsx: 'typescript', + py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java', + kt: 'kotlin', c: 'c', h: 'c', cpp: 'cpp', hpp: 'cpp', cc: 'cpp', + cs: 'csharp', swift: 'swift', m: 'objectivec', + php: 'php', pl: 'perl', r: 'r', lua: 'lua', + sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash', ps1: 'powershell', + sql: 'sql', graphql: 'graphql', + yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini', + dockerfile: 'dockerfile', makefile: 'makefile', + css: 'css', scss: 'scss', sass: 'scss', less: 'less', + html: 'html', htm: 'html', xml: 'xml', svg: 'xml', + json: 'json', md: 'markdown', markdown: 'markdown', + vue: 'html', svelte: 'html', astro: 'html', + hs: 'haskell', elm: 'elm', ex: 'elixir', exs: 'elixir', erl: 'erlang', + clj: 'clojure', cljs: 'clojure', lisp: 'lisp', scm: 'scheme', + dart: 'dart', scala: 'scala', groovy: 'groovy', + proto: 'protobuf', tf: 'hcl', sol: 'solidity', +} + +/** Extract file extension from a filename or URL path. */ +export function getExtension(filenameOrUrl: string): string { + const clean = filenameOrUrl.split('?')[0].split('#')[0] + const lastSlash = clean.lastIndexOf('/') + const basename = lastSlash >= 0 ? clean.slice(lastSlash + 1) : clean + const dot = basename.lastIndexOf('.') + if (dot < 0) return basename.toLowerCase() // no extension, use whole name (e.g., "Makefile") + return basename.slice(dot + 1).toLowerCase() +} + +/** Detect document format from a source descriptor. */ +export function detectFormat(source: DocumentSource): DocumentFormat | null { + // Pages and chunked sources have explicit types + if (source.type === 'pages') return 'pages' + if (source.type === 'chunked') return 'chunked-pdf' + + // Try MIME type first + const mime = 'mimeType' in source ? source.mimeType : undefined + if (mime && MIME_MAP[mime]) return MIME_MAP[mime] + + // Try filename / URL extension + let name: string | undefined + if ('filename' in source) name = source.filename + if (!name && source.type === 'url') name = source.url + + if (name) { + const ext = getExtension(name) + if (EXTENSION_MAP[ext]) return EXTENSION_MAP[ext] + } + + return null +} + +/** Get highlight.js language from file extension. */ +export function getLanguageFromExtension(filename: string): string | undefined { + const ext = getExtension(filename) + return EXTENSION_TO_LANGUAGE[ext] +} + +/** Determine which underlying renderer format to use. Some formats alias to the same renderer. */ +export function getRendererFormat(format: DocumentFormat): DocumentFormat { + switch (format) { + case 'markdown': + case 'html': + case 'json': + case 'xml': + // These are rendered as code with language-specific highlighting + return 'code' + case 'tsv': + return 'csv' + case 'chunked-pdf': + return 'chunked-pdf' + case 'pages': + return 'pages' + default: + return format + } +} + + +// --------------------------------------------------------------------------- +// Data Conversion +// --------------------------------------------------------------------------- + +/** Convert various binary types to ArrayBuffer. */ +export async function toArrayBuffer( + data: Blob | ArrayBuffer | Uint8Array, +): Promise { + if (data instanceof ArrayBuffer) return data + if (data instanceof Uint8Array) return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer + return data.arrayBuffer() +} + +/** Convert various binary types to Blob. */ +export function toBlob( + data: Blob | ArrayBuffer | Uint8Array, + mimeType = 'application/octet-stream', +): Blob { + if (data instanceof Blob) return data + return new Blob([data as BlobPart], { type: mimeType }) +} + +/** Read binary data as UTF-8 text. */ +export async function toText(data: Blob | ArrayBuffer | Uint8Array): Promise { + if (data instanceof Blob) return data.text() + const decoder = new TextDecoder('utf-8') + return decoder.decode(data instanceof ArrayBuffer ? data : data.buffer) +} + +/** Fetch a URL as ArrayBuffer with optional custom fetch options. */ +export async function fetchAsBuffer( + url: string, + options?: RequestInit, +): Promise { + const response = await fetch(url, options) + if (!response.ok) { + throw new DVError( + 'SOURCE_LOAD_FAILED', + `Failed to fetch ${url}: ${response.status} ${response.statusText}`, + ) + } + return response.arrayBuffer() +} + + +// --------------------------------------------------------------------------- +// Peer Dependency Loading +// --------------------------------------------------------------------------- + +/** + * Attempt a dynamic import and throw a helpful error if the module is missing. + * Each renderer calls this for its peer dependency. + */ +export async function requirePeerDep( + moduleName: string, + formatName: string, +): Promise { + try { + const mod = await import(/* @vite-ignore */ moduleName) + return mod as T + } catch { + throw new DVError( + 'PEER_DEPENDENCY_MISSING', + `The "${moduleName}" package is required to render ${formatName} files. ` + + `Install it with: npm install ${moduleName}`, + ) + } +} + + +// --------------------------------------------------------------------------- +// Misc +// --------------------------------------------------------------------------- + +/** Clamp a number between min and max. */ +export function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)) +} + +/** Debounce a function. */ +export function debounce void>( + fn: T, + ms: number, +): T & { cancel(): void } { + let timer: ReturnType + const debounced = ((...args: unknown[]) => { + clearTimeout(timer) + timer = setTimeout(() => fn(...args), ms) + }) as T & { cancel(): void } + debounced.cancel = () => clearTimeout(timer) + return debounced +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..8f24167 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts new file mode 100644 index 0000000..37d02fe --- /dev/null +++ b/packages/core/tsup.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + sourcemap: true, + clean: true, + external: [ + 'pdfjs-dist', + 'epubjs', + 'docx-preview', + 'papaparse', + 'highlight.js', + 'jszip', + 'xlsx', + ], + esbuildOptions(options) { + options.loader = { ...options.loader, '.css': 'copy' } + }, +}) diff --git a/packages/react/LICENSE b/packages/react/LICENSE new file mode 100644 index 0000000..770f53d --- /dev/null +++ b/packages/react/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2026 Aria Quinlan + +This software is provided ‘as-is’, without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not +claim that you wrote the original software. If you use this software +in a product, an acknowledgment in the product documentation would be +appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not be +misrepresented as being the original software. + +3. This notice may not be removed or altered from any source +distribution. \ No newline at end of file diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 0000000..7ee4e98 --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,57 @@ +{ + "name": "@docview/react", + "version": "0.1.0", + "description": "React components for the DocView universal document renderer", + "license": "Zlib", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@docview/core": "workspace:*" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "tsup": "^8.0.0", + "typescript": "^5.5.0" + }, + "keywords": [ + "react", + "pdf", + "epub", + "docx", + "csv", + "odt", + "document", + "viewer", + "component", + "renderer", + "reader", + "chunked" + ] +} \ No newline at end of file diff --git a/packages/react/src/DocumentViewer.tsx b/packages/react/src/DocumentViewer.tsx new file mode 100644 index 0000000..ba10b50 --- /dev/null +++ b/packages/react/src/DocumentViewer.tsx @@ -0,0 +1,191 @@ +import { forwardRef, useImperativeHandle, useMemo } from 'react' +import type { + DocumentSource, + DocumentFormat, + DocumentInfo, + DocViewError, + ToolbarConfig, + PdfOptions, + CodeOptions, + CsvOptions, + EpubOptions, +} from '@docview/core' +import { useDocumentRenderer } from './useDocumentRenderer.js' + +export interface DocumentViewerProps { + /** The document to render. Pass null/undefined to show nothing. */ + source: DocumentSource | null | undefined + + /** Explicit format override. If omitted, auto-detected from source. */ + format?: DocumentFormat + + /** Color theme. Defaults to 'dark'. */ + theme?: 'light' | 'dark' | 'system' + + /** Additional CSS class(es) for the root container. */ + className?: string + + /** Inline styles for the outer wrapper div (set width/height here). */ + style?: React.CSSProperties + + /** Page to display initially (1-indexed). Defaults to 1. */ + initialPage?: number + + /** Initial zoom level. */ + zoom?: number | 'fit-width' | 'fit-page' | 'auto' + + /** Toolbar configuration. true = default, false = hidden. */ + toolbar?: boolean | ToolbarConfig + + /** Show page numbers. */ + showPageNumbers?: boolean + + // --- Callbacks --- + onReady?: (info: DocumentInfo) => void + onPageChange?: (page: number, totalPages: number) => void + onZoomChange?: (zoom: number) => void + onError?: (error: DocViewError) => void + onLoadingChange?: (loading: boolean) => void + + // --- Format-specific options --- + pdf?: PdfOptions + code?: CodeOptions + csv?: CsvOptions + epub?: EpubOptions +} + +export interface DocumentViewerRef { + /** Navigate to a specific page (1-indexed). */ + goToPage: (page: number) => void + /** Set zoom level. */ + setZoom: (zoom: number | 'fit-width' | 'fit-page') => void + /** Get current page number. */ + getCurrentPage: () => number + /** Get total page count. */ + getPageCount: () => number + /** Get current zoom level. */ + getZoom: () => number +} + +/** + * React component for rendering documents of any supported format. + * + * Wraps the framework-agnostic `@docview/core` library with React lifecycle + * management, ref-based imperative API, and automatic cleanup. + * + * @example + * ```tsx + * import { DocumentViewer } from '@docview/react' + * import '@docview/core/styles.css' + * + * function App() { + * return ( + * console.log(`Loaded ${info.pageCount} pages`)} + * /> + * ) + * } + * ``` + * + * @example Chunked / pre-rendered pages + * ```tsx + * ({ + * pageNumber: n, + * imageUrl: `/api/pages/${n}.webp`, + * width: 1654, + * height: 2339, + * }), + * }, + * }} + * /> + * ``` + * + * @example Imperative control via ref + * ```tsx + * const viewerRef = useRef(null) + * + * + * + * ``` + */ +export const DocumentViewer = forwardRef( + function DocumentViewer(props, ref) { + const { + source, + format, + theme, + className, + style, + initialPage, + zoom, + toolbar, + showPageNumbers, + onReady, + onPageChange, + onZoomChange, + onError, + onLoadingChange, + pdf, + code, + csv, + epub, + } = props + + const { + containerRef, + state, + goToPage, + setZoom, + } = useDocumentRenderer({ + source: source ?? undefined, + format, + theme, + className, + initialPage, + zoom, + toolbar, + showPageNumbers, + onReady, + onPageChange, + onZoomChange, + onError, + onLoadingChange, + pdf, + code, + csv, + epub, + }) + + // Expose imperative API via ref + useImperativeHandle(ref, () => ({ + goToPage, + setZoom, + getCurrentPage: () => state.currentPage, + getPageCount: () => state.totalPages, + getZoom: () => state.zoom, + }), [goToPage, setZoom, state.currentPage, state.totalPages, state.zoom]) + + const containerStyle = useMemo(() => ({ + width: '100%', + height: '100%', + minHeight: 200, + ...style, + }), [style]) + + return ( +
    + ) + }, +) diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts new file mode 100644 index 0000000..ccb7388 --- /dev/null +++ b/packages/react/src/index.ts @@ -0,0 +1,38 @@ +// Components +export { DocumentViewer } from './DocumentViewer.js' +export type { DocumentViewerProps, DocumentViewerRef } from './DocumentViewer.js' + +// Hook +export { useDocumentRenderer } from './useDocumentRenderer.js' +export type { UseDocumentRendererOptions, UseDocumentRendererReturn } from './useDocumentRenderer.js' + +// Re-export core types for convenience (so consumers don't need to +// install @docview/core separately just for types) +export type { + DocumentSource, + FileSource, + UrlSource, + PagesSource, + ChunkedSource, + PageData, + ChunkData, + TextLayerData, + TextItem, + PageFetchAdapter, + ChunkFetchAdapter, + TextFetchAdapter, + DocViewOptions, + PdfOptions, + CodeOptions, + CsvOptions, + EpubOptions, + ToolbarConfig, + DocumentInfo, + DocViewState, + DocumentFormat, + DocViewEventMap, + DocViewEventType, + DocViewErrorCode, +} from '@docview/core' + +export { DocViewError, DocView } from '@docview/core' diff --git a/packages/react/src/useDocumentRenderer.ts b/packages/react/src/useDocumentRenderer.ts new file mode 100644 index 0000000..d3f0e26 --- /dev/null +++ b/packages/react/src/useDocumentRenderer.ts @@ -0,0 +1,168 @@ +import { useRef, useEffect, useState, useCallback } from 'react' +import type { + DocViewOptions, + DocViewState, + DocumentInfo, + DocViewError, + DocumentSource, +} from '@docview/core' +import { DocView } from '@docview/core' + +export interface UseDocumentRendererOptions + extends Omit { + source: DocumentSource | null | undefined + onReady?: (info: DocumentInfo) => void + onPageChange?: (page: number, totalPages: number) => void + onZoomChange?: (zoom: number) => void + onError?: (error: DocViewError) => void + onLoadingChange?: (loading: boolean) => void +} + +export interface UseDocumentRendererReturn { + /** Ref to attach to the container div. */ + containerRef: React.RefObject + /** Current viewer state. */ + state: DocViewState + /** Navigate to a page. */ + goToPage: (page: number) => void + /** Set zoom level. */ + setZoom: (zoom: number | 'fit-width' | 'fit-page') => void + /** Whether the viewer is mounted and ready. */ + ready: boolean + /** Current error, if any. */ + error: DocViewError | null +} + +/** + * React hook for the DocView document renderer. + * + * Manages the lifecycle of a DocView instance, bridging its imperative API + * to React's declarative model. Handles mounting, updating, and cleanup. + * + * @example + * ```tsx + * function MyViewer({ url }: { url: string }) { + * const { containerRef, state, goToPage } = useDocumentRenderer({ + * source: { type: 'url', url }, + * theme: 'dark', + * }) + * + * return ( + *
    + *
    + *

    Page {state.currentPage} of {state.totalPages}

    + *
    + * ) + * } + * ``` + */ +export function useDocumentRenderer( + options: UseDocumentRendererOptions, +): UseDocumentRendererReturn { + const containerRef = useRef(null) + const instanceRef = useRef(null) + const optionsRef = useRef(options) + + const [state, setState] = useState({ + loading: true, + error: null, + currentPage: 1, + totalPages: 0, + zoom: 1, + documentInfo: null, + }) + + const [ready, setReady] = useState(false) + const [error, setError] = useState(null) + + // Keep options ref current + optionsRef.current = options + + // Mount / unmount effect + useEffect(() => { + const container = containerRef.current + if (!container || !options.source) return + + // Clear any previous instance + if (instanceRef.current) { + instanceRef.current.destroy() + instanceRef.current = null + } + + setReady(false) + setError(null) + setState((s) => ({ ...s, loading: true, error: null })) + + const instance = new DocView(container, { + ...options, + source: options.source, + onReady: (info) => { + setReady(true) + setState((s) => ({ + ...s, + loading: false, + totalPages: info.pageCount, + documentInfo: info, + })) + optionsRef.current.onReady?.(info) + }, + onPageChange: (page, totalPages) => { + setState((s) => ({ ...s, currentPage: page, totalPages })) + optionsRef.current.onPageChange?.(page, totalPages) + }, + onZoomChange: (zoom) => { + setState((s) => ({ ...s, zoom })) + optionsRef.current.onZoomChange?.(zoom) + }, + onError: (err) => { + setError(err) + setState((s) => ({ ...s, loading: false, error: err })) + optionsRef.current.onError?.(err) + }, + onLoadingChange: (loading) => { + setState((s) => ({ ...s, loading })) + optionsRef.current.onLoadingChange?.(loading) + }, + }) + + instanceRef.current = instance + + return () => { + instance.destroy() + instanceRef.current = null + } + // Re-mount when source identity changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [options.source, options.format, options.theme]) + + // Update non-source options without re-mounting + useEffect(() => { + if (!instanceRef.current) return + + const changed: Partial = {} + if (options.theme) changed.theme = options.theme + if (options.className !== undefined) changed.className = options.className + if (options.zoom !== undefined) changed.zoom = options.zoom + + if (Object.keys(changed).length > 0) { + instanceRef.current.update(changed) + } + }, [options.theme, options.className, options.zoom]) + + const goToPage = useCallback((page: number) => { + instanceRef.current?.goToPage(page) + }, []) + + const setZoom = useCallback((zoom: number | 'fit-width' | 'fit-page') => { + instanceRef.current?.setZoom(zoom) + }, []) + + return { + containerRef, + state, + goToPage, + setZoom, + ready, + error, + } +} diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json new file mode 100644 index 0000000..ae463e4 --- /dev/null +++ b/packages/react/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "jsx": "react-jsx" + }, + "include": ["src/**/*"] +} diff --git a/packages/react/tsup.config.ts b/packages/react/tsup.config.ts new file mode 100644 index 0000000..9039613 --- /dev/null +++ b/packages/react/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + sourcemap: true, + clean: true, + external: ['react', 'react-dom', '@docview/core'], + esbuildOptions(options) { + options.jsx = 'automatic' + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..7be79df --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1890 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + pdfjs-dist: + specifier: ^5.5.207 + version: 5.5.207 + typescript: + specifier: ^5.5.0 + version: 5.9.3 + + examples/basic: + dependencies: + '@docview/core': + specifier: workspace:* + version: link:../../packages/core + docx-preview: + specifier: '>=0.3.0' + version: 0.3.7 + epubjs: + specifier: '>=0.3.0' + version: 0.3.93 + highlight.js: + specifier: '>=11.0.0' + version: 11.11.1 + jszip: + specifier: '>=3.0.0' + version: 3.10.1 + papaparse: + specifier: '>=5.0.0' + version: 5.5.3 + pdfjs-dist: + specifier: '>=4.0.0' + version: 5.5.207 + xlsx: + specifier: '>=0.18.0' + version: 0.18.5 + devDependencies: + typescript: + specifier: ^5.5.0 + version: 5.9.3 + vite: + specifier: ^6.0.0 + version: 6.4.1 + + examples/vanilla: + dependencies: + '@docview/core': + specifier: workspace:* + version: link:../../packages/core + docx-preview: + specifier: '>=0.3.0' + version: 0.3.7 + epubjs: + specifier: '>=0.3.0' + version: 0.3.93 + highlight.js: + specifier: '>=11.0.0' + version: 11.11.1 + jszip: + specifier: '>=3.0.0' + version: 3.10.1 + papaparse: + specifier: '>=5.0.0' + version: 5.5.3 + pdfjs-dist: + specifier: '>=4.0.0' + version: 5.5.207 + xlsx: + specifier: '>=0.18.0' + version: 0.18.5 + devDependencies: + esbuild: + specifier: ^0.25.0 + version: 0.25.12 + typescript: + specifier: ^5.5.0 + version: 5.9.3 + + packages/core: + dependencies: + docx-preview: + specifier: '>=0.3.0' + version: 0.3.7 + epubjs: + specifier: '>=0.3.0' + version: 0.3.93 + highlight.js: + specifier: '>=11.0.0' + version: 11.11.1 + jszip: + specifier: '>=3.0.0' + version: 3.10.1 + papaparse: + specifier: '>=5.0.0' + version: 5.5.3 + pdfjs-dist: + specifier: '>=4.0.0' + version: 5.5.207 + xlsx: + specifier: '>=0.18.0' + version: 0.18.5 + devDependencies: + tsup: + specifier: ^8.0.0 + version: 8.5.1(postcss@8.5.8)(typescript@5.9.3) + typescript: + specifier: ^5.5.0 + version: 5.9.3 + + packages/react: + dependencies: + '@docview/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/react': + specifier: ^18.3.0 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.28) + react: + specifier: ^18.3.0 + version: 18.3.1 + react-dom: + specifier: ^18.3.0 + version: 18.3.1(react@18.3.1) + tsup: + specifier: ^8.0.0 + version: 8.5.1(postcss@8.5.8)(typescript@5.9.3) + typescript: + specifier: ^5.5.0 + version: 5.9.3 + +packages: + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/canvas-android-arm64@0.1.96': + resolution: {integrity: sha512-ew1sPrN3dGdZ3L4FoohPfnjq0f9/Jk7o+wP7HkQZokcXgIUD6FIyICEWGhMYzv53j63wUcPvZeAwgewX58/egg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.96': + resolution: {integrity: sha512-Q/wOXZ5PzTqpdmA5eUOcegCf4Go/zz3aZ5DlzSeDpOjFmfwMKh8EzLAoweQ+mJVagcHQyzoJhaTEnrO68TNyNg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.96': + resolution: {integrity: sha512-UrXiQz28tQEvGM1qvyptewOAfmUrrd5+wvi6Rzjj2VprZI8iZ2KIvBD2lTTG1bVF95AbeDeG7PJA0D9sLKaOFA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.96': + resolution: {integrity: sha512-I90ODxweD8aEP6XKU/NU+biso95MwCtQ2F46dUvhec1HesFi0tq/tAJkYic/1aBSiO/1kGKmSeD1B0duOHhEHQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.96': + resolution: {integrity: sha512-Dx/0+RFV++w3PcRy+4xNXkghhXjA5d0Mw1bs95emn5Llinp1vihMaA6WJt3oYv2LAHc36+gnrhIBsPhUyI2SGw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-arm64-musl@0.1.96': + resolution: {integrity: sha512-UvOi7fii3IE2KDfEfhh8m+LpzSRvhGK7o1eho99M2M0HTik11k3GX+2qgVx9EtujN3/bhFFS1kSO3+vPMaJ0Mg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.96': + resolution: {integrity: sha512-MBSukhGCQ5nRtf9NbFYWOU080yqkZU1PbuH4o1ROvB4CbPl12fchDR35tU83Wz8gWIM9JTn99lBn9DenPIv7Ig==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-gnu@0.1.96': + resolution: {integrity: sha512-I/ccu2SstyKiV3HIeVzyBIWfrJo8cN7+MSQZPnabewWV6hfJ2nY7Df2WqOHmobBRUw84uGR6zfQHsUEio/m5Vg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-musl@0.1.96': + resolution: {integrity: sha512-H3uov7qnTl73GDT4h52lAqpJPsl1tIUyNPWJyhQ6gHakohNqqRq3uf80+NEpzcytKGEOENP1wX3yGwZxhjiWEQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-win32-arm64-msvc@0.1.96': + resolution: {integrity: sha512-ATp6Y+djOjYtkfV/VRH7CZ8I1MEtkUQBmKUbuWw5zWEHHqfL0cEcInE4Cxgx7zkNAhEdBbnH8HMVrqNp+/gwxA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@0.1.96': + resolution: {integrity: sha512-UYGdTltVd+Z8mcIuoqGmAXXUvwH5CLf2M6mIB5B0/JmX5J041jETjqtSYl7gN+aj3k1by/SG6sS0hAwCqyK7zw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.96': + resolution: {integrity: sha512-6NNmNxvoJKeucVjxaaRUt3La2i5jShgiAbaY3G/72s1Vp3U06XPrAIxkAjBxpDcamEn/t+WJ4OOlGmvILo4/Ew==} + engines: {node: '>= 10'} + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/localforage@0.0.34': + resolution: {integrity: sha512-tJxahnjm9dEI1X+hQSC5f2BSd/coZaqbIl1m3TCl0q9SVuC52XcXfV0XmoCU1+PmjyucuVITwoTnN8OlTbEXXA==} + deprecated: This is a stub types definition for localforage (https://github.com/localForage/localForage). localforage provides its own type definitions, so you don't need @types/localforage installed! + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.28': + resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} + + '@xmldom/xmldom@0.7.13': + resolution: {integrity: sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==} + engines: {node: '>=10.0.0'} + deprecated: this version is no longer supported, please update to at least 0.8.* + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + core-js@3.48.0: + resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d@1.0.2: + resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} + engines: {node: '>=0.12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + docx-preview@0.3.7: + resolution: {integrity: sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==} + + epubjs@0.3.93: + resolution: {integrity: sha512-c06pNSdBxcXv3dZSbXAVLE1/pmleRhOT6mXNZo6INKmvuKpYB65MwU/lO7830czCtjIiK9i+KR+3S+p0wtljrw==} + + es5-ext@0.10.64: + resolution: {integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==} + engines: {node: '>=0.10'} + + es6-iterator@2.0.3: + resolution: {integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==} + + es6-symbol@3.1.4: + resolution: {integrity: sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==} + engines: {node: '>=0.12'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + esniff@2.0.1: + resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==} + engines: {node: '>=0.10'} + + event-emitter@0.3.5: + resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==} + + ext@1.7.0: + resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + lie@3.1.1: + resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + localforage@1.10.0: + resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==} + + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + marks-pane@1.0.9: + resolution: {integrity: sha512-Ahs4oeG90tbdPWwAJkAAoHg2lRR8lAs9mZXETNPO9hYg3AkjUJBKi1NQ4aaIQZVGrig7c/3NUV1jANl8rFTeMg==} + + mlly@1.8.1: + resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + next-tick@1.1.0: + resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} + + node-readable-to-web-readable-stream@0.4.2: + resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + papaparse@5.5.3: + resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} + + path-webpack@0.0.3: + resolution: {integrity: sha512-AmeDxedoo5svf7aB3FYqSAKqMxys014lVKBzy1o/5vv9CtU7U4wgGWL1dA2o6MOzcD53ScN4Jmiq6VbtLz1vIQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pdfjs-dist@5.5.207: + resolution: {integrity: sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==} + engines: {node: '>=20.19.0 || >=22.13.0 || >=24'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + type@2.7.3: + resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@6.4.1: + resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + + word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + + xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + engines: {node: '>=0.8'} + hasBin: true + +snapshots: + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/canvas-android-arm64@0.1.96': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.96': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.96': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.96': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.96': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.96': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.96': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.96': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.96': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@0.1.96': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.96': + optional: true + + '@napi-rs/canvas@0.1.96': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.96 + '@napi-rs/canvas-darwin-arm64': 0.1.96 + '@napi-rs/canvas-darwin-x64': 0.1.96 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.96 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.96 + '@napi-rs/canvas-linux-arm64-musl': 0.1.96 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.96 + '@napi-rs/canvas-linux-x64-gnu': 0.1.96 + '@napi-rs/canvas-linux-x64-musl': 0.1.96 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.96 + '@napi-rs/canvas-win32-x64-msvc': 0.1.96 + optional: true + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@types/estree@1.0.8': {} + + '@types/localforage@0.0.34': + dependencies: + localforage: 1.10.0 + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.28)': + dependencies: + '@types/react': 18.3.28 + + '@types/react@18.3.28': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@xmldom/xmldom@0.7.13': {} + + acorn@8.16.0: {} + + adler-32@1.3.1: {} + + any-promise@1.3.0: {} + + bundle-require@5.1.0(esbuild@0.27.3): + dependencies: + esbuild: 0.27.3 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + codepage@1.15.0: {} + + commander@4.1.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + core-js@3.48.0: {} + + core-util-is@1.0.3: {} + + crc-32@1.2.2: {} + + csstype@3.2.3: {} + + d@1.0.2: + dependencies: + es5-ext: 0.10.64 + type: 2.7.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + docx-preview@0.3.7: + dependencies: + jszip: 3.10.1 + + epubjs@0.3.93: + dependencies: + '@types/localforage': 0.0.34 + '@xmldom/xmldom': 0.7.13 + core-js: 3.48.0 + event-emitter: 0.3.5 + jszip: 3.10.1 + localforage: 1.10.0 + lodash: 4.17.23 + marks-pane: 1.0.9 + path-webpack: 0.0.3 + + es5-ext@0.10.64: + dependencies: + es6-iterator: 2.0.3 + es6-symbol: 3.1.4 + esniff: 2.0.1 + next-tick: 1.1.0 + + es6-iterator@2.0.3: + dependencies: + d: 1.0.2 + es5-ext: 0.10.64 + es6-symbol: 3.1.4 + + es6-symbol@3.1.4: + dependencies: + d: 1.0.2 + ext: 1.7.0 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + esniff@2.0.1: + dependencies: + d: 1.0.2 + es5-ext: 0.10.64 + event-emitter: 0.3.5 + type: 2.7.3 + + event-emitter@0.3.5: + dependencies: + d: 1.0.2 + es5-ext: 0.10.64 + + ext@1.7.0: + dependencies: + type: 2.7.3 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.1 + rollup: 4.59.0 + + frac@1.1.2: {} + + fsevents@2.3.3: + optional: true + + highlight.js@11.11.1: {} + + immediate@3.0.6: {} + + inherits@2.0.4: {} + + isarray@1.0.0: {} + + joycon@3.1.1: {} + + js-tokens@4.0.0: {} + + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + lie@3.1.1: + dependencies: + immediate: 3.0.6 + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + localforage@1.10.0: + dependencies: + lie: 3.1.1 + + lodash@4.17.23: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + marks-pane@1.0.9: {} + + mlly@1.8.1: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.11: {} + + next-tick@1.1.0: {} + + node-readable-to-web-readable-stream@0.4.2: + optional: true + + object-assign@4.1.1: {} + + pako@1.0.11: {} + + papaparse@5.5.3: {} + + path-webpack@0.0.3: {} + + pathe@2.0.3: {} + + pdfjs-dist@5.5.207: + optionalDependencies: + '@napi-rs/canvas': 0.1.96 + node-readable-to-web-readable-stream: 0.4.2 + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.1 + pathe: 2.0.3 + + postcss-load-config@6.0.1(postcss@8.5.8): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.8 + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + process-nextick-args@2.0.1: {} + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readdirp@4.1.2: {} + + resolve-from@5.0.0: {} + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + safe-buffer@5.1.2: {} + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + setimmediate@1.0.5: {} + + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + ssf@0.11.2: + dependencies: + frac: 1.1.2 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.15 + ts-interface-checker: 0.1.13 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + + tsup@8.5.1(postcss@8.5.8)(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.3) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.3 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(postcss@8.5.8) + resolve-from: 5.0.0 + rollup: 4.59.0 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.8 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + type@2.7.3: {} + + typescript@5.9.3: {} + + ufo@1.6.3: {} + + util-deprecate@1.0.2: {} + + vite@6.4.1: + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.8 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + fsevents: 2.3.3 + + wmf@1.0.2: {} + + word@0.3.0: {} + + xlsx@0.18.5: + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..89723f9 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "packages/*" + - "examples/*" diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..84ce6b7 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + } +}