Speed up Reader startup and stream encoded TXT
Browse files- static/app.js +8 -4
- static/reader.js +60 -5
- static/sw.js +12 -0
static/app.js
CHANGED
|
@@ -748,13 +748,17 @@ const warmedReaderAssets = new Set();
|
|
| 748 |
function warmReaderIntent(rawUrl) {
|
| 749 |
if (!rawUrl) return;
|
| 750 |
let extension = "";
|
| 751 |
-
try {
|
| 752 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 753 |
? ["/static/vendor/pdf.min.e0be3863c23c.mjs", "/static/pdf-worker-wrapper.mjs", "/static/vendor/pdf.worker.min.0613f41490dd.mjs"]
|
| 754 |
-
: extension === "epub" ? ["/static/vendor/epub.min.06eae1574510.js"]
|
| 755 |
: extension === "docx" ? ["/static/vendor/jszip.min.acc7e41455a8.js", "/static/vendor/docx-preview.min.3573b8d99344.js"]
|
| 756 |
: ["md", "markdown"].includes(extension) ? ["/static/vendor/marked.min.eaccee2fb9fb.js", "/static/vendor/purify.min.c2f26ea4fc0d.js"] : [];
|
| 757 |
-
for (const href of
|
| 758 |
if (warmedReaderAssets.has(href)) continue;
|
| 759 |
warmedReaderAssets.add(href);
|
| 760 |
const link = document.createElement("link"); link.rel = "prefetch"; link.href = href; document.head.appendChild(link);
|
|
|
|
| 748 |
function warmReaderIntent(rawUrl) {
|
| 749 |
if (!rawUrl) return;
|
| 750 |
let extension = "";
|
| 751 |
+
try {
|
| 752 |
+
const readerUrl = new URL(rawUrl, location.origin);
|
| 753 |
+
extension = (readerUrl.searchParams.get("ext") || "").toLowerCase();
|
| 754 |
+
} catch (_) { return; }
|
| 755 |
+
const shellAssets = ["/static/reader.css", "/static/reader-contract.js", "/static/reader-store.js", "/static/reader.js"];
|
| 756 |
+
const engineAssets = extension === "pdf"
|
| 757 |
? ["/static/vendor/pdf.min.e0be3863c23c.mjs", "/static/pdf-worker-wrapper.mjs", "/static/vendor/pdf.worker.min.0613f41490dd.mjs"]
|
| 758 |
+
: extension === "epub" ? ["/static/vendor/jszip.min.acc7e41455a8.js", "/static/vendor/epub.min.06eae1574510.js"]
|
| 759 |
: extension === "docx" ? ["/static/vendor/jszip.min.acc7e41455a8.js", "/static/vendor/docx-preview.min.3573b8d99344.js"]
|
| 760 |
: ["md", "markdown"].includes(extension) ? ["/static/vendor/marked.min.eaccee2fb9fb.js", "/static/vendor/purify.min.c2f26ea4fc0d.js"] : [];
|
| 761 |
+
for (const href of shellAssets.concat(engineAssets)) {
|
| 762 |
if (warmedReaderAssets.has(href)) continue;
|
| 763 |
warmedReaderAssets.add(href);
|
| 764 |
const link = document.createElement("link"); link.rel = "prefetch"; link.href = href; document.head.appendChild(link);
|
static/reader.js
CHANGED
|
@@ -286,19 +286,74 @@ async function renderText(markdown, prepared) {
|
|
| 286 |
let response = await prepared.response;
|
| 287 |
if (!response.ok) response = await fetch(sourceUrl);
|
| 288 |
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
| 289 |
-
|
| 290 |
-
if (!markdown) { const pre = document.createElement("pre"); pre.className = "reader-text"; pre.textContent = text; content.appendChild(pre); }
|
| 291 |
else {
|
|
|
|
| 292 |
await prepared.engines;
|
| 293 |
const article = document.createElement("article"); article.className = "reader-markdown";
|
| 294 |
article.innerHTML = DOMPurify.sanitize(marked.parse(text), { USE_PROFILES: { html: true } }); content.appendChild(article);
|
| 295 |
}
|
| 296 |
status.textContent = "已加载";
|
| 297 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
async function renderEpub(prepared) {
|
| 299 |
-
await prepared;
|
|
|
|
| 300 |
const frame = document.createElement("div"); frame.className = "epub-frame"; content.appendChild(frame);
|
| 301 |
-
await displayEpub(
|
| 302 |
status.textContent = "EPUB";
|
| 303 |
}
|
| 304 |
async function displayEpub(url, frame) {
|
|
@@ -352,7 +407,7 @@ function prepareDocument() {
|
|
| 352 |
if (capability.mode === "pdf") return import(PDFJS_URL).then((pdfjs) => { pdfjs.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL; return pdfjs.getDocument({ url: contentUrl, withCredentials: false }).promise.catch(() => pdfjs.getDocument({ url: sourceUrl, withCredentials: false }).promise); });
|
| 353 |
if (capability.mode === "markdown") return Promise.all([fetch(contentUrl), Promise.all([loadScript(MARKED_URL), loadScript(PURIFY_URL)])]).then(([response, engines]) => ({ response, engines }));
|
| 354 |
if (capability.mode === "text") return fetch(contentUrl).then((response) => ({ response }));
|
| 355 |
-
if (capability.mode === "epub") return loadScript(EPUB_URL);
|
| 356 |
if (capability.mode === "docx") return Promise.all([fetch(contentUrl), loadScript(JSZIP_URL).then(() => loadScript(DOCX_PREVIEW_URL))]);
|
| 357 |
if (capability.mode === "image") return new Promise((resolve, reject) => { const image = new Image(); image.className = "reader-image"; image.alt = title; image.decoding = "async"; let fallback = false; image.onload = () => resolve(image); image.onerror = () => { if (!fallback) { fallback = true; image.src = sourceUrl; } else reject(new Error("image load failed")); }; image.src = contentUrl; });
|
| 358 |
return Promise.resolve(null);
|
|
|
|
| 286 |
let response = await prepared.response;
|
| 287 |
if (!response.ok) response = await fetch(sourceUrl);
|
| 288 |
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
| 289 |
+
if (!markdown) await renderPlainText(response);
|
|
|
|
| 290 |
else {
|
| 291 |
+
const text = await response.text();
|
| 292 |
await prepared.engines;
|
| 293 |
const article = document.createElement("article"); article.className = "reader-markdown";
|
| 294 |
article.innerHTML = DOMPurify.sanitize(marked.parse(text), { USE_PROFILES: { html: true } }); content.appendChild(article);
|
| 295 |
}
|
| 296 |
status.textContent = "已加载";
|
| 297 |
}
|
| 298 |
+
async function renderPlainText(response) {
|
| 299 |
+
const pre = document.createElement("pre"); pre.className = "reader-text";
|
| 300 |
+
const textNode = document.createTextNode(""); pre.appendChild(textNode); content.appendChild(pre);
|
| 301 |
+
if (!response.body || !response.body.getReader) {
|
| 302 |
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
| 303 |
+
textNode.data = new TextDecoder(detectTextEncoding(bytes, title)).decode(bytes);
|
| 304 |
+
return;
|
| 305 |
+
}
|
| 306 |
+
const reader = response.body.getReader();
|
| 307 |
+
const first = await reader.read();
|
| 308 |
+
const sample = first.value || new Uint8Array();
|
| 309 |
+
let streamDone = first.done;
|
| 310 |
+
const decoder = new TextDecoder(detectTextEncoding(sample, title));
|
| 311 |
+
let pending = "", frame = 0;
|
| 312 |
+
const flush = () => { frame = 0; if (pending) { textNode.appendData(pending); pending = ""; } };
|
| 313 |
+
const scheduleFlush = () => { if (!frame) frame = requestAnimationFrame(flush); };
|
| 314 |
+
pending = decoder.decode(sample, { stream: !streamDone });
|
| 315 |
+
scheduleFlush();
|
| 316 |
+
while (!streamDone) {
|
| 317 |
+
const { value, done } = await reader.read();
|
| 318 |
+
if (done) { streamDone = true; break; }
|
| 319 |
+
pending += decoder.decode(value, { stream: true });
|
| 320 |
+
scheduleFlush();
|
| 321 |
+
}
|
| 322 |
+
pending += decoder.decode();
|
| 323 |
+
if (frame) cancelAnimationFrame(frame);
|
| 324 |
+
flush();
|
| 325 |
+
}
|
| 326 |
+
function detectTextEncoding(bytes, hint = "") {
|
| 327 |
+
if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) return "utf-8";
|
| 328 |
+
if (bytes[0] === 0xff && bytes[1] === 0xfe) return "utf-16le";
|
| 329 |
+
if (bytes[0] === 0xfe && bytes[1] === 0xff) return "utf-16be";
|
| 330 |
+
const evenNulls = bytes.filter((value, index) => !value && index % 2 === 0).length;
|
| 331 |
+
const oddNulls = bytes.filter((value, index) => !value && index % 2 === 1).length;
|
| 332 |
+
if (oddNulls > bytes.length / 8 && oddNulls > evenNulls * 4) return "utf-16le";
|
| 333 |
+
if (evenNulls > bytes.length / 8 && evenNulls > oddNulls * 4) return "utf-16be";
|
| 334 |
+
try { new TextDecoder("utf-8", { fatal: true }).decode(bytes, { stream: true }); return "utf-8"; } catch (_) {}
|
| 335 |
+
if (/[\u0400-\u04ff]/.test(hint)) return "windows-1251";
|
| 336 |
+
const candidates = ["gb18030", "big5", "windows-1251", "windows-1252"];
|
| 337 |
+
let best = "gb18030", bestScore = -Infinity;
|
| 338 |
+
for (const encoding of candidates) {
|
| 339 |
+
try {
|
| 340 |
+
const text = new TextDecoder(encoding, { fatal: true }).decode(bytes);
|
| 341 |
+
const controls = (text.match(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g) || []).length;
|
| 342 |
+
const cjk = (text.match(/[\u3400-\u9fff]/g) || []).length;
|
| 343 |
+
const cyrillic = (text.match(/[\u0400-\u04ff]/g) || []).length;
|
| 344 |
+
const commonCjk = (text.match(/[的一是在不了有和人这中大为上个国我以要他时来用们生到作地于出就分对成会可主发年动同工也能下过子说产种面而方后多定行学法所民得经之进着等部家自理起现实都体制当本性应开合因由然前外政社义事相全与关各重新内正反明原利质向道命此变结解问意建公系军情者立代通题党程展料员革文总品活长求老基资级图统知组别期论运农指区战任处理世]/g) || []).length;
|
| 345 |
+
const commonCyrillic = (text.toLowerCase().match(/[оеаинтсрвлкмдпуяызьгчбйхжюшцщэфъ]/g) || []).length;
|
| 346 |
+
const score = Math.max(cjk + commonCjk * 5, cyrillic + commonCyrillic) - controls * 20;
|
| 347 |
+
if (score > bestScore) { best = encoding; bestScore = score; }
|
| 348 |
+
} catch (_) {}
|
| 349 |
+
}
|
| 350 |
+
return best;
|
| 351 |
+
}
|
| 352 |
async function renderEpub(prepared) {
|
| 353 |
+
const [response] = await prepared;
|
| 354 |
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
| 355 |
const frame = document.createElement("div"); frame.className = "epub-frame"; content.appendChild(frame);
|
| 356 |
+
await displayEpub(await response.arrayBuffer(), frame);
|
| 357 |
status.textContent = "EPUB";
|
| 358 |
}
|
| 359 |
async function displayEpub(url, frame) {
|
|
|
|
| 407 |
if (capability.mode === "pdf") return import(PDFJS_URL).then((pdfjs) => { pdfjs.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL; return pdfjs.getDocument({ url: contentUrl, withCredentials: false }).promise.catch(() => pdfjs.getDocument({ url: sourceUrl, withCredentials: false }).promise); });
|
| 408 |
if (capability.mode === "markdown") return Promise.all([fetch(contentUrl), Promise.all([loadScript(MARKED_URL), loadScript(PURIFY_URL)])]).then(([response, engines]) => ({ response, engines }));
|
| 409 |
if (capability.mode === "text") return fetch(contentUrl).then((response) => ({ response }));
|
| 410 |
+
if (capability.mode === "epub") return Promise.all([fetch(contentUrl), loadScript(JSZIP_URL).then(() => loadScript(EPUB_URL))]);
|
| 411 |
if (capability.mode === "docx") return Promise.all([fetch(contentUrl), loadScript(JSZIP_URL).then(() => loadScript(DOCX_PREVIEW_URL))]);
|
| 412 |
if (capability.mode === "image") return new Promise((resolve, reject) => { const image = new Image(); image.className = "reader-image"; image.alt = title; image.decoding = "async"; let fallback = false; image.onload = () => resolve(image); image.onerror = () => { if (!fallback) { fallback = true; image.src = sourceUrl; } else reject(new Error("image load failed")); }; image.src = contentUrl; });
|
| 413 |
return Promise.resolve(null);
|
static/sw.js
CHANGED
|
@@ -60,6 +60,18 @@ self.addEventListener("fetch", (event) => {
|
|
| 60 |
if (url.pathname.startsWith("/txt/")) {
|
| 61 |
return;
|
| 62 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
if (event.request.mode === "navigate") {
|
| 64 |
return;
|
| 65 |
}
|
|
|
|
| 60 |
if (url.pathname.startsWith("/txt/")) {
|
| 61 |
return;
|
| 62 |
}
|
| 63 |
+
if (event.request.mode === "navigate" && url.pathname === "/static/reader.html") {
|
| 64 |
+
event.respondWith(
|
| 65 |
+
caches.open(CACHE_NAME).then((cache) => cache.match("/static/reader.html").then((cached) => {
|
| 66 |
+
const fetchPromise = fetch(event.request).then((response) => {
|
| 67 |
+
if (response.ok) cache.put("/static/reader.html", response.clone());
|
| 68 |
+
return response;
|
| 69 |
+
}).catch(() => cached);
|
| 70 |
+
return cached || fetchPromise;
|
| 71 |
+
}))
|
| 72 |
+
);
|
| 73 |
+
return;
|
| 74 |
+
}
|
| 75 |
if (event.request.mode === "navigate") {
|
| 76 |
return;
|
| 77 |
}
|