From 052123beafed921a9a2a45ef6330c235289a634e Mon Sep 17 00:00:00 2001 From: John Factotum <50942278+johnfactotum@users.noreply.github.com> Date: Sat, 29 Mar 2025 14:56:37 +0800 Subject: [PATCH] Add an EventTarget for transforming the book And use it to do paginator-specific CSS replacements. And use it to catch loading errors in reader.js. Fixes #14 Closes #18 --- README.md | 2 ++ epub.js | 38 ++++++++++++++++++++------------------ paginator.js | 16 ++++++++++++++++ reader.js | 6 ++++++ 4 files changed, 44 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 95de730..a3fe2d5 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,8 @@ The following methods are consumed by `progress.js`, for getting the correct TOC - `.splitTOCHref(href)`: given an href string (from the TOC), returns an array, the first element of which is the `id` of the section (see above), and the second element is the fragment identifier (can be any type; see below). May be async. - `.getTOCFragment(doc, id)`: given a `Document` object and a fragment identifier (the one provided by `.splitTOCHref()`; see above), returns a `Node` representing the target linked by the TOC item +In addition, the `.transformTarget`, if present, can be used to transform the contents of the book as it loads. It is an `EventTarget` with a custom event `"data"`, whose `.detail` is `{ data, type, name }`, where `.data` is either a string or `Blob`, or a `Promise` thereof, `.type` the content type string, and `.name` the identifier of the resource. Event handlers should mutate `.data` to transform the data. + Almost all of the properties and methods are optional. At minimum it needs `.sections` and the `.load()` method for the sections, as otherwise there won't be anything to render. ### Archived Files diff --git a/epub.js b/epub.js index 378ffd8..ea6e91a 100644 --- a/epub.js +++ b/epub.js @@ -706,6 +706,7 @@ class Loader { #children = new Map() #refCount = new Map() allowScript = false + eventTarget = new EventTarget() constructor({ loadText, loadBlob, resources }) { this.loadText = loadText this.loadBlob = loadBlob @@ -714,9 +715,15 @@ class Loader { // needed only when replacing in (X)HTML w/o parsing (see below) //.filter(({ mediaType }) => ![MIME.XHTML, MIME.HTML].includes(mediaType)) } - createURL(href, data, type, parent) { + async createURL(href, data, type, parent) { if (!data) return '' - const url = URL.createObjectURL(new Blob([data], { type })) + const detail = { data, type } + Object.defineProperty(detail, 'name', { value: href }) // readonly + const event = new CustomEvent('data', { detail }) + this.eventTarget.dispatchEvent(event) + const newData = await event.detail.data + const newType = await event.detail.type + const url = URL.createObjectURL(new Blob([newData], { type: newType })) this.#cache.set(href, url) this.#refCount.set(href, 1) if (parent) { @@ -767,7 +774,9 @@ class Loader { // prevent circular references && parents.every(p => p !== href) if (shouldReplace) return this.loadReplaced(item, parents) - return this.createURL(href, await this.loadBlob(href), mediaType, parent) + // NOTE: this can be replaced with `Promise.try()` + const tryLoadBlob = Promise.resolve().then(() => this.loadBlob(href)) + return this.createURL(href, tryLoadBlob, mediaType, parent) } async loadHref(href, base, parents = []) { if (isExternal(href)) return href @@ -779,7 +788,12 @@ class Loader { async loadReplaced(item, parents = []) { const { href, mediaType } = item const parent = parents.at(-1) - const str = await this.loadText(href) + let str = '' + try { + str = await this.loadText(href) + } catch (e) { + return this.createURL(href, Promise.reject(e), mediaType, parent) + } if (!str) return null // note that one can also just use `replaceString` for everything: @@ -851,23 +865,10 @@ class Loader { (_, url) => this.loadHref(url, href, parents) .then(url => `url("${url}")`)) // apart from `url()`, strings can be used for `@import` (but why?!) - const replacedImports = await replaceSeries(replacedUrls, + return replaceSeries(replacedUrls, /@import\s*["']([^"'\n]*?)["']/gi, (_, url) => this.loadHref(url, href, parents) .then(url => `@import "${url}"`)) - const w = window?.innerWidth ?? 800 - const h = window?.innerHeight ?? 600 - return replacedImports - // unprefix as most of the props are (only) supported unprefixed - .replace(/(?<=[{\s;])-epub-/gi, '') - // replace vw and vh as they cause problems with layout - .replace(/(\d*\.?\d+)vw/gi, (_, d) => parseFloat(d) * w / 100 + 'px') - .replace(/(\d*\.?\d+)vh/gi, (_, d) => parseFloat(d) * h / 100 + 'px') - // `page-break-*` unsupported in columns; replace with `column-break-*` - .replace(/page-break-(after|before|inside)\s*:/gi, (_, x) => - `-webkit-column-break-${x}:`) - .replace(/break-(after|before|inside)\s*:\s*(avoid-)?page/gi, (_, x, y) => - `break-${x}: ${y ?? ''}column`) } // find & replace all possible relative paths for all assets without parsing replaceString(str, href, parents = []) { @@ -965,6 +966,7 @@ ${doc.querySelector('parsererror').innerText}`) .then(this.#encryption.getDecoder(uri)), resources: this.resources, }) + this.transformTarget = this.#loader.eventTarget this.sections = this.resources.spine.map((spineItem, index) => { const { idref, linear, properties = [] } = spineItem const item = this.resources.getItemByID(idref) diff --git a/paginator.js b/paginator.js index 234ca89..012cd49 100644 --- a/paginator.js +++ b/paginator.js @@ -636,6 +636,22 @@ export class Paginator extends HTMLElement { open(book) { this.bookDir = book.dir this.sections = book.sections + book.transformTarget?.addEventListener('data', ({ detail }) => { + if (detail.type !== 'text/css') return + const w = innerWidth + const h = innerHeight + detail.data = Promise.resolve(detail.data).then(data => data + // unprefix as most of the props are (only) supported unprefixed + .replace(/(?<=[{\s;])-epub-/gi, '') + // replace vw and vh as they cause problems with layout + .replace(/(\d*\.?\d+)vw/gi, (_, d) => parseFloat(d) * w / 100 + 'px') + .replace(/(\d*\.?\d+)vh/gi, (_, d) => parseFloat(d) * h / 100 + 'px') + // `page-break-*` unsupported in columns; replace with `column-break-*` + .replace(/page-break-(after|before|inside)\s*:/gi, (_, x) => + `-webkit-column-break-${x}:`) + .replace(/break-(after|before|inside)\s*:\s*(avoid-)?page/gi, (_, x, y) => + `break-${x}: ${y ?? ''}column`)) + }) } #createView() { if (this.#view) { diff --git a/reader.js b/reader.js index c4c4c4f..f62102c 100644 --- a/reader.js +++ b/reader.js @@ -111,6 +111,12 @@ class Reader { this.view.addEventListener('relocate', this.#onRelocate.bind(this)) const { book } = this.view + book.transformTarget?.addEventListener('data', ({ detail }) => { + detail.data = Promise.resolve(detail.data).catch(e => { + console.error(new Error(`Failed to load ${detail.name}`, { cause: e })) + return '' + }) + }) this.view.renderer.setStyles?.(getCSS(this.style)) this.view.renderer.next()