From b87b1d238287996f5c043f6a35286ec77c5239b4 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Sat, 13 Jun 2026 14:05:17 +0800 Subject: [PATCH 1/4] fix(reader): resolve epub responsive image resources --- packages/app-expo/assets/reader/reader.html | 42 +++++++++--------- packages/foliate-js/epub.js | 49 ++++++++++++++++++++- 2 files changed, 68 insertions(+), 23 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 178d0f69..7668d953 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -4790,8 +4790,8 @@ diff --git a/packages/foliate-js/epub.js b/packages/foliate-js/epub.js index 14f56563..11ec6588 100644 --- a/packages/foliate-js/epub.js +++ b/packages/foliate-js/epub.js @@ -176,6 +176,36 @@ const replaceSeries = async (str, regex, f) => { const regexEscape = (str) => str.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"); +const parseSrcset = (srcset) => { + const candidates = []; + let i = 0; + while (i < srcset.length) { + let leading = ""; + while (i < srcset.length && /[\t\n\f\r ,]/.test(srcset[i])) leading += srcset[i++]; + if (i >= srcset.length) { + if (leading) candidates.push({ leading, url: "", descriptor: "", comma: "" }); + break; + } + + const urlStart = i; + const isDataURL = srcset.slice(i, i + 5).toLowerCase() === "data:"; + while ( + i < srcset.length && + !/[\t\n\f\r ]/.test(srcset[i]) && + (isDataURL || srcset[i] !== ",") + ) + i++; + + const url = srcset.slice(urlStart, i); + const descriptorStart = i; + while (i < srcset.length && srcset[i] !== ",") i++; + const descriptor = srcset.slice(descriptorStart, i); + const comma = i < srcset.length && srcset[i] === "," ? srcset[i++] : ""; + candidates.push({ leading, url, descriptor, comma }); + } + return candidates; +}; + const tidy = (obj) => { for (const [key, val] of Object.entries(obj)) if (val == null) delete obj[key]; @@ -1026,14 +1056,19 @@ class Loader { child = child.nextSibling; } } - // replace hrefs (excluding anchors) - // TODO: srcset? + // replace resource hrefs (excluding anchors) const replace = async (el, attr) => el.setAttribute(attr, await this.loadHref(el.getAttribute(attr), href, parents)); + const replaceSrcset = async (el, attr) => + el.setAttribute(attr, await this.replaceSrcset(el.getAttribute(attr), href, parents)); for (const el of doc.querySelectorAll("link[href]")) await replace(el, "href"); for (const el of doc.querySelectorAll("[src]")) await replace(el, "src"); + for (const el of doc.querySelectorAll("[srcset]")) await replaceSrcset(el, "srcset"); + for (const el of doc.querySelectorAll("[imagesrcset]")) + await replaceSrcset(el, "imagesrcset"); for (const el of doc.querySelectorAll("[poster]")) await replace(el, "poster"); for (const el of doc.querySelectorAll("object[data]")) await replace(el, "data"); + for (const el of doc.querySelectorAll("image[href], use[href]")) await replace(el, "href"); for (const el of doc.querySelectorAll("[*|href]:not([href])")) el.setAttributeNS( NS.XLINK, @@ -1073,6 +1108,16 @@ class Loader { this.loadHref(url, href, parents).then((url) => `@import "${url}"`), ); } + async replaceSrcset(str, href, parents = []) { + if (!str) return str; + let result = ""; + for (const { leading, url, descriptor, comma } of parseSrcset(str)) { + result += leading; + result += url ? await this.loadHref(url, href, parents) : ""; + result += descriptor + comma; + } + return result; + } // find & replace all possible relative paths for all assets without parsing replaceString(str, href, parents = []) { const assetMap = new Map(); From 9940d4c0ae83666b9742250fea5ffa547140a866 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Thu, 25 Jun 2026 00:45:42 +0800 Subject: [PATCH 2/4] fix(reader): preserve fragments for rewritten EPUB resources --- packages/app-expo/assets/reader/reader.html | 14 +++++++------- packages/foliate-js/epub.js | 13 ++++++++++--- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index c7cc4df7..e75be2f5 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -4520,7 +4520,7 @@ diff --git a/packages/foliate-js/epub.js b/packages/foliate-js/epub.js index f480d461..dec940c5 100644 --- a/packages/foliate-js/epub.js +++ b/packages/foliate-js/epub.js @@ -489,6 +489,26 @@ const parseClock = (str) => { return n * f; }; +const IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "webp", "svg"]; +const FONT_EXTENSIONS = ["woff", "woff2", "ttf", "otf"]; + +const getMediaTypeForExtension = (path, fallback) => { + const extension = path.toLowerCase().split(".").pop(); + const mediaTypeMap = { + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + woff: "font/woff", + woff2: "font/woff2", + ttf: "font/ttf", + otf: "font/otf", + }; + return mediaTypeMap[extension] || fallback; +}; + class MediaOverlay extends EventTarget { #entries; #lastMediaOverlayItem; @@ -927,11 +947,12 @@ class Loader { #children = new Map(); #refCount = new Map(); eventTarget = new EventTarget(); - constructor({ loadText, loadBlob, resources }) { + constructor({ loadText, loadBlob, resources, entries }) { this.loadText = loadText; this.loadBlob = loadBlob; this.manifest = resources.manifest; this.assets = resources.manifest; + this.entries = entries; // needed only when replacing in (X)HTML w/o parsing (see below) //.filter(({ mediaType }) => ![MIME.XHTML, MIME.HTML].includes(mediaType)) } @@ -1002,12 +1023,22 @@ class Loader { const tryLoadBlob = Promise.resolve().then(() => this.loadBlob(href)); return this.createURL(href, tryLoadBlob, mediaType, parent); } + getEntryItem(path) { + const extension = path.toLowerCase().split(".").pop(); + if (IMAGE_EXTENSIONS.includes(extension) && this.entries?.has(path)) + return { href: path, mediaType: getMediaTypeForExtension(path, "image/jpeg") }; + if (!FONT_EXTENSIONS.includes(extension)) return null; + const fontPath = this.entries?.has(path) ? path : `fonts/${path.split("/").pop()}`; + return this.entries?.has(fontPath) + ? { href: fontPath, mediaType: getMediaTypeForExtension(fontPath, "font/ttf") } + : null; + } async loadHref(href, base, parents = []) { if (!href || href.startsWith("#")) return href; if (isExternal(href)) return href; const resolved = resolveURL(href, base); const [path, hash = ""] = resolved.split(/(#.*)/s); - const item = this.manifest.find((item) => item.href === path); + const item = this.manifest.find((item) => item.href === path) ?? this.getEntryItem(path); if (!item) return href; const url = await this.loadItem(item, parents.concat(base)); return url ? url + hash : href; @@ -1179,7 +1210,14 @@ export class EPUB { parser = new DOMParser(); #loader; #encryption; - constructor({ loadText, loadBlob, getSize, sha1 }) { + constructor({ entries, loadText, loadBlob, getSize, sha1 }) { + this.entries = entries + ? new Map( + entries + .map((entry) => [entry.filename ?? entry.fullPath, entry]) + .filter(([name]) => name), + ) + : new Map(); this.loadText = loadText; this.loadBlob = loadBlob; this.getSize = getSize; @@ -1223,6 +1261,7 @@ ${doc.querySelector("parsererror").innerText}`); loadText: this.loadText, loadBlob: (uri) => Promise.resolve(this.loadBlob(uri)).then(this.#encryption.getDecoder(uri)), resources: this.resources, + entries: this.entries, }); this.transformTarget = this.#loader.eventTarget; this.sections = this.resources.spine From 457241861f242b96174cde1c1fc559822c01ea68 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Thu, 25 Jun 2026 02:03:13 +0800 Subject: [PATCH 4/4] fix(reader): provide rewritten epub content to paginator --- packages/app-expo/assets/reader/reader.html | 46 ++++++++++----------- packages/foliate-js/epub.js | 16 ++++++- 2 files changed, 38 insertions(+), 24 deletions(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 9bc8859a..f0952ec3 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -4520,8 +4520,8 @@ diff --git a/packages/foliate-js/epub.js b/packages/foliate-js/epub.js index dec940c5..e52c7ecd 100644 --- a/packages/foliate-js/epub.js +++ b/packages/foliate-js/epub.js @@ -944,6 +944,7 @@ class Resources { class Loader { #cache = new Map(); + #cacheXHTMLContent = new Map(); #children = new Map(); #refCount = new Map(); eventTarget = new EventTarget(); @@ -967,6 +968,9 @@ class Loader { const url = URL.createObjectURL(new Blob([newData], { type: newType })); this.#cache.set(href, url); this.#refCount.set(href, 1); + if (newType === MIME.XHTML || newType === MIME.HTML) { + this.#cacheXHTMLContent.set(url, { href, type: newType, data: newData }); + } if (parent) { const childList = this.#children.get(parent); if (childList) childList.push(href); @@ -990,8 +994,10 @@ class Loader { //console.log(`unreferencing ${href}, now ${count}`) if (count < 1) { //console.log(`unloading ${href}`) - URL.revokeObjectURL(this.#cache.get(href)); + const url = this.#cache.get(href); + URL.revokeObjectURL(url); this.#cache.delete(href); + this.#cacheXHTMLContent.delete(url); this.#refCount.delete(href); // unref children const childList = this.#children.get(href); @@ -1023,6 +1029,13 @@ class Loader { const tryLoadBlob = Promise.resolve().then(() => this.loadBlob(href)); return this.createURL(href, tryLoadBlob, mediaType, parent); } + async loadItemXHTMLContent(item, parents = []) { + if (this.#cache.has(item?.href)) { + return this.#cacheXHTMLContent.get(this.#cache.get(item.href))?.data; + } + const url = await this.loadItem(item, parents); + if (url) return this.#cacheXHTMLContent.get(url)?.data; + } getEntryItem(path) { const extension = path.toLowerCase().split(".").pop(); if (IMAGE_EXTENSIONS.includes(extension) && this.entries?.has(path)) @@ -1276,6 +1289,7 @@ ${doc.querySelector("parsererror").innerText}`); id: item.href, load: () => this.#loader.loadItem(item), unload: () => this.#loader.unloadItem(item), + loadContent: () => this.#loader.loadItemXHTMLContent(item), createDocument: () => this.loadDocument(item), size: this.getSize(item.href), cfi: this.resources.cfis[index],