Support sanitized HTML Reader assets and local resources
Browse files- app.py +3 -3
- static/app.js +1 -1
- static/reader.css +6 -0
- static/reader.js +18 -3
app.py
CHANGED
|
@@ -118,7 +118,7 @@ def decode_reader_assets(raw: bytes) -> dict:
|
|
| 118 |
if entry.get("s") == 2:
|
| 119 |
path = entry.get("p")
|
| 120 |
mode = entry.get("m")
|
| 121 |
-
if mode not in ("p", "e", "d") or not isinstance(path, str) or not re.fullmatch(r"objects/[0-9a-f]{2}/[0-9a-f]{64}/(?:[a-z0-9-]+/)?(?:document\.pdf|book\.epub|document\.docx)", path):
|
| 122 |
continue
|
| 123 |
clean[key] = {"s": 2, "m": mode, "p": path}
|
| 124 |
else:
|
|
@@ -391,7 +391,7 @@ def validate_voiceofml_source_url(url: str) -> str:
|
|
| 391 |
|
| 392 |
|
| 393 |
READER_ASSET_SOURCE_RE = re.compile(
|
| 394 |
-
r"^/datasets/vomebook/Reader-Assets/resolve/main/objects/[0-9a-f]{2}/[0-9a-f]{64}/(?:[a-z0-9-]+/)?(?:document\.pdf|book\.epub|document\.docx)$"
|
| 395 |
)
|
| 396 |
VOICEOFML_READER_SOURCE_RE = re.compile(r"^/datasets/VoiceOfML/[A-Za-z0-9._-]+/(?:resolve|raw)/main/.+$")
|
| 397 |
|
|
@@ -1419,7 +1419,7 @@ async def api_random_reader(repo: Optional[str] = Query(default=None)):
|
|
| 1419 |
asset = converted.get(index)
|
| 1420 |
if asset:
|
| 1421 |
rec["ReaderLink"] = f"https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/{asset['p']}"
|
| 1422 |
-
rec["ReaderExtension"] = {"p": "pdf", "e": "epub", "d": "docx"}[asset["m"]]
|
| 1423 |
rec["DownloadLink"] = rec["Link"]
|
| 1424 |
return JSONResponse(rec)
|
| 1425 |
|
|
|
|
| 118 |
if entry.get("s") == 2:
|
| 119 |
path = entry.get("p")
|
| 120 |
mode = entry.get("m")
|
| 121 |
+
if mode not in ("p", "e", "d", "h") or not isinstance(path, str) or not re.fullmatch(r"objects/[0-9a-f]{2}/[0-9a-f]{64}/(?:[a-z0-9-]+/)?(?:document\.pdf|book\.epub|document\.docx|document\.html)", path):
|
| 122 |
continue
|
| 123 |
clean[key] = {"s": 2, "m": mode, "p": path}
|
| 124 |
else:
|
|
|
|
| 391 |
|
| 392 |
|
| 393 |
READER_ASSET_SOURCE_RE = re.compile(
|
| 394 |
+
r"^/datasets/vomebook/Reader-Assets/resolve/main/objects/[0-9a-f]{2}/[0-9a-f]{64}/(?:[a-z0-9-]+/)?(?:document\.pdf|book\.epub|document\.docx|document\.html)$"
|
| 395 |
)
|
| 396 |
VOICEOFML_READER_SOURCE_RE = re.compile(r"^/datasets/VoiceOfML/[A-Za-z0-9._-]+/(?:resolve|raw)/main/.+$")
|
| 397 |
|
|
|
|
| 1419 |
asset = converted.get(index)
|
| 1420 |
if asset:
|
| 1421 |
rec["ReaderLink"] = f"https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/{asset['p']}"
|
| 1422 |
+
rec["ReaderExtension"] = {"p": "pdf", "e": "epub", "d": "docx", "h": "html"}[asset["m"]]
|
| 1423 |
rec["DownloadLink"] = rec["Link"]
|
| 1424 |
return JSONResponse(rec)
|
| 1425 |
|
static/app.js
CHANGED
|
@@ -773,7 +773,7 @@ function warmReaderIntent(rawUrl) {
|
|
| 773 |
? ["/static/vendor/pdf.min.e0be3863c23c.mjs", "/static/pdf-worker-wrapper.mjs", "/static/vendor/pdf.worker.min.0613f41490dd.mjs"]
|
| 774 |
: extension === "epub" ? ["/static/vendor/jszip.min.acc7e41455a8.js", "/static/vendor/epub.min.06eae1574510.js"]
|
| 775 |
: extension === "docx" ? ["/static/vendor/jszip.min.acc7e41455a8.js", "/static/vendor/docx-preview.min.3573b8d99344.js"]
|
| 776 |
-
|
| 777 |
for (const href of shellAssets.concat(engineAssets)) {
|
| 778 |
if (warmedReaderAssets.has(href)) continue;
|
| 779 |
warmedReaderAssets.add(href);
|
|
|
|
| 773 |
? ["/static/vendor/pdf.min.e0be3863c23c.mjs", "/static/pdf-worker-wrapper.mjs", "/static/vendor/pdf.worker.min.0613f41490dd.mjs"]
|
| 774 |
: extension === "epub" ? ["/static/vendor/jszip.min.acc7e41455a8.js", "/static/vendor/epub.min.06eae1574510.js"]
|
| 775 |
: extension === "docx" ? ["/static/vendor/jszip.min.acc7e41455a8.js", "/static/vendor/docx-preview.min.3573b8d99344.js"]
|
| 776 |
+
: ["md", "markdown", "html", "htm"].includes(extension) ? ["/static/vendor/marked.min.eaccee2fb9fb.js", "/static/vendor/purify.min.c2f26ea4fc0d.js"] : [];
|
| 777 |
for (const href of shellAssets.concat(engineAssets)) {
|
| 778 |
if (warmedReaderAssets.has(href)) continue;
|
| 779 |
warmedReaderAssets.add(href);
|
static/reader.css
CHANGED
|
@@ -26,6 +26,12 @@ body { margin: 0; height: 100vh; height: 100dvh; overflow: hidden; display: flex
|
|
| 26 |
.loading-status { display: none; color: #aeb5ba; font-size: 13px; text-align: center; padding: 12px; }
|
| 27 |
.loading-status[hidden] { display: none; }
|
| 28 |
.reader-content { --reader-zoom: 1; width: min(100%, 1100px); margin: 0 auto; padding: 20px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
.reader-page { position: relative; margin: 0 auto 18px; background: white; box-shadow: 0 2px 14px #0008; min-height: 160px; }
|
| 30 |
.reader-content[data-mode="pdf"] .reader-page { width: calc(100% * var(--reader-zoom)); }
|
| 31 |
.reader-page canvas { display: block; width: 100%; height: auto; }
|
|
|
|
| 26 |
.loading-status { display: none; color: #aeb5ba; font-size: 13px; text-align: center; padding: 12px; }
|
| 27 |
.loading-status[hidden] { display: none; }
|
| 28 |
.reader-content { --reader-zoom: 1; width: min(100%, 1100px); margin: 0 auto; padding: 20px; }
|
| 29 |
+
.reader-loading-indicator { min-height: 220px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; color: #9ba3aa; font-size: 13px; }
|
| 30 |
+
.reader-loading-spinner { width: 24px; height: 24px; border: 2px solid #3b4147; border-top-color: #8ab4f8; border-radius: 50%; animation: reader-loading-spin 800ms linear infinite; }
|
| 31 |
+
@keyframes reader-loading-spin { to { transform: rotate(360deg); } }
|
| 32 |
+
.reader-loading-indicator { min-height: 220px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; color: #9ba3aa; font-size: 13px; }
|
| 33 |
+
.reader-loading-spinner { width: 24px; height: 24px; border: 2px solid #3b4147; border-top-color: #8ab4f8; border-radius: 50%; animation: reader-loading-spin 800ms linear infinite; }
|
| 34 |
+
@keyframes reader-loading-spin { to { transform: rotate(360deg); } }
|
| 35 |
.reader-page { position: relative; margin: 0 auto 18px; background: white; box-shadow: 0 2px 14px #0008; min-height: 160px; }
|
| 36 |
.reader-content[data-mode="pdf"] .reader-page { width: calc(100% * var(--reader-zoom)); }
|
| 37 |
.reader-page canvas { display: block; width: 100%; height: auto; }
|
static/reader.js
CHANGED
|
@@ -35,6 +35,11 @@ const downloadUrl = params.get("download") || sourceUrl;
|
|
| 35 |
const extension = (params.get("ext") || "").toLowerCase();
|
| 36 |
const capability = VoiceOfMLReader.capability(extension);
|
| 37 |
const content = document.querySelector("#content");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
content.dataset.mode = capability.mode || "unsupported";
|
| 39 |
const status = document.querySelector("#status");
|
| 40 |
const loadingStatus = document.querySelector("#loading-status");
|
|
@@ -71,6 +76,13 @@ const viewport = document.querySelector("#viewport");
|
|
| 71 |
const zoomInput = document.querySelector("#zoom");
|
| 72 |
const pageInput = document.querySelector("#page-number");
|
| 73 |
const readerPath = document.querySelector("#reader-path");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
document.querySelector(".page-controls").hidden = !["pdf", "epub"].includes(capability.mode);
|
| 75 |
|
| 76 |
document.querySelector("#title").textContent = title;
|
|
@@ -193,7 +205,7 @@ function loadScript(url) {
|
|
| 193 |
document.head.appendChild(script);
|
| 194 |
});
|
| 195 |
}
|
| 196 |
-
function fail(message) { loadingStatus.hidden = true; content.innerHTML = `<div class="reader-error">${message}</div>`; status.textContent = "无法打开"; }
|
| 197 |
|
| 198 |
async function renderPdf(prepared) {
|
| 199 |
const pdf = await prepared;
|
|
@@ -327,10 +339,12 @@ async function renderHtml(prepared) {
|
|
| 327 |
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
| 328 |
const bytes = new Uint8Array(await response.arrayBuffer());
|
| 329 |
const text = new TextDecoder(detectTextEncoding(bytes, title)).decode(bytes);
|
|
|
|
| 330 |
await prepared.engine;
|
| 331 |
-
const clean = DOMPurify.sanitize(
|
| 332 |
USE_PROFILES: { html: true },
|
| 333 |
-
|
|
|
|
| 334 |
FORBID_ATTR: ["action", "formaction", "srcdoc"],
|
| 335 |
ALLOWED_URI_REGEXP: /^data:image\/(?:gif|png|jpeg|webp);/i,
|
| 336 |
});
|
|
@@ -462,6 +476,7 @@ async function start() {
|
|
| 462 |
else if (capability.mode === "html") await renderHtml(prepared);
|
| 463 |
else if (capability.mode === "epub") await renderEpub(prepared);
|
| 464 |
else if (capability.mode === "docx") await renderDocx(prepared);
|
|
|
|
| 465 |
loadingStatus.hidden = true;
|
| 466 |
if (!pageCount && restoredEntry) viewport.scrollTop = restoredEntry.scrollTop || 0;
|
| 467 |
scheduleSave();
|
|
|
|
| 35 |
const extension = (params.get("ext") || "").toLowerCase();
|
| 36 |
const capability = VoiceOfMLReader.capability(extension);
|
| 37 |
const content = document.querySelector("#content");
|
| 38 |
+
const loadingIndicator = document.createElement("div");
|
| 39 |
+
loadingIndicator.className = "reader-loading-indicator";
|
| 40 |
+
loadingIndicator.setAttribute("role", "status");
|
| 41 |
+
loadingIndicator.innerHTML = '<span class="reader-loading-spinner" aria-hidden="true"></span><span>正在加载正文...</span>';
|
| 42 |
+
content.appendChild(loadingIndicator);
|
| 43 |
content.dataset.mode = capability.mode || "unsupported";
|
| 44 |
const status = document.querySelector("#status");
|
| 45 |
const loadingStatus = document.querySelector("#loading-status");
|
|
|
|
| 76 |
const zoomInput = document.querySelector("#zoom");
|
| 77 |
const pageInput = document.querySelector("#page-number");
|
| 78 |
const readerPath = document.querySelector("#reader-path");
|
| 79 |
+
const loadingObserver = new MutationObserver(() => {
|
| 80 |
+
if (content.querySelector(".reader-page, .reader-image, .reader-text, .reader-markdown, .html-frame, .epub-frame, .docx-body")) {
|
| 81 |
+
loadingIndicator.remove();
|
| 82 |
+
loadingObserver.disconnect();
|
| 83 |
+
}
|
| 84 |
+
});
|
| 85 |
+
loadingObserver.observe(content, { childList: true });
|
| 86 |
document.querySelector(".page-controls").hidden = !["pdf", "epub"].includes(capability.mode);
|
| 87 |
|
| 88 |
document.querySelector("#title").textContent = title;
|
|
|
|
| 205 |
document.head.appendChild(script);
|
| 206 |
});
|
| 207 |
}
|
| 208 |
+
function fail(message) { loadingStatus.hidden = true; loadingIndicator.remove(); content.innerHTML = `<div class="reader-error">${message}</div>`; status.textContent = "无法打开"; }
|
| 209 |
|
| 210 |
async function renderPdf(prepared) {
|
| 211 |
const pdf = await prepared;
|
|
|
|
| 339 |
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
| 340 |
const bytes = new Uint8Array(await response.arrayBuffer());
|
| 341 |
const text = new TextDecoder(detectTextEncoding(bytes, title)).decode(bytes);
|
| 342 |
+
const offlineText = text.replace(/@import[^;]+;|url\s*\([^)]*\)/gi, "");
|
| 343 |
await prepared.engine;
|
| 344 |
+
const clean = DOMPurify.sanitize(offlineText, {
|
| 345 |
USE_PROFILES: { html: true },
|
| 346 |
+
ADD_TAGS: ["style"],
|
| 347 |
+
FORBID_TAGS: ["base", "embed", "form", "iframe", "object", "script"],
|
| 348 |
FORBID_ATTR: ["action", "formaction", "srcdoc"],
|
| 349 |
ALLOWED_URI_REGEXP: /^data:image\/(?:gif|png|jpeg|webp);/i,
|
| 350 |
});
|
|
|
|
| 476 |
else if (capability.mode === "html") await renderHtml(prepared);
|
| 477 |
else if (capability.mode === "epub") await renderEpub(prepared);
|
| 478 |
else if (capability.mode === "docx") await renderDocx(prepared);
|
| 479 |
+
loadingIndicator.remove();
|
| 480 |
loadingStatus.hidden = true;
|
| 481 |
if (!pageCount && restoredEntry) viewport.scrollTop = restoredEntry.scrollTop || 0;
|
| 482 |
scheduleSave();
|