diff --git a/astro.config.mjs b/astro.config.mjs index 2fc08f3..6a1ddd9 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -45,6 +45,18 @@ export default defineConfig({ rollupOptions: { // Tauri API imports are only available in Tauri context, externalize for web build external: [/^@tauri-apps\//], + output: { + // epub.js bundles from source (its `module` field is src/index.js) into a + // ~2 MB chunk that Rollup would otherwise name `index.*` — undetectable by + // the workbox globIgnore. Force stable names so the EPUB reader's heavy, + // lazily-imported deps stay out of the PWA precache (see globIgnores below). + // jszip is shared with docx-preview, so it gets its own chunk rather than + // being merged into epubjs. + manualChunks(id) { + if (id.includes('node_modules/epubjs')) return 'epubjs'; + if (id.includes('node_modules/jszip')) return 'jszip'; + }, + }, }, }, // mupdf/pdf.js workers use dynamic import() (code-splitting), which requires @@ -118,6 +130,8 @@ export default defineConfig({ '**/*huggingface*.js', '**/maplibre-gl*.js', '**/xlsx*.js', + '**/epubjs*.js', + '**/jszip*.js', 'og/*.png', ], runtimeCaching: [ diff --git a/package-lock.json b/package-lock.json index b32d6c6..c99242f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "comlink": "^4.4.2", "docx-preview": "^0.4.0", "dompurify": "^3.4.12", + "epubjs": "^0.3.93", "fast-xml-parser": "^5.10.0", "fflate": "^0.8.3", "gifenc": "^1.0.3", @@ -7754,6 +7755,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/localforage": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/@types/localforage/-/localforage-0.0.34.tgz", + "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!", + "license": "MIT", + "dependencies": { + "localforage": "*" + } + }, "node_modules/@types/lodash": { "version": "4.14.202", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.202.tgz", @@ -8383,6 +8394,15 @@ "integrity": "sha512-7LrhVKz2PRh+DD7+S+PVaFd5HxaWQvoMqBbsV9fNJO1pjUs1P8bM2vQVNfk+3URTqbuTI7gkXi0rfsN0IadoBA==", "license": "BSD-3-Clause" }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/@xyflow/react": { "version": "12.11.2", "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz", @@ -10062,6 +10082,19 @@ "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", "license": "MIT" }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/d3": { "version": "7.9.0", "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", @@ -10955,6 +10988,23 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/epubjs": { + "version": "0.3.93", + "resolved": "https://registry.npmjs.org/epubjs/-/epubjs-0.3.93.tgz", + "integrity": "sha512-c06pNSdBxcXv3dZSbXAVLE1/pmleRhOT6mXNZo6INKmvuKpYB65MwU/lO7830czCtjIiK9i+KR+3S+p0wtljrw==", + "license": "BSD-2-Clause", + "dependencies": { + "@types/localforage": "0.0.34", + "@xmldom/xmldom": "^0.7.5", + "core-js": "^3.18.3", + "event-emitter": "^0.3.5", + "jszip": "^3.7.1", + "localforage": "^1.10.0", + "lodash": "^4.17.21", + "marks-pane": "^1.0.9", + "path-webpack": "0.0.3" + } + }, "node_modules/error-stack-parser-es": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", @@ -11156,12 +11206,39 @@ "benchmarks" ] }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/es6-error": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", "license": "MIT" }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, "node_modules/es6-promise-pool": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/es6-promise-pool/-/es6-promise-pool-2.5.0.tgz", @@ -11171,6 +11248,19 @@ "node": ">=0.10.0" } }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -11683,6 +11773,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -11795,6 +11900,16 @@ "url": "https://github.com/bgub/eta?sponsor=1" } }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -11841,6 +11956,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -14209,6 +14333,24 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/localforage": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", + "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", + "license": "Apache-2.0", + "dependencies": { + "lie": "3.1.1" + } + }, + "node_modules/localforage/node_modules/lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -14436,6 +14578,12 @@ "node": ">= 20" } }, + "node_modules/marks-pane": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/marks-pane/-/marks-pane-1.0.9.tgz", + "integrity": "sha512-Ahs4oeG90tbdPWwAJkAAoHg2lRR8lAs9mZXETNPO9hYg3AkjUJBKi1NQ4aaIQZVGrig7c/3NUV1jANl8rFTeMg==", + "license": "MIT" + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -16068,6 +16216,12 @@ "node": ">= 10" } }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "license": "ISC" + }, "node_modules/nlcst-to-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", @@ -16619,6 +16773,12 @@ "dev": true, "license": "MIT" }, + "node_modules/path-webpack": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/path-webpack/-/path-webpack-0.0.3.tgz", + "integrity": "sha512-AmeDxedoo5svf7aB3FYqSAKqMxys014lVKBzy1o/5vv9CtU7U4wgGWL1dA2o6MOzcD53ScN4Jmiq6VbtLz1vIQ==", + "license": "MIT" + }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -19583,6 +19743,12 @@ "npm": ">=9" } }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "license": "ISC" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index da16034..dc0250c 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,9 @@ "directories": { "doc": "docs" }, + "overrides": { + "@xmldom/xmldom": "0.8.13" + }, "scripts": { "copy:wasm": "node scripts/copy-wasm.mjs", "og": "node scripts/generate-og.mjs", @@ -77,6 +80,7 @@ "comlink": "^4.4.2", "docx-preview": "^0.4.0", "dompurify": "^3.4.12", + "epubjs": "^0.3.93", "fast-xml-parser": "^5.10.0", "fflate": "^0.8.3", "gifenc": "^1.0.3", diff --git a/src/islands/documents/EpubReader.tsx b/src/islands/documents/EpubReader.tsx new file mode 100644 index 0000000..0890521 --- /dev/null +++ b/src/islands/documents/EpubReader.tsx @@ -0,0 +1,206 @@ +import { useEffect, useRef, useState } from 'react'; +import { BookOpen, ChevronLeft, ChevronRight, List, Minus, Plus } from 'lucide-react'; +import type { Book, Rendition, NavItem, Location } from 'epubjs'; +import { Dropzone } from '@/components/ui/Dropzone'; +import { Button } from '@/components/ui/Button'; +import { Alert } from '@/components/ui/Alert'; +import { flattenToc, type FlatTocItem } from '@/tools/documents/epub-toc.lib'; +import type { Lang } from '@/i18n/config'; + +const MIN_FONT = 70; +const MAX_FONT = 200; + +const TR: Record = { + en: { + intro: 'Read an EPUB e-book right in your browser — chapters, table of contents and adjustable text size. The file is opened on your device; nothing is uploaded.', + drop: 'Drop an EPUB (.epub)', dropSub: 'Opened on your device — no upload.', + how: 'Use the arrow keys or the buttons to turn pages. Scripts inside the book are disabled for your safety.', + opening: 'Opening…', another: 'Open another', contents: 'Contents', prev: 'Previous page', next: 'Next page', + smaller: 'Smaller text', larger: 'Larger text', errRead: 'Could not open this e-book — is it a valid .epub file?', by: 'by', + }, + id: { + intro: 'Baca buku elektronik EPUB langsung di browser Anda — bab, daftar isi, dan ukuran teks yang dapat disesuaikan. Berkas dibuka di perangkat Anda; tidak ada yang diunggah.', + drop: 'Letakkan EPUB (.epub)', dropSub: 'Dibuka di perangkat Anda — tanpa unggahan.', + how: 'Gunakan tombol panah atau tombol di layar untuk membalik halaman. Skrip di dalam buku dinonaktifkan demi keamanan Anda.', + opening: 'Membuka…', another: 'Buka yang lain', contents: 'Daftar Isi', prev: 'Halaman sebelumnya', next: 'Halaman berikutnya', + smaller: 'Perkecil teks', larger: 'Perbesar teks', errRead: 'Tidak dapat membuka buku ini — apakah berkas .epub yang valid?', by: 'oleh', + }, +}; + +export default function EpubReader({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const viewerRef = useRef(null); + const bookRef = useRef(null); + const renditionRef = useRef(null); + + const [ready, setReady] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [meta, setMeta] = useState<{ title: string; creator: string } | null>(null); + const [toc, setToc] = useState([]); + const [showToc, setShowToc] = useState(false); + const [nav, setNav] = useState({ atStart: true, atEnd: false }); + const [fontPct, setFontPct] = useState(100); + + const teardown = () => { + renditionRef.current?.destroy(); + renditionRef.current = null; + bookRef.current?.destroy(); + bookRef.current = null; + }; + + // Release the book/rendition (iframes, blob URLs) when the island unmounts. + useEffect(() => teardown, []); + + const onDrop = async (files: File[]) => { + const f = files[0]; + if (!f) return; + setError(''); + setBusy(true); + try { + const buf = await f.arrayBuffer(); + const { default: ePub } = await import('epubjs'); + teardown(); + const book = ePub(buf); + bookRef.current = book; + await book.ready; + const [md, navigation] = await Promise.all([book.loaded.metadata, book.loaded.navigation]); + setMeta({ title: md.title || f.name, creator: md.creator || '' }); + setToc(flattenToc(navigation.toc as NavItem[])); + setNav({ atStart: true, atEnd: false }); + setReady(true); + } catch { + setError(t.errRead); + teardown(); + setReady(false); + } finally { + setBusy(false); + } + }; + + // Mount the rendition once the reader UI (and its viewer element) is in the DOM. + useEffect(() => { + if (!ready || !viewerRef.current || !bookRef.current || renditionRef.current) return; + const rendition = bookRef.current.renderTo(viewerRef.current, { + width: '100%', + height: '100%', + flow: 'paginated', + spread: 'none', + allowScriptedContent: false, // never execute JS embedded in the e-book + }); + renditionRef.current = rendition; + rendition.themes.fontSize(`${fontPct}%`); + rendition.display(); + rendition.on('relocated', (loc: Location) => { + setNav({ atStart: !!loc.atStart, atEnd: !!loc.atEnd }); + }); + // fontPct intentionally omitted — applied via its own effect below. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ready]); + + useEffect(() => { + renditionRef.current?.themes.fontSize(`${fontPct}%`); + }, [fontPct]); + + // Arrow-key page turning while a book is open. + useEffect(() => { + if (!ready) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft') renditionRef.current?.prev(); + else if (e.key === 'ArrowRight') renditionRef.current?.next(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [ready]); + + const reset = () => { + teardown(); + setReady(false); + setMeta(null); + setToc([]); + setShowToc(false); + setNav({ atStart: true, atEnd: false }); + setError(''); + }; + + const goTo = (href: string) => { + renditionRef.current?.display(href); + setShowToc(false); + }; + + return ( +
+

{t.intro}

+ + {!ready && ( +
+ +
+

{busy ? t.opening : t.drop}

+

{t.dropSub}

+
+
+

{t.how}

+
+ )} + + {error && {error}} + + {ready && ( +
+
+
+

{meta?.title}

+ {meta?.creator &&

{t.by} {meta.creator}

} +
+ {toc.length > 0 && ( + + )} + + + +
+ +
+ {showToc && toc.length > 0 && ( + + )} +
+
+
+
+ +
+ + +
+
+ )} +
+ ); +} diff --git a/src/registry/tool-seo.ts b/src/registry/tool-seo.ts index a928b6b..6c36373 100644 --- a/src/registry/tool-seo.ts +++ b/src/registry/tool-seo.ts @@ -7,6 +7,23 @@ import type { Lang } from '@/i18n/config'; * a locale entry is missing. Feeds on-page copy + HowTo/FAQPage structured data. */ const en: Record = { + 'epub-reader': { + title: 'Free EPUB Reader — Read E-Books in Your Browser', + description: 'A free online EPUB reader to open and read .epub e-books in your browser — chapters, table of contents and adjustable text size. 100% private; nothing is uploaded.', + intro: 'This free EPUB reader opens .epub e-books right in your browser with a clean, paginated reading view — table of contents, arrow-key page turning and adjustable text size — no app or account needed. The book is opened on your device and never uploaded.', + howTo: [ + 'Drop an .epub file (or click to browse) — it opens entirely in your browser.', + 'Turn pages with the on-screen buttons or your keyboard’s arrow keys.', + 'Open the Contents list to jump straight to any chapter.', + 'Use the − / + buttons to make the text smaller or larger.', + ], + faqs: [ + { q: 'Is my e-book uploaded anywhere?', a: 'No. The .epub is opened and rendered entirely in your browser with JavaScript. It never leaves your device, so your library stays private.' }, + { q: 'Is it safe to open any EPUB?', a: 'Yes. Any scripts embedded in the book are disabled and its content is rendered in a sandboxed frame, so opening a file only ever displays it.' }, + { q: 'Does it work offline?', a: 'Once the page has loaded it works without a connection, and installing GoodWebTools as an app lets you read anywhere.' }, + { q: 'Can it open .mobi or Kindle books?', a: 'This reader supports the open EPUB format (.epub). Convert a .mobi or Kindle file to EPUB first, then open it here.' }, + ], + }, 'spreadsheet-viewer': { title: 'Free Spreadsheet Viewer — Open XLSX, ODS & CSV', description: 'A free spreadsheet viewer to open Excel (.xlsx/.xls), OpenDocument (.ods) and CSV files in your browser — every sheet as a table. 100% private; nothing is uploaded.', @@ -1342,6 +1359,23 @@ const en: Record = { }; const id: Record = { + 'epub-reader': { + title: 'Pembaca EPUB Gratis — Baca E-Book di Browser', + description: 'Pembaca EPUB gratis untuk membuka dan membaca e-book .epub di browser Anda — bab, daftar isi, dan ukuran teks yang dapat disesuaikan. 100% privat; tidak ada yang diunggah.', + intro: 'Pembaca EPUB gratis ini membuka e-book .epub langsung di browser Anda dengan tampilan baca berhalaman yang rapi — daftar isi, membalik halaman dengan tombol panah, dan ukuran teks yang dapat disesuaikan — tanpa aplikasi atau akun. Buku dibuka di perangkat Anda dan tidak pernah diunggah.', + howTo: [ + 'Letakkan berkas .epub (atau klik untuk menelusuri) — dibuka sepenuhnya di browser Anda.', + 'Balik halaman dengan tombol di layar atau tombol panah pada keyboard.', + 'Buka daftar isi untuk melompat langsung ke bab mana pun.', + 'Gunakan tombol − / + untuk memperkecil atau memperbesar teks.', + ], + faqs: [ + { q: 'Apakah e-book saya diunggah ke suatu tempat?', a: 'Tidak. Berkas .epub dibuka dan ditampilkan sepenuhnya di browser Anda dengan JavaScript. Berkas tidak pernah meninggalkan perangkat, jadi koleksi Anda tetap privat.' }, + { q: 'Apakah aman membuka EPUB apa pun?', a: 'Ya. Skrip apa pun yang tertanam di dalam buku dinonaktifkan dan kontennya ditampilkan dalam bingkai terisolasi (sandbox), sehingga membuka berkas hanya menampilkannya.' }, + { q: 'Apakah bekerja secara luring (offline)?', a: 'Setelah halaman dimuat, ini bekerja tanpa koneksi, dan memasang GoodWebTools sebagai aplikasi memungkinkan Anda membaca di mana saja.' }, + { q: 'Bisakah membuka .mobi atau buku Kindle?', a: 'Pembaca ini mendukung format EPUB terbuka (.epub). Konversi berkas .mobi atau Kindle ke EPUB terlebih dahulu, lalu buka di sini.' }, + ], + }, 'spreadsheet-viewer': { title: 'Penampil Spreadsheet Gratis — Buka XLSX, ODS & CSV', description: 'Penampil spreadsheet gratis untuk membuka berkas Excel (.xlsx/.xls), OpenDocument (.ods), dan CSV di browser — setiap lembar sebagai tabel. 100% privat; tidak ada yang diunggah.', diff --git a/src/registry/tools.ts b/src/registry/tools.ts index 517a3c9..c4072f1 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -1,4 +1,4 @@ -import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText, Ghost, FileSpreadsheet } from 'lucide-react'; +import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText, Ghost, FileSpreadsheet, BookOpen } from 'lucide-react'; import type { ToolDef } from '@/types/tool'; export const tools: ToolDef[] = [ @@ -179,6 +179,17 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/documents/SpreadsheetViewer'), status: 'beta' }, + { + id: 'epub-reader', + name: 'EPUB Reader', + category: 'Documents', + route: '/tools/epub-reader', + keywords: ['epub', 'ebook', 'e-book', 'reader', 'book', 'read', 'viewer', 'open', 'kindle', 'ereader'], + icon: BookOpen, + summary: 'Read EPUB e-books in your browser with chapters and text sizing', + load: () => import('@/islands/documents/EpubReader'), + status: 'beta' + }, { id: 'markdown', name: 'Markdown Preview', diff --git a/src/tools/documents/epub-toc.lib.test.ts b/src/tools/documents/epub-toc.lib.test.ts new file mode 100644 index 0000000..52e92c1 --- /dev/null +++ b/src/tools/documents/epub-toc.lib.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { flattenToc } from './epub-toc.lib'; + +describe('flattenToc', () => { + it('flattens nested chapters with increasing depth', () => { + const toc = [ + { href: 'ch1.html', label: 'Chapter 1', subitems: [ + { href: 'ch1.html#s1', label: 'Section 1.1' }, + { href: 'ch1.html#s2', label: 'Section 1.2' }, + ] }, + { href: 'ch2.html', label: 'Chapter 2' }, + ]; + expect(flattenToc(toc)).toEqual([ + { href: 'ch1.html', label: 'Chapter 1', depth: 0 }, + { href: 'ch1.html#s1', label: 'Section 1.1', depth: 1 }, + { href: 'ch1.html#s2', label: 'Section 1.2', depth: 1 }, + { href: 'ch2.html', label: 'Chapter 2', depth: 0 }, + ]); + }); + + it('trims whitespace/newlines in labels (common in EPUB nav)', () => { + const flat = flattenToc([{ href: 'a.html', label: '\n Prologue \n' }]); + expect(flat[0].label).toBe('Prologue'); + }); + + it('falls back to the href when a label is empty', () => { + const flat = flattenToc([{ href: 'cover.xhtml', label: ' ' }]); + expect(flat[0].label).toBe('cover.xhtml'); + }); + + it('skips entries with no href', () => { + const flat = flattenToc([ + { href: '', label: 'Unlinked heading', subitems: [{ href: 'x.html', label: 'Real' }] }, + ]); + expect(flat).toEqual([{ href: 'x.html', label: 'Real', depth: 1 }]); + }); + + it('handles empty / missing input', () => { + expect(flattenToc([])).toEqual([]); + // @ts-expect-error — defensive: real EPUBs sometimes have no nav + expect(flattenToc(undefined)).toEqual([]); + }); +}); diff --git a/src/tools/documents/epub-toc.lib.ts b/src/tools/documents/epub-toc.lib.ts new file mode 100644 index 0000000..4162fcb --- /dev/null +++ b/src/tools/documents/epub-toc.lib.ts @@ -0,0 +1,32 @@ +/** + * A single table-of-contents entry as epub.js exposes it (structurally the + * subset of its NavItem we need). Kept dep-free so the flattening logic is + * unit-testable without pulling in epub.js. + */ +export interface RawTocItem { + href: string; + label: string; + subitems?: RawTocItem[]; +} + +export interface FlatTocItem { + href: string; + label: string; + /** Nesting level, 0 for top-level chapters — used to indent the TOC list. */ + depth: number; +} + +/** + * Flatten epub.js's nested TOC into a depth-tagged list. Labels are trimmed + * (EPUB nav documents are riddled with indentation newlines) and fall back to + * the href; entries without an href are structural-only and skipped. + */ +export function flattenToc(items: RawTocItem[], depth = 0): FlatTocItem[] { + const out: FlatTocItem[] = []; + for (const it of items ?? []) { + const label = (it.label ?? '').trim(); + if (it.href) out.push({ href: it.href, label: label || it.href, depth }); + if (it.subitems?.length) out.push(...flattenToc(it.subitems, depth + 1)); + } + return out; +}