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],