vomebook commited on
Commit
dffc0ca
·
verified ·
1 Parent(s): 8644ef8

Support sanitized HTML Reader assets and local resources

Browse files
Files changed (4) hide show
  1. app.py +3 -3
  2. static/app.js +1 -1
  3. static/reader.css +6 -0
  4. 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
- : ["md", "markdown"].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);
 
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(text, {
332
  USE_PROFILES: { html: true },
333
- FORBID_TAGS: ["base", "embed", "form", "iframe", "object", "script", "style"],
 
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();