export function createReaderHtmlFormat(readerContext) {
function normalizeEpubDocument(doc) {
// Legacy CHM conversions can use an XHTML prefix even in HTML content.
for (const element of [...doc.querySelectorAll("*")]) {
const tagName = String(element.tagName || "").toLowerCase();
const localName = tagName.includes(":") ? tagName.slice(tagName.lastIndexOf(":") + 1) : tagName;
if (tagName === localName || !readerContext.EPUB_HTML_TAGS.has(localName)) continue;
const replacement = doc.createElementNS("http://www.w3.org/1999/xhtml", localName);
for (const attribute of [...element.attributes]) {
if (!attribute.name.toLowerCase().startsWith("xmlns:"))
replacement.setAttribute(attribute.name, attribute.value);
}
// HTML parsing treats prefixed void tags as containers. Keep the following
// content as siblings: serializing real
/
/
drops their children.
// Move the existing text nodes so Foliate's source/CFI mapping stays intact.
const fragment = doc.createDocumentFragment();
fragment.appendChild(replacement);
if (readerContext.EPUB_HTML_VOID_TAGS.has(localName)) {
while (element.firstChild) fragment.appendChild(element.firstChild);
} else {
while (element.firstChild) replacement.appendChild(element.firstChild);
}
element.replaceWith(fragment);
}
return doc;
}
function sanitizeReaderDocument(doc, { preserveEpubStyles = false, keepBlobImports = false } = {}) {
const blocked = preserveEpubStyles
? "script,iframe,object,embed,base,form"
: "script,iframe,object,embed,base,form,link,style";
for (const node of doc.querySelectorAll(blocked)) node.remove();
// SMIL can retarget a link's href after sanitizing; drop animations that do.
for (const node of doc.querySelectorAll("*")) {
if (!SVG_ANIMATION_ELEMENTS.has(String(node.localName || "").toLowerCase())) continue;
const target = String(node.getAttribute("attributeName") || "").toLowerCase();
if (/(?:^|:)href$/.test(target.trim()) || target.trim().startsWith("on")) node.remove();
}
if (preserveEpubStyles) {
for (const link of doc.querySelectorAll("link")) {
const rel = (link.getAttribute("rel") || "").toLowerCase().split(/\s+/);
if (!rel.includes("stylesheet") || rel.includes("alternate") ||
!/^blob:/i.test(link.getAttribute("href") || ""))
link.remove();
}
for (const style of doc.querySelectorAll("style"))
style.textContent = sanitizeCssReferences(style.textContent || "", { keepBlobImports });
}
for (const element of doc.querySelectorAll("*"))
for (const attribute of [...element.attributes]) {
const name = attribute.name.toLowerCase(),
value = String(attribute.value || "").trim(),
// WHATWG URL parsing ignores ASCII whitespace/control characters
// around and inside a scheme. Check a normalized copy, but retain
// the original value for safe relative URLs.
schemeValue = value.replace(/[\u0000-\u0020\u007f-\u009f]/g, "");
// XHTML may bind the XLink namespace to any prefix (x:href), so match the
// attribute's namespace and local name rather than its qualified name.
const localName = String(attribute.localName || name).toLowerCase(),
namespace = attribute.namespaceURI;
const urlAttribute = (!namespace || namespace === XLINK_NAMESPACE) &&
["href", "src", "poster", "data"].includes(localName);
if (
name.startsWith("on") ||
name === "srcdoc" ||
(urlAttribute && /^(?:javascript|vbscript):/i.test(schemeValue))
)
element.removeAttributeNode(attribute);
else if (CSS_VALUE_ATTRIBUTES.has(name)) {
const clean = sanitizeCssReferences(attribute.value);
if (clean !== attribute.value) element.setAttribute(attribute.name, clean);
}
// Legacy table/body background images fetch like url(); same policy.
else if (name === "background" && !namespace && !safeCssReference(value))
element.removeAttributeNode(attribute);
}
return doc;
}
const XLINK_NAMESPACE = "http://www.w3.org/1999/xlink";
const SVG_ANIMATION_ELEMENTS = new Set(["animate", "animatemotion", "animatetransform", "set", "discard"]);
// Offline HTML may embed only inline images. Restrict URL-bearing attributes to
// that rule; ordinary attributes (colspan, width, lang...) keep DOMPurify defaults.
const OFFLINE_URL_ATTRIBUTES = new Set([
"href", "xlink:href", "src", "srcset", "poster", "action", "formaction", "background",
"cite", "data", "longdesc", "ping", "usemap", "codebase", "manifest", "lowsrc", "dynsrc"
]);
const OFFLINE_INLINE_IMAGE = /^data:image\/(?:gif|png|jpeg|webp|svg\+xml);/i;
function restrictOfflineUrlAttribute(_node, data) {
if (OFFLINE_URL_ATTRIBUTES.has(data.attrName) &&
!OFFLINE_INLINE_IMAGE.test(String(data.attrValue || "").replace(/[\u0000-\u0020\u007f-\u009f]/g, "")))
data.keepAttr = false;
}
// Style attributes and SVG presentation attributes that accept CSS url().
const CSS_VALUE_ATTRIBUTES = new Set([
"style", "fill", "stroke", "filter", "mask", "clip-path",
"marker-start", "marker-mid", "marker-end", "cursor"
]);
// Functions whose string arguments are resource URLs (CSS Images 4/Fonts 4).
const CSS_REFERENCE_FUNCTIONS = new Set([
"url", "src", "image", "image-set", "-webkit-image-set", "cross-fade", "-webkit-cross-fade"
]);
const CSS_BLOB_STYLESHEET_MAX_BYTES = 4 * 1024 * 1024;
const CSS_IMPORT_MAX_DEPTH = 4;
const CSS_IMPORT_MAX_PER_SHEET = 32;
function cssNameChar(code) {
return (code >= 0x61 && code <= 0x7a) || (code >= 0x41 && code <= 0x5a) ||
(code >= 0x30 && code <= 0x39) || code === 0x2d || code === 0x5f || code >= 0x80;
}
function cssWhitespace(code) {
return code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d;
}
// Resource policy: fragments, blob:, data: and same-origin relative paths only.
// data: cannot fetch, blob: is an unguessable same-origin object, and any scheme
// (https:, javascript:, file:...) or protocol-relative reference is rejected
// after control/whitespace normalization, matching URL parser leniency.
function safeCssReference(raw, { strict = false } = {}) {
if (strict) return false;
const value = String(raw || "").replace(/[\u0000-\u0020\u007f-\u009f]/g, "");
if (!value || value.startsWith("#") || /^(?:blob|data):/i.test(value)) return true;
if (/^[\\/]{2}/.test(value)) return false;
return !/^[a-z][a-z0-9+.-]*:/i.test(value);
}
// Minimal CSS Syntax 3 scanner: decodes escapes and honours strings/comments
// exactly where the browser does, so escaped names (`\75rl(`), comments
// (`@import/**/"x"`) and quoted references cannot hide a remote fetch.
// Unsafe url()/image-set()/image() become `none` ("" in strict mode), unsafe
// custom-property URL strings become "", and unsafe
// @import rules are removed. Everything else is copied byte-for-byte.
function scanCss(source, { strict = false, keepBlobImports = false, blobImports = null, nonce = "" } = {}) {
const css = String(source || "");
const length = css.length;
const edits = [];
const stack = [];
let i = 0, customValueDepth = -1, imports = 0;
// Offline HTML historically deletes references; EPUB styles keep a valid
// `none` so shorthand declarations stay parseable.
const removed = strict ? "" : "none";
const readEscape = () => {
// css[i] is a backslash that is not followed by a newline.
i++;
if (i >= length) return "\ufffd";
let hex = "";
while (hex.length < 6 && i < length && /[0-9a-f]/i.test(css[i])) hex += css[i++];
if (hex) {
if (i < length && css[i] === "\r" && css[i + 1] === "\n") i += 2;
else if (i < length && cssWhitespace(css.charCodeAt(i))) i++;
const code = parseInt(hex, 16);
return code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)
? "\ufffd" : String.fromCodePoint(code);
}
const char = String.fromCodePoint(css.codePointAt(i));
i += char.length;
return char;
};
const validEscape = (at) => css[at] === "\\" && at + 1 < length && !/[\n\r\f]/.test(css[at + 1]);
const readName = () => {
let name = "";
while (i < length) {
if (cssNameChar(css.charCodeAt(i))) name += css[i++];
else if (validEscape(i)) name += readEscape();
else break;
}
return name;
};
const readString = () => {
const quote = css[i++];
let value = "";
while (i < length) {
const char = css[i];
if (char === quote) { i++; return value; }
if (char === "\n" || char === "\r" || char === "\f") return value; // bad-string
if (char === "\\") {
if (i + 1 >= length) { i++; continue; }
if (css[i + 1] === "\r" && css[i + 2] === "\n") { i += 3; continue; }
if (/[\n\r\f]/.test(css[i + 1])) { i += 2; continue; }
value += readEscape();
continue;
}
value += char;
i++;
}
return value;
};
const skipComment = () => {
const end = css.indexOf("*/", i + 2);
i = end < 0 ? length : end + 2;
};
// Unquoted url( token; `i` is just after "(". Returns null for bad-url.
const readUrlToken = () => {
let value = "", bad = false;
while (i < length) {
const code = css.charCodeAt(i);
if (css[i] === ")") { i++; return bad ? null : value; }
if (cssWhitespace(code)) {
while (i < length && cssWhitespace(css.charCodeAt(i))) i++;
if (css[i] !== ")" && i < length) bad = true;
continue;
}
if (css[i] === "\"" || css[i] === "'" || css[i] === "(" || code <= 0x08 ||
code === 0x0b || (code >= 0x0e && code <= 0x1f) || code === 0x7f) {
bad = true; i++; continue;
}
if (css[i] === "\\") {
if (validEscape(i)) value += readEscape();
else { bad = true; i++; }
continue;
}
value += css[i++];
}
return bad ? null : value;
};
// Skip to the end of an @import prelude (`;`) or past a stray `{...}` block.
const skipAtRule = () => {
let depth = 0;
while (i < length) {
const char = css[i];
if (char === "/" && css[i + 1] === "*") { skipComment(); continue; }
if (char === "\"" || char === "'") { readString(); continue; }
if (char === "\\") { if (validEscape(i)) readEscape(); else i++; continue; }
i++;
if (char === "(" || char === "[" || char === "{") depth++;
else if (char === ")" || char === "]") depth = Math.max(0, depth - 1);
else if (char === "}") { if (--depth <= 0) return; }
else if (char === ";" && depth === 0) return;
}
};
const handleImport = (start) => {
// `i` is just after "@import".
let target = null, cursor;
while (i < length) {
if (cssWhitespace(css.charCodeAt(i))) { i++; continue; }
if (css[i] === "/" && css[i + 1] === "*") { skipComment(); continue; }
break;
}
if (css[i] === "\"" || css[i] === "'") target = readString();
else {
cursor = i;
const name = cssNameChar(css.charCodeAt(i)) || validEscape(i) ? readName() : "";
if (name.toLowerCase() === "url" && css[i] === "(") {
i++;
while (i < length && cssWhitespace(css.charCodeAt(i))) i++;
if (css[i] === "\"" || css[i] === "'") {
target = readString();
while (i < length && cssWhitespace(css.charCodeAt(i))) i++;
if (css[i] === ")") i++; else target = null;
} else target = readUrlToken();
} else i = cursor;
}
const conditionStart = i;
skipAtRule();
const end = i;
const condition = css.slice(conditionStart, end).replace(/;\s*$/, "").trim();
const value = target === null ? "" : target.replace(/[\u0000-\u0020\u007f-\u009f]/g, "");
const blob = /^blob:/i.test(value);
// Same-origin relative sheets are allowed; data: CSS is never imported.
const relative = !!value && !blob && !/^data:/i.test(value) && !value.startsWith("#") &&
safeCssReference(value);
if (!strict && blob && blobImports && imports < CSS_IMPORT_MAX_PER_SHEET &&
/^[\w\s(),:.\-]*$/.test(condition) && !/^(?:layer|supports)\b/i.test(condition)) {
blobImports.push({ url: value, media: condition });
edits.push([start, end, `/*${nonce}:${blobImports.length - 1}*/`]);
imports++;
} else if (strict || target === null || !(relative || (blob && keepBlobImports && !blobImports)))
edits.push([start, end, ""]);
};
while (i < length) {
const char = css[i], code = css.charCodeAt(i);
if (char === "/" && css[i + 1] === "*") { skipComment(); continue; }
if (char === "\"" || char === "'") {
const start = i, value = readString();
const frame = stack[stack.length - 1];
// Custom properties can later be substituted into image-set()/image();
// treat absolute or protocol-relative strings there as references.
const reference = frame?.reference || (customValueDepth >= 0 &&
/^(?:[a-z][a-z0-9+.-]*:|[\\/]{2})/i.test(value.replace(/[\u0000-\u0020\u007f-\u009f]/g, "")));
if (reference && !safeCssReference(value, { strict })) {
if (frame?.reference) frame.unsafe = true;
else edits.push([start, i, "\"\""]);
}
continue;
}
if (char === "@" && (cssNameChar(css.charCodeAt(i + 1)) || validEscape(i + 1))) {
const start = i;
i++;
const name = readName().toLowerCase();
if (name === "import" && !stack.length) handleImport(start);
continue;
}
if (cssNameChar(code) || validEscape(i) ||
(char === "-" && (cssNameChar(css.charCodeAt(i + 1)) || validEscape(i + 1)))) {
const start = i, name = readName().toLowerCase();
if (css[i] === "(") {
i++;
if (name === "url") {
let probe = i;
while (probe < length && cssWhitespace(css.charCodeAt(probe))) probe++;
if (css[probe] !== "\"" && css[probe] !== "'") {
i = probe;
const value = readUrlToken();
if (value === null || !safeCssReference(value, { strict })) {
edits.push([start, i, removed]);
const parent = stack[stack.length - 1];
if (parent?.reference) parent.unsafe = true;
}
continue;
}
}
stack.push({ start, name, reference: CSS_REFERENCE_FUNCTIONS.has(name), unsafe: false });
continue;
}
if (name.startsWith("--") && customValueDepth < 0) {
let probe = i;
while (probe < length && cssWhitespace(css.charCodeAt(probe))) probe++;
if (css[probe] === ":") { customValueDepth = stack.length; i = probe + 1; }
}
continue;
}
if (char === "\\") { i++; continue; } // invalid escape
if (char === "(") { stack.push({ start: i, name: "", reference: false, unsafe: false }); i++; continue; }
if (char === ")") {
const frame = stack.pop();
i++;
if (frame?.unsafe) {
edits.push([frame.start, i, removed]);
const parent = stack[stack.length - 1];
if (parent?.reference) parent.unsafe = true;
}
continue;
}
if ((char === ";" || char === "}") && customValueDepth >= 0 && stack.length <= customValueDepth) customValueDepth = -1;
if (char === "{" || char === "}") stack.length = 0;
i++;
}
for (const frame of stack) if (frame.unsafe) edits.push([frame.start, length, removed]);
if (!edits.length) return css;
edits.sort((a, b) => a[0] - b[0] || b[1] - a[1]);
let output = "", cursor = 0;
for (const [start, end, replacement] of edits) {
if (start < cursor) continue; // nested inside an earlier replacement
output += css.slice(cursor, start) + replacement;
cursor = end;
}
return output + css.slice(cursor);
}
function sanitizeCssReferences(css, options = {}) {
return scanCss(css, options);
}
async function readBlobStylesheet(url) {
try {
const response = await fetch(url);
if (!response.ok) return null;
const declared = Number(response.headers.get("content-length") || 0);
if (declared > CSS_BLOB_STYLESHEET_MAX_BYTES) return null;
const blob = await response.blob();
return blob.size > CSS_BLOB_STYLESHEET_MAX_BYTES ? null : await blob.text();
} catch (_) {
return null;
}
}
// Replace blob @imports with their sanitized contents, preserving cascade
// position and media conditions. Cycles, excessive depth and unreadable
// sheets are dropped rather than loaded unsanitized.
async function inlineCssImports(css, depth = 0, ancestors = new Set()) {
const blobImports = [];
const nonce = `reader-import-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
const sanitized = scanCss(css, { blobImports, nonce });
if (!blobImports.length) return sanitized;
const bodies = await Promise.all(blobImports.map(async ({ url, media }) => {
if (depth >= CSS_IMPORT_MAX_DEPTH || ancestors.has(url)) return "";
const text = await readBlobStylesheet(url);
if (text === null) return "";
const body = (await inlineCssImports(text, depth + 1, new Set([...ancestors, url])))
.replace(/^\ufeff/, "").replace(/^\s*@charset\s+"[^"]*"\s*;/i, "");
return media && !/^all$/i.test(media) ? `@media ${media} {\n${body}\n}` : body;
}));
return sanitized.replace(new RegExp(`/\\*${nonce}:(\\d+)\\*/`, "g"), (_, index) => bodies[Number(index)] ?? "");
}
// Foliate rewrites chapter stylesheets to blob: URLs. Load them as sanitized
// inline styles so remote references inside packaged CSS cannot escape the
// archive-only resource policy. Results are cached per blob URL.
const foliateStyleCache = new Map();
const FOLIATE_STYLE_CACHE_MAX = 64;
async function sanitizeFoliateStyles(nodes) {
const output = [];
for (const node of nodes) {
if (node.localName === "link") {
const href = node.getAttribute("href") || "";
if (!/^blob:/i.test(href)) continue;
let pending = foliateStyleCache.get(href);
if (!pending) {
pending = readBlobStylesheet(href).then(text => text === null ? null : inlineCssImports(text, 0, new Set([href])));
pending.then(text => { if (text === null) foliateStyleCache.delete(href); });
foliateStyleCache.set(href, pending);
while (foliateStyleCache.size > FOLIATE_STYLE_CACHE_MAX)
foliateStyleCache.delete(foliateStyleCache.keys().next().value);
}
const text = await pending;
if (text === null) continue;
const style = document.createElement("style");
const media = node.getAttribute("media");
if (media) style.setAttribute("media", media);
style.textContent = text;
output.push(style);
} else {
const style = node.cloneNode(false);
style.textContent = await inlineCssImports(node.textContent || "");
output.push(style);
}
}
return output;
}
function sanitizeEpubDocument(doc, options = {}) {
return readerContext.sanitizeReaderDocument(readerContext.normalizeEpubDocument(doc), { ...options, preserveEpubStyles: true });
}
function parseChapterDocument(text) {
// Parse generated XHTML before HTML's void-element rules can swallow children
// of prefixed elements such as (including the following title).
const xml = new DOMParser().parseFromString(text, "application/xhtml+xml");
if (!xml.querySelector("parsererror"))
text = new XMLSerializer().serializeToString(readerContext.normalizeEpubDocument(xml));
return readerContext.sanitizeEpubDocument(new DOMParser().parseFromString(text, "text/html"));
}
function epubContentBody(doc) {
const nestedBody = [...doc.querySelectorAll("body")].find((body) => body !== doc.body);
return nestedBody || doc.body || doc.documentElement;
}
async function renderText(markdown, prepared) {
let response = await prepared.response;
if (!response.ok) response = await readerContext.fetchReaderResponse();
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (!markdown) await readerContext.renderPlainText(response);
else {
const bytes = await readerContext.readPreparedBytes(response, prepared.engines);
const text = new TextDecoder(readerContext.detectTextEncoding(bytes, readerContext.documentState.title)).decode(bytes);
const article = document.createElement("article");
article.className = "reader-markdown";
article.innerHTML = DOMPurify.sanitize(marked.parse(text), { USE_PROFILES: { html: true } });
readerContext.content.appendChild(article);
readerContext.setToc(readerContext.headingTocEntries(article));
}
readerContext.markReaderContentReady();
readerContext.status.textContent = "已加载";
}
function sanitizeOfflineHtml(text) {
// A global ALLOWED_URI_REGEXP would also strip every non-allow-listed plain
// attribute (colspan/rowspan/width), so scope the inline-image rule to URLs.
DOMPurify.addHook("uponSanitizeAttribute", restrictOfflineUrlAttribute);
let clean;
try {
clean = DOMPurify.sanitize(text, {
USE_PROFILES: { html: true },
RETURN_DOM_FRAGMENT: true,
ADD_TAGS: ["style"],
FORBID_TAGS: ["base", "embed", "form", "iframe", "object", "script"],
FORBID_ATTR: ["action", "formaction", "srcdoc"]
});
} finally {
DOMPurify.removeHook("uponSanitizeAttribute", restrictOfflineUrlAttribute);
}
const template = document.createElement("template");
template.content.appendChild(clean);
const stripUrls = (value) => sanitizeCssReferences(value, { strict: true });
for (const style of template.content.querySelectorAll("style")) {
const value = style.textContent, stripped = stripUrls(value);
if (stripped !== value) style.textContent = stripped;
}
for (const element of template.content.querySelectorAll("[style]")) {
const value = element.getAttribute("style"), stripped = stripUrls(value);
if (stripped !== value) element.setAttribute("style", stripped);
}
return template.innerHTML;
}
function buildDiscourseDocument(text) {
try {
const source = new DOMParser().parseFromString(text, "text/html");
const posts = [...source.querySelectorAll(readerContext.DISCOURSE_POST_SELECTOR)];
if (!source.querySelector(".post-stream") || !posts.length) return null;
const article = source.createElement("article");
article.className = "reader-forum-document";
const titleNode = source.querySelector(readerContext.DISCOURSE_TITLE_SELECTOR);
const title = titleNode?.textContent?.trim();
if (title) {
const heading = source.createElement("h1");
heading.textContent = title;
article.appendChild(heading);
}
posts.forEach((post, index) => {
const section = source.createElement("section");
section.className = "reader-forum-post";
section.dataset.post = String(index + 1);
section.innerHTML = post.innerHTML;
article.appendChild(section);
});
return article.outerHTML;
} catch (_) {
return null;
}
}
function prepareHtmlDocument(text) {
const discourse = readerContext.buildDiscourseDocument(text);
const html = readerContext.sanitizeOfflineHtml(discourse || text);
return {
html,
discourse: !!discourse,
spreadsheet: !discourse && html.includes("reader-spreadsheet-sheet")
};
}
function buildHtmlFrameSource(documentData) {
// A CSP only applies as a child, so it must precede any body
// content; the remaining frame styles keep their later cascade position.
const frameStyle = readerContext.HTML_FRAME_STYLE;
const policyEnd = frameStyle.startsWith('") + 1 : 0;
return frameStyle.slice(0, policyEnd) + documentData.html + frameStyle.slice(policyEnd) +
(documentData.discourse ? readerContext.DISCOURSE_HTML_STYLE : "") +
(documentData.spreadsheet ? readerContext.SPREADSHEET_HTML_STYLE : "");
}
async function waitForSpreadsheetImages(frame) {
if (frame.contentDocument)
await readerContext.settleReaderImages(frame.contentDocument, 30000);
}
async function renderHtml(prepared) {
const bytes = await readerContext.readPreparedBytes(prepared.response, prepared.engine);
const text = new TextDecoder(readerContext.detectHtmlEncoding(bytes, readerContext.documentState.title)).decode(bytes);
readerContext.assertReaderActive();
const documentData = readerContext.prepareHtmlDocument(text);
const frame = document.createElement("iframe");
readerContext.htmlFrame = frame;
frame.className = "html-frame";
readerContext.content.classList.toggle("reader-content--spreadsheet", documentData.spreadsheet);
if (documentData.spreadsheet) frame.classList.add("spreadsheet-html-frame");
frame.title = `${readerContext.documentState.title} 正文`;
frame.setAttribute("sandbox", "allow-same-origin");
frame.setAttribute("referrerpolicy", "no-referrer");
frame.style.colorScheme = "only light";
const frameLoaded = new Promise((resolve, reject) => {
let untrack = () => {}, frameTimer = 0, deadline = 0, started = false;
const cleanup = () => {
clearTimeout(frameTimer);
clearTimeout(deadline);
frame.removeEventListener("load", loaded);
untrack();
};
const loaded = async () => {
if (started || frame.contentDocument?.URL !== "about:srcdoc" || frame.contentDocument.readyState === "loading") return;
started = true;
cleanup();
try {
readerContext.assertReaderActive();
readerContext.repairHtmlContrast(frame);
frame.contentDocument.documentElement.style.zoom = String(readerContext.documentState.zoom);
frame.contentWindow.addEventListener("scroll", readerContext.handleReaderPositionChange, {
passive: true
});
frame.contentDocument.addEventListener("keydown", readerContext.handleReaderKeydown);
const doc = frame.contentDocument;
doc.addEventListener("click", readerContext.handleReaderOutsideClick);
readerContext.trackReaderResource(() => doc.removeEventListener("click", readerContext.handleReaderOutsideClick));
readerContext.installReaderTapSelectionClear(frame.contentDocument.body, frame.contentDocument);
for (const type of ["wheel", "touchstart", "pointerdown"])
frame.contentDocument.addEventListener(type, readerContext.beginReaderNavigation, { passive: true });
readerContext.setToc(readerContext.headingTocEntries(frame.contentDocument));
if (documentData.spreadsheet) await readerContext.waitForSpreadsheetImages(frame);
readerContext.assertReaderActive();
resolve();
} catch (error) {
reject(error);
}
};
untrack = readerContext.trackReaderResource(() => {
cleanup();
reject(readerContext.readerAbortError());
});
const poll = () => {
if (started) return;
loaded();
if (!started) frameTimer = setTimeout(poll, 16);
};
frameTimer = setTimeout(poll, 0);
deadline = setTimeout(() => { cleanup(); reject(new Error("HTML document initialization timeout")); }, 20000);
frame.addEventListener("load", loaded);
});
frame.srcdoc = readerContext.buildHtmlFrameSource(documentData);
readerContext.content.appendChild(frame);
await frameLoaded;
readerContext.markReaderContentReady();
readerContext.status.textContent = documentData.spreadsheet ? "表格" : "HTML";
}
function detectHtmlEncoding(bytes, hint = "") {
const probe = String.fromCharCode(...bytes.subarray(0, 8192));
const match = probe.match(/charset\s*=\s*["']?\s*([a-z0-9._:-]+)/i);
if (match) {
const label =
{ gb2312: "gb18030", "gb-2312": "gb18030", gbk: "gb18030", "x-gbk": "gb18030" }[
match[1].toLowerCase()
] || match[1];
try {
new TextDecoder(label);
return label;
} catch (_) {}
}
return readerContext.detectTextEncoding(bytes, hint);
}
function repairHtmlContrast(frame) {
const doc = frame.contentDocument;
if (!doc || !doc.body) return;
const parseColor = (value) => {
const parts = String(value).match(/[\d.]+/g);
return parts && parts.length >= 3
? [
Number(parts[0]),
Number(parts[1]),
Number(parts[2]),
parts[3] === undefined ? 1 : Number(parts[3])
]
: null;
};
const luminance = (color) => {
const channels = color.slice(0, 3).map((value) => {
const normalized = value / 255;
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
});
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
};
const backgrounds = new Map();
const background = (element, style) => {
const pending = [];
let light = 1;
for (let current = element; current; current = current.parentElement) {
if (backgrounds.has(current)) {
light = backgrounds.get(current);
break;
}
pending.push(current);
const color = parseColor((current === element ? style :
frame.contentWindow.getComputedStyle(current)).backgroundColor);
if (color && color[3] > 0.1) {
light = luminance(color);
break;
}
}
for (const current of pending) backgrounds.set(current, light);
return light;
};
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
const elements = new Set();
while (walker.nextNode() && elements.size < 10000)
if (walker.currentNode.data.trim()) elements.add(walker.currentNode.parentElement);
for (const element of elements) {
const style = frame.contentWindow.getComputedStyle(element);
const foreground = parseColor(style.color);
const dark = background(element, style);
if (!foreground) continue;
const light = luminance(foreground);
const contrast = (Math.max(light, dark) + 0.05) / (Math.min(light, dark) + 0.05);
if (contrast < 3) {
element.style.setProperty("color", dark > 0.45 ? "#111" : "#f5f5f5", "important");
// Inherited color and currentColor backgrounds can change after a repair.
backgrounds.clear();
}
}
}
return {
normalizeEpubDocument,
sanitizeReaderDocument,
sanitizeEpubDocument,
sanitizeCssReferences,
sanitizeFoliateStyles,
parseChapterDocument,
epubContentBody,
renderText,
sanitizeOfflineHtml,
buildDiscourseDocument,
prepareHtmlDocument,
buildHtmlFrameSource,
waitForSpreadsheetImages,
renderHtml,
detectHtmlEncoding,
repairHtmlContrast
};
}