From 2a5ccb93d9e49b0f02c1dcebd5eac98b870d81a0 Mon Sep 17 00:00:00 2001 From: John Factotum <50942278+johnfactotum@users.noreply.github.com> Date: Thu, 27 Oct 2022 17:41:26 +0000 Subject: [PATCH] Reader: allow opening unzipped EPUBs --- README.md | 12 +++++++----- epub.js | 2 +- reader.js | 43 +++++++++++++++++++++++++++++++++++++------ 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 3454322..c880c76 100644 --- a/README.md +++ b/README.md @@ -66,15 +66,17 @@ Almost all of the properties and methods are optional. At minimum it needs `.sec ### Archived Files -Reading Zip-based formats requires a separate library. Both `epub.js` and `comic-book.js` expect a `loader` object that implements the following interface: +Reading Zip-based formats will require adapting an external library. Both `epub.js` and `comic-book.js` expect a `loader` object that implements the following interface: -- `.entries`: an array, each element of which has a `filename` property, which is a string containing the filename (the full path) (only used by `comic-book.js`) -- `.loadText(filename)`: given the path, returns content of the file as string -- `.loadBlob(filename)`: give the path, returns the file as a blob -- `.getSize(filename)`: returns the file size in bytes +- `.entries`: (only used by `comic-book.js`) an array, each element of which has a `filename` property, which is a string containing the filename (the full path). +- `.loadText(filename)`: given the path, returns the contents of the file as string. May be async. +- `.loadBlob(filename)`: given the path, returns the file as a `Blob` object. May be async. +- `.getSize(filename)`: returns the file size in bytes. Used to set the `.size` property for `.sections` (see above). In the demo, this is implemented using [Zip.js](https://github.com/gildas-lormeau/zip.js), which is highly recommended because it seems to be the only library that supports random access for `File` objects (as well as HTTP range requests). +One advantage of having such an interface is that one can easily use it for reading unarchived files as well. For example, the demo has a loader that allows you to open unpacked EPUBs as directories. + ### Mobipocket and Kindle Files It can read both MOBI and KF8 (.azw3, and combo .mobi files) from a `File` (or `Blob`) object. For MOBI files, it decompresses all text at once and splits the raw markup into sections at every ``, instead of outputing one long page for the whole book, which drastically improves rendering performance. For KF8 files, it tries to decompress as little text as possible when loading a section, but it can still be quite slow due to the slowness of the current HUFF/CDIC decompressor implementation. In all cases, images and other resources are not loaded until they are needed. diff --git a/epub.js b/epub.js index f427ba1..8858dcd 100644 --- a/epub.js +++ b/epub.js @@ -604,7 +604,7 @@ export class EPUB { }) const loader = new Loader({ loadText: this.loadText, - loadBlob: uri => this.loadBlob(uri) + loadBlob: uri => Promise.resolve(this.loadBlob(uri)) .then(this.#encryption.getDecoder(uri)), resources: this.resources, }) diff --git a/reader.js b/reader.js index 3642c1c..b104422 100644 --- a/reader.js +++ b/reader.js @@ -24,6 +24,29 @@ const makeZipLoader = async file => { 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') @@ -35,9 +58,14 @@ const isFBZ = ({ name, type }) => || name.endsWith('.fb2.zip') || name.endsWith('.fbz') const getView = async (file, emit) => { - if (!file.size) throw new Error('File not found') let book - if (await isZip(file)) { + 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') @@ -195,7 +223,7 @@ class Reader { ?.map(author => typeof author === 'string' ? author : author.name) ?.join(', ') ?? '' - book.getCover?.()?.then(blob => + Promise.resolve(book.getCover?.())?.then(blob => blob ? $('#side-bar-cover').src = URL.createObjectURL(blob) : null) const toc = book.toc @@ -276,9 +304,12 @@ const open = async file => { const dragOverHandler = e => e.preventDefault() const dropHandler = e => { e.preventDefault() - const file = Array.from(e.dataTransfer.items) - .find(item => item.kind === 'file')?.getAsFile() - if (file) open(file).catch(e => console.error(e)) + const item = Array.from(e.dataTransfer.items) + .find(item => item.kind === 'file') + if (item) { + const entry = item.webkitGetAsEntry() + open(entry.isFile ? item.getAsFile() : entry).catch(e => console.error(e)) + } } const dropTarget = $('#drop-target') dropTarget.addEventListener('drop', dropHandler)