diff --git a/README.md b/README.md index 65865a0..a082993 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ There are mainly three kinds of modules: The modules are designed to be modular. In general, they don't directly depend on each other. Instead they depend on certain interfaces, detailed below. The exception is `view.js`. It is the higher level renderer that strings most of the things together, and you can think of it as the main entry point of the library. See "Basic Usage" below. -The repo also includes a still higher level reader, though strictly speaking, `reader.html` (along with `reader.js` and its associated files in `ui/` and `vendor/`) is not considered part of the library itself. It's akin to [Epub.js Reader](https://github.com/futurepress/epubjs-reader). You are expected to modify it or replace it with your own code. +The repo also includes a still higher level reader, though strictly speaking, `reader.html` (along with `reader.js` and its associated files in `ui/`) is not considered part of the library itself. It's akin to [Epub.js Reader](https://github.com/futurepress/epubjs-reader). You are expected to modify it or replace it with your own code. ### Basic Usage @@ -61,8 +61,9 @@ view.addEventListener('relocate', e => { console.log(e.detail) }) -const book = /* an object implementing the "book" interface */ -await view.open(book) +// can open a File/Blob object or a URL +// or any object that implements the "book" interface +await view.open('example.epub') await view.goTo(/* path, section index, or CFI */) ``` diff --git a/reader.js b/reader.js index 2a3091f..c4c4c4f 100644 --- a/reader.js +++ b/reader.js @@ -3,111 +3,6 @@ import { createTOCView } from './ui/tree.js' import { createMenu } from './ui/menu.js' import { Overlayer } from './overlayer.js' -const isZip = async file => { - const arr = new Uint8Array(await file.slice(0, 4).arrayBuffer()) - return arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03 && arr[3] === 0x04 -} - -const isPDF = async file => { - const arr = new Uint8Array(await file.slice(0, 5).arrayBuffer()) - return arr[0] === 0x25 - && arr[1] === 0x50 && arr[2] === 0x44 && arr[3] === 0x46 - && arr[4] === 0x2d -} - -const makeZipLoader = async file => { - const { configure, ZipReader, BlobReader, TextWriter, BlobWriter } = - await import('./vendor/zip.js') - configure({ useWebWorkers: false }) - const reader = new ZipReader(new BlobReader(file)) - const entries = await reader.getEntries() - const map = new Map(entries.map(entry => [entry.filename, entry])) - const load = f => (name, ...args) => - map.has(name) ? f(map.get(name), ...args) : null - const loadText = load(entry => entry.getData(new TextWriter())) - const loadBlob = load((entry, type) => entry.getData(new BlobWriter(type))) - const getSize = name => map.get(name)?.uncompressedSize ?? 0 - return { entries, loadText, loadBlob, getSize } -} - -const getFileEntries = async entry => entry.isFile ? entry - : (await Promise.all(Array.from( - await new Promise((resolve, reject) => entry.createReader() - .readEntries(entries => resolve(entries), error => reject(error))), - getFileEntries))).flat() - -const makeDirectoryLoader = async entry => { - const entries = await getFileEntries(entry) - const files = await Promise.all( - entries.map(entry => new Promise((resolve, reject) => - entry.file(file => resolve([file, entry.fullPath]), - error => reject(error))))) - const map = new Map(files.map(([file, path]) => - [path.replace(entry.fullPath + '/', ''), file])) - const decoder = new TextDecoder() - const decode = x => x ? decoder.decode(x) : null - const getBuffer = name => map.get(name)?.arrayBuffer() ?? null - const loadText = async name => decode(await getBuffer(name)) - const loadBlob = name => map.get(name) - const getSize = name => map.get(name)?.size ?? 0 - return { loadText, loadBlob, getSize } -} - -const isCBZ = ({ name, type }) => - type === 'application/vnd.comicbook+zip' || name.endsWith('.cbz') - -const isFB2 = ({ name, type }) => - type === 'application/x-fictionbook+xml' || name.endsWith('.fb2') - -const isFBZ = ({ name, type }) => - type === 'application/x-zip-compressed-fb2' - || name.endsWith('.fb2.zip') || name.endsWith('.fbz') - -const getView = async file => { - let book - if (file.isDirectory) { - const loader = await makeDirectoryLoader(file) - const { EPUB } = await import('./epub.js') - book = await new EPUB(loader).init() - } - else if (!file.size) throw new Error('File not found') - else if (await isZip(file)) { - const loader = await makeZipLoader(file) - if (isCBZ(file)) { - const { makeComicBook } = await import('./comic-book.js') - book = makeComicBook(loader, file) - } else if (isFBZ(file)) { - const { makeFB2 } = await import('./fb2.js') - const { entries } = loader - const entry = entries.find(entry => entry.filename.endsWith('.fb2')) - const blob = await loader.loadBlob((entry ?? entries[0]).filename) - book = await makeFB2(blob) - } else { - const { EPUB } = await import('./epub.js') - book = await new EPUB(loader).init() - } - } - else if (await isPDF(file)) { - const { makePDF } = await import('./pdf.js') - book = await makePDF(file) - } - else { - const { isMOBI, MOBI } = await import('./mobi.js') - if (await isMOBI(file)) { - const fflate = await import('./vendor/fflate.js') - book = await new MOBI({ unzlib: fflate.unzlibSync }).open(file) - } else if (isFB2(file)) { - const { makeFB2 } = await import('./fb2.js') - book = await makeFB2(file) - } - } - if (!book) throw new Error('File type not supported') - const view = document.createElement('foliate-view') - document.body.append(view) - await view.open(book) - return view -} - const getCSS = ({ spacing, justify, hyphenate }) => ` @namespace epub "http://www.idpf.org/2007/ops"; html { @@ -209,7 +104,9 @@ class Reader { menu.groups.layout.select('paginated') } async open(file) { - this.view = await getView(file) + this.view = document.createElement('foliate-view') + document.body.append(this.view) + await this.view.open(file) this.view.addEventListener('load', this.#onLoad.bind(this)) this.view.addEventListener('relocate', this.#onRelocate.bind(this)) @@ -332,8 +229,5 @@ $('#file-button').addEventListener('click', () => $('#file-input').click()) const params = new URLSearchParams(location.search) const url = params.get('url') -if (url) fetch(url) - .then(res => res.blob()) - .then(blob => open(new File([blob], new URL(url, window.location.origin).pathname))) - .catch(e => console.error(e)) +if (url) open(url).catch(e => console.error(e)) else dropTarget.style.visibility = 'visible' diff --git a/view.js b/view.js index e8d6cd1..17566ac 100644 --- a/view.js +++ b/view.js @@ -5,6 +5,123 @@ import { textWalker } from './text-walker.js' const SEARCH_PREFIX = 'foliate-search:' +const isZip = async file => { + const arr = new Uint8Array(await file.slice(0, 4).arrayBuffer()) + return arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03 && arr[3] === 0x04 +} + +const isPDF = async file => { + const arr = new Uint8Array(await file.slice(0, 5).arrayBuffer()) + return arr[0] === 0x25 + && arr[1] === 0x50 && arr[2] === 0x44 && arr[3] === 0x46 + && arr[4] === 0x2d +} + +const isCBZ = ({ name, type }) => + type === 'application/vnd.comicbook+zip' || name.endsWith('.cbz') + +const isFB2 = ({ name, type }) => + type === 'application/x-fictionbook+xml' || name.endsWith('.fb2') + +const isFBZ = ({ name, type }) => + type === 'application/x-zip-compressed-fb2' + || name.endsWith('.fb2.zip') || name.endsWith('.fbz') + +const makeZipLoader = async file => { + const { configure, ZipReader, BlobReader, TextWriter, BlobWriter } = + await import('./vendor/zip.js') + configure({ useWebWorkers: false }) + const reader = new ZipReader(new BlobReader(file)) + const entries = await reader.getEntries() + const map = new Map(entries.map(entry => [entry.filename, entry])) + const load = f => (name, ...args) => + map.has(name) ? f(map.get(name), ...args) : null + const loadText = load(entry => entry.getData(new TextWriter())) + const loadBlob = load((entry, type) => entry.getData(new BlobWriter(type))) + const getSize = name => map.get(name)?.uncompressedSize ?? 0 + return { entries, loadText, loadBlob, getSize } +} + +const getFileEntries = async entry => entry.isFile ? entry + : (await Promise.all(Array.from( + await new Promise((resolve, reject) => entry.createReader() + .readEntries(entries => resolve(entries), error => reject(error))), + getFileEntries))).flat() + +const makeDirectoryLoader = async entry => { + const entries = await getFileEntries(entry) + const files = await Promise.all( + entries.map(entry => new Promise((resolve, reject) => + entry.file(file => resolve([file, entry.fullPath]), + error => reject(error))))) + const map = new Map(files.map(([file, path]) => + [path.replace(entry.fullPath + '/', ''), file])) + const decoder = new TextDecoder() + const decode = x => x ? decoder.decode(x) : null + const getBuffer = name => map.get(name)?.arrayBuffer() ?? null + const loadText = async name => decode(await getBuffer(name)) + const loadBlob = name => map.get(name) + const getSize = name => map.get(name)?.size ?? 0 + return { loadText, loadBlob, getSize } +} + +export class ResponseError extends Error {} +export class NotFoundError extends Error {} +export class UnsupportedTypeError extends Error {} + +const fetchFile = async url => { + const res = await fetch(url) + if (!res.ok) throw new ResponseError( + `${res.status} ${res.statusText}`, { cause: res }) + return new File([await res.blob()], new URL(res.url).pathname) +} + +export const makeBook = async file => { + if (typeof file === 'string') file = await fetchFile(file) + let book + if (file.isDirectory) { + const loader = await makeDirectoryLoader(file) + const { EPUB } = await import('./epub.js') + book = await new EPUB(loader).init() + } + else if (!file.size) throw new NotFoundError('File not found') + else if (await isZip(file)) { + const loader = await makeZipLoader(file) + if (isCBZ(file)) { + const { makeComicBook } = await import('./comic-book.js') + book = makeComicBook(loader, file) + } + else if (isFBZ(file)) { + const { makeFB2 } = await import('./fb2.js') + const { entries } = loader + const entry = entries.find(entry => entry.filename.endsWith('.fb2')) + const blob = await loader.loadBlob((entry ?? entries[0]).filename) + book = await makeFB2(blob) + } + else { + const { EPUB } = await import('./epub.js') + book = await new EPUB(loader).init() + } + } + else if (await isPDF(file)) { + const { makePDF } = await import('./pdf.js') + book = await makePDF(file) + } + else { + const { isMOBI, MOBI } = await import('./mobi.js') + if (await isMOBI(file)) { + const fflate = await import('./vendor/fflate.js') + book = await new MOBI({ unzlib: fflate.unzlibSync }).open(file) + } + else if (isFB2(file)) { + const { makeFB2 } = await import('./fb2.js') + book = await makeFB2(file) + } + } + if (!book) throw new UnsupportedTypeError('File type not supported') + return book +} + class CursorAutohider { #timeout #el @@ -112,6 +229,9 @@ export class View extends HTMLElement { }) } async open(book) { + if (typeof book === 'string' + || typeof book.arrayBuffer === 'function' + || book.isDirectory) book = await makeBook(book) this.book = book this.language = languageInfo(book.metadata?.language)