vomebook commited on
Commit
2af4df0
·
verified ·
1 Parent(s): 9d29ac8

Restore CCRD deployment files

Browse files
Files changed (7) hide show
  1. Dockerfile +14 -26
  2. app.py +0 -0
  3. requirements.txt +0 -2
  4. static/app.js +0 -0
  5. static/reader-contract.js +0 -52
  6. static/reader.css +0 -48
  7. static/reader.js +0 -488
Dockerfile CHANGED
@@ -5,45 +5,33 @@ ARG ESBUILD_VERSION=0.28.2
5
  WORKDIR /build
6
 
7
  RUN npm install --global "esbuild@${ESBUILD_VERSION}" \
8
- && test "$(esbuild --version)" = "${ESBUILD_VERSION}"
9
 
10
  COPY static/ /build/static/
11
- COPY scripts/build_static_assets.mjs /build/build_static_assets.mjs
12
- COPY scripts/copy_reader_vendor.mjs /build/copy_reader_vendor.mjs
13
-
14
- RUN mkdir -p /out \
15
- && cp -a static/. /out/ \
16
- && node /build/copy_reader_vendor.mjs /out/vendor \
17
- && esbuild static/app.js --minify --target=es2019 --charset=utf8 --legal-comments=none --outfile=/out/app.js \
18
- && esbuild static/reader-contract.js --minify --target=es2019 --charset=utf8 --legal-comments=none --outfile=/out/reader-contract.js \
19
- && esbuild static/reader-store.js --minify --target=es2019 --charset=utf8 --legal-comments=none --outfile=/out/reader-store.js \
20
- && esbuild static/reader.js --minify --target=es2019 --charset=utf8 --legal-comments=none --outfile=/out/reader.js \
21
- && esbuild static/style.css --minify --outfile=/out/style.css \
22
- && esbuild static/reader.css --minify --outfile=/out/reader.css \
23
- && node /build/build_static_assets.mjs \
24
- && esbuild /out/sw.js --minify --target=es2019 --charset=utf8 --legal-comments=none --outfile=/out/sw.min.js \
25
- && mv /out/sw.min.js /out/sw.js
26
 
27
  FROM python:3.11-slim
28
 
29
  WORKDIR /app
30
 
31
- RUN apt-get update && apt-get install -y --no-install-recommends \
32
- gzip \
33
- && rm -rf /var/lib/apt/lists/*
34
-
35
  COPY requirements.txt .
 
36
  RUN pip install --no-cache-dir -r requirements.txt
37
 
38
- COPY app.py .
 
39
  COPY --from=static-assets /out/ static/
 
40
  COPY data/ data/
41
- COPY txt/ txt/
42
 
43
- RUN if [ -f data/search_data.json ]; then \
44
- gzip -k -9 data/search_data.json; \
45
- fi
46
 
47
  EXPOSE 7860
48
 
49
- CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--log-level", "info"]
 
5
  WORKDIR /build
6
 
7
  RUN npm install --global "esbuild@${ESBUILD_VERSION}" \
8
+ && test "$(esbuild --version)" = "${ESBUILD_VERSION}"
9
 
10
  COPY static/ /build/static/
11
+
12
+ RUN cp -a static /out \
13
+ && esbuild static/app.js --minify --target=es2019 --charset=utf8 --legal-comments=none --outfile=/out/app.js \
14
+ && esbuild static/style.css --minify --outfile=/out/style.css \
15
+ && esbuild static/sw.js --minify --target=es2019 --charset=utf8 --legal-comments=none --outfile=/out/sw.js
 
 
 
 
 
 
 
 
 
 
16
 
17
  FROM python:3.11-slim
18
 
19
  WORKDIR /app
20
 
 
 
 
 
21
  COPY requirements.txt .
22
+
23
  RUN pip install --no-cache-dir -r requirements.txt
24
 
25
+ COPY app.py build_fulltext_db.py prepare_fulltext_index.py start.sh ./
26
+
27
  COPY --from=static-assets /out/ static/
28
+
29
  COPY data/ data/
 
30
 
31
+ COPY CCRD/ CCRD/
32
+
33
+ COPY CW/ CW/
34
 
35
  EXPOSE 7860
36
 
37
+ CMD ["sh", "/app/start.sh"]
app.py CHANGED
The diff for this file is too large to render. See raw diff
 
requirements.txt CHANGED
@@ -1,4 +1,2 @@
1
  fastapi==0.141.1
2
  uvicorn==0.52.4
3
- aiohttp==3.14.3
4
- jieba==0.42.1
 
1
  fastapi==0.141.1
2
  uvicorn==0.52.4
 
 
static/app.js CHANGED
The diff for this file is too large to render. See raw diff
 
static/reader-contract.js DELETED
@@ -1,52 +0,0 @@
1
- (function (root) {
2
- "use strict";
3
-
4
- const ReaderMode = Object.freeze({
5
- UNSUPPORTED: 0,
6
- ORIGINAL: 1,
7
- CONVERTED: 2,
8
- PENDING: 3,
9
- FAILED: 4,
10
- });
11
- const modes = Object.freeze({
12
- pdf: "pdf", epub: "epub", docx: "docx", html: "html", htm: "html", txt: "text", md: "markdown", markdown: "markdown",
13
- jpg: "image", jpeg: "image", png: "image", gif: "image", bmp: "image", webp: "image",
14
- });
15
- const articleExtensions = Object.freeze(Object.keys(modes));
16
-
17
- function capability(extension) {
18
- const normalized = String(extension || "").toLowerCase();
19
- return Object.freeze({
20
- extension: normalized,
21
- mode: modes[normalized] || null,
22
- readerMode: modes[normalized] ? ReaderMode.ORIGINAL : ReaderMode.UNSUPPORTED,
23
- article: !!modes[normalized],
24
- });
25
- }
26
-
27
- function clampNumber(value, minimum, maximum, fallback) {
28
- const numeric = Math.round(Number(value));
29
- return Number.isFinite(numeric) ? Math.min(maximum, Math.max(minimum, numeric)) : fallback;
30
- }
31
-
32
- function readerUrl(record, basePath) {
33
- const source = record && (record.ReaderLink || record.readerLink || record.Link || record.link);
34
- const readerExtension = record && (record.ReaderExtension || record.readerExtension || record.Extension || record.extension);
35
- if (!source || capability(readerExtension).readerMode === ReaderMode.UNSUPPORTED) return "";
36
- const params = new URLSearchParams({
37
- url: source,
38
- title: (record.File || record.name || "") + ((record.Extension || record.extension) ? "." + (record.Extension || record.extension) : ""),
39
- ext: readerExtension || "",
40
- });
41
- if (record.DownloadLink || record.downloadLink) params.set("download", record.DownloadLink || record.downloadLink);
42
- if (record.OcrUrl || record.ocrUrl) params.set("ocr", record.OcrUrl || record.ocrUrl);
43
- if (record.ReturnUrl || record.returnUrl) params.set("return", record.ReturnUrl || record.returnUrl);
44
- const repo = String(record.Repo || record.repo || "").split("/").pop();
45
- const folder = Array.isArray(record.Folder || record.folder) ? (record.Folder || record.folder).join("/") : "";
46
- if (repo) params.set("path", repo + (folder ? "/" + folder : ""));
47
- if (record.FolderUrl || record.folderUrl) params.set("folder_url", record.FolderUrl || record.folderUrl);
48
- return (basePath || "/reader.html") + "?" + params.toString();
49
- }
50
-
51
- root.VoiceOfMLReader = Object.freeze({ ReaderMode, articleExtensions, capability, clampNumber, readerUrl });
52
- })(typeof self !== "undefined" ? self : window);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/reader.css DELETED
@@ -1,48 +0,0 @@
1
- :root { color-scheme: dark; font-family: system-ui, sans-serif; background: #111315; color: #eceff1; }
2
- * { box-sizing: border-box; letter-spacing: 0; }
3
- body { margin: 0; height: 100vh; height: 100dvh; overflow: hidden; display: flex; flex-direction: column; }
4
- .reader-toolbar { min-height: 44px; flex: none; display: flex; align-items: center; gap: 7px; padding: 3px 10px; background: #1b1e21; border-bottom: 1px solid #34383d; }
5
- .reader-heading { min-width: 0; flex: 1; display: flex; flex-direction: column; }
6
- .reader-heading strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; line-height: 18px; }
7
- .reader-heading span, .reader-path { color: #9ba3aa; font-size: 12px; line-height: 16px; }
8
- .reader-path { min-width: 0; max-width: 100%; padding: 0; border: 0; background: transparent; font-family: inherit; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
9
- .reader-path:hover, .reader-path:focus-visible { color: #d5d9dc; text-decoration: underline; outline: none; }
10
- .reader-actions { display: flex; align-items: center; gap: 4px; }
11
- .control-group { display: inline-flex; align-items: center; gap: 2px; }
12
- .control-group[hidden] { display: none; }
13
- .compact-input { height: 32px; display: inline-flex; align-items: center; border: 1px solid #3b4147; background: #25292d; border-radius: 4px; padding-right: 4px; color: #c6ccd1; font-size: 12px; }
14
- .compact-input input { width: 34px; height: 28px; border: 0; background: transparent; color: inherit; text-align: right; font: inherit; outline: none; }
15
- .icon-button { width: 32px; height: 32px; border: 0; background: transparent; color: inherit; display: inline-grid; place-items: center; text-decoration: none; cursor: pointer; border-radius: 4px; font-size: 18px; }
16
- .text-button { height: 32px; padding: 0 8px; border: 0; background: transparent; color: inherit; display: inline-flex; align-items: center; text-decoration: none; border-radius: 4px; font-size: 12px; }
17
- .text-button[hidden] { display: none; }
18
- .icon-button:hover, .text-button:hover { background: #292d31; }
19
- .history-panel { position: fixed; top: 44px; right: 0; bottom: 0; width: min(380px, 92vw); z-index: 20; background: #1b1e21; border-left: 1px solid #34383d; box-shadow: -5px 0 20px #0008; overflow: auto; }
20
- .history-panel > header { height: 52px; display: flex; align-items: center; justify-content: space-between; padding: 0 12px; border-bottom: 1px solid #34383d; }
21
- .history-item { display: grid; grid-template-columns: 1fr auto; gap: 4px 10px; padding: 12px; border-bottom: 1px solid #2d3237; }
22
- .history-item a { color: #e5e9ec; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
23
- .history-item small { color: #929aa1; }
24
- .history-item button { grid-row: 1 / 3; grid-column: 2; border: 0; background: transparent; color: #b5bbc0; cursor: pointer; }
25
- .reader-viewport { min-height: 0; flex: 1; overflow: auto; background: #121416; }
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-page { position: relative; margin: 0 auto 18px; background: white; box-shadow: 0 2px 14px #0008; min-height: 160px; }
33
- .reader-content[data-mode="pdf"] .reader-page { width: calc(100% * var(--reader-zoom)); }
34
- .reader-page canvas { display: block; width: 100%; height: auto; }
35
- .reader-page canvas { position: relative; opacity: 0; transition: opacity 120ms ease; }
36
- .reader-page canvas.ready { opacity: 1; }
37
- .reader-image { display: block; width: calc(100% * var(--reader-zoom)); max-width: none; height: auto; margin: 0 auto; background: #fff; }
38
- .reader-text { margin: 0 auto; max-width: 76ch; white-space: pre-wrap; overflow-wrap: anywhere; font: calc(17px * var(--reader-zoom))/1.8 ui-monospace, monospace; color: #e7e9eb; }
39
- .reader-markdown { margin: 0 auto; max-width: 76ch; font-size: calc(17px * var(--reader-zoom)); line-height: 1.75; }
40
- .reader-markdown img { max-width: 100%; }
41
- .reader-markdown a { color: #72a7df; }
42
- .reader-error { margin: 20vh auto; max-width: 560px; color: #d7dadd; text-align: center; line-height: 1.6; }
43
- .epub-frame { width: 100%; height: calc(100vh - 96px); background: white; }
44
- .html-frame { display: block; width: 100%; height: calc(100vh - 96px); border: 0; background: white; }
45
- .docx-body { overflow-x: auto; color: #111; }
46
- .docx-body .reader-docx-wrapper { background: transparent; padding: 0; zoom: var(--reader-zoom); }
47
- .docx-body .reader-docx-wrapper > section.reader-docx { margin: 0 auto 18px; box-shadow: 0 2px 14px #0008; }
48
- @media (max-width: 600px) { .reader-toolbar { min-height: 36px; gap: 1px; padding: 2px 5px; } .reader-content { padding: 10px; } .reader-heading { display: none; } .reader-actions { flex: 1; justify-content: flex-end; gap: 1px; min-width: 0; } .reader-actions .control-group { gap: 0; } .reader-actions .icon-button { width: 26px; } .reader-actions .text-button { height: 30px; padding: 0 3px; } .loading-status { display: block; padding: 10px 6px 0; } .icon-button { width: 26px; height: 30px; flex: none; } .compact-input { height: 30px; padding-right: 3px; font-size: 11px; } .compact-input input { width: 24px; height: 28px; } .zoom-controls .compact-input input { width: 32px; } .history-panel { top: 36px; } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/reader.js DELETED
@@ -1,488 +0,0 @@
1
- const PDFJS_URL = "/static/vendor/pdf.min.e0be3863c23c.mjs";
2
- const PDFJS_WORKER_URL = "/static/pdf-worker-wrapper.mjs";
3
- const PDFJS_WASM_URL = "/static/vendor/wasm/";
4
- const PDFJS_CMAP_URL = "/static/vendor/cmaps/";
5
- const PDFJS_STANDARD_FONT_URL = "/static/vendor/standard_fonts/";
6
- const EPUB_URL = "/static/vendor/epub.min.06eae1574510.js";
7
- const MARKED_URL = "/static/vendor/marked.min.eaccee2fb9fb.js";
8
- const PURIFY_URL = "/static/vendor/purify.min.c2f26ea4fc0d.js";
9
- const JSZIP_URL = "/static/vendor/jszip.min.acc7e41455a8.js";
10
- const DOCX_PREVIEW_URL = "/static/vendor/docx-preview.min.3573b8d99344.js";
11
- if (!Map.prototype.getOrInsertComputed) {
12
- Map.prototype.getOrInsertComputed = function(key, callback) {
13
- if (this.has(key)) return this.get(key);
14
- const value = callback(key);
15
- this.set(key, value);
16
- return value;
17
- };
18
- }
19
- if (!Math.sumPrecise) {
20
- Math.sumPrecise = function(values) {
21
- let sum = 0;
22
- let correction = 0;
23
- for (const value of values) {
24
- const next = sum + value;
25
- correction += Math.abs(sum) >= Math.abs(value) ? (sum - next) + value : (value - next) + sum;
26
- sum = next;
27
- }
28
- return sum + correction;
29
- };
30
- }
31
- const params = new URLSearchParams(location.search);
32
- const sourceUrl = params.get("url") || "";
33
- const contentUrl = `/api/reader-content?url=${encodeURIComponent(sourceUrl)}`;
34
- 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
- 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");
46
- const title = params.get("title") || "在线阅读";
47
- const ocrUrl = params.get("ocr") || "";
48
- const returnUrl = params.get("return") || "";
49
- const readerPathLabel = params.get("path") || "";
50
- const folderReturnUrl = params.get("folder_url") || "";
51
- const returnNavigationToken = params.get("nav") || "";
52
- const returnHistoryKey = returnNavigationToken ? `reader-return:${returnNavigationToken}` : "";
53
- let canReturnWithHistory = false;
54
- try {
55
- const target = new URL(returnUrl, location.origin);
56
- const storedReturnUrl = returnHistoryKey ? sessionStorage.getItem(returnHistoryKey) : "";
57
- const referrer = document.referrer ? new URL(document.referrer) : null;
58
- canReturnWithHistory = storedReturnUrl === target.href || !!(referrer && referrer.origin === target.origin && referrer.pathname === target.pathname && referrer.search === target.search);
59
- } catch (_) {}
60
- let zoom = 1;
61
- let currentPage = 1;
62
- let pageCount = 0;
63
- let restoredEntry = null;
64
- let saveTimer = 0;
65
- let pdfDocument = null;
66
- let pdfRenderGeneration = 0;
67
- let pdfActiveRenders = 0;
68
- let pdfShellsReady = Promise.resolve();
69
- let restorationApplied = false;
70
- const pdfRenderWaiters = [];
71
- let epubRendition = null;
72
- let epubLocation = "";
73
- let lastSavedProgress = "";
74
- let progressSaveChain = Promise.resolve();
75
- const viewport = document.querySelector("#viewport");
76
- const zoomInput = document.querySelector("#zoom");
77
- const pageInput = document.querySelector("#page-number");
78
- const readerPath = document.querySelector("#reader-path");
79
- document.querySelector(".page-controls").hidden = !["pdf", "epub"].includes(capability.mode);
80
-
81
- document.querySelector("#title").textContent = title;
82
- document.title = title + " - VoiceOfML Reader";
83
- try {
84
- const folderTarget = new URL(folderReturnUrl, location.origin);
85
- if (readerPathLabel && folderTarget.origin === location.origin && folderTarget.pathname.split("/").filter(Boolean).length === 1) {
86
- readerPath.textContent = readerPathLabel;
87
- readerPath.setAttribute("aria-label", `筛选文件夹:${readerPathLabel}`);
88
- readerPath.hidden = false;
89
- status.hidden = true;
90
- readerPath.addEventListener("click", () => {
91
- if (returnHistoryKey) { try { sessionStorage.removeItem(returnHistoryKey); } catch (_) {} }
92
- location.assign(folderTarget.href);
93
- });
94
- }
95
- } catch (_) {}
96
- document.querySelector("#back").addEventListener("click", () => {
97
- try {
98
- const target = new URL(returnUrl, location.origin);
99
- if (target.origin === location.origin) {
100
- if (canReturnWithHistory && history.length > 1) {
101
- if (returnHistoryKey) { try { sessionStorage.removeItem(returnHistoryKey); } catch (_) {} }
102
- history.back();
103
- }
104
- else location.assign(target.href);
105
- return;
106
- }
107
- } catch (_) {}
108
- location.assign("/");
109
- });
110
- function setZoom(percent, persist = true) {
111
- const normalized = VoiceOfMLReader.clampNumber(percent, 50, 250, 100);
112
- const horizontalCenter = viewport.scrollWidth ? (viewport.scrollLeft + viewport.clientWidth / 2) / viewport.scrollWidth : 0;
113
- zoom = normalized / 100;
114
- content.style.setProperty("--reader-zoom", String(zoom));
115
- zoomInput.value = String(normalized);
116
- if (epubRendition) epubRendition.themes.fontSize(`${normalized}%`);
117
- if (pdfDocument) rerenderVisiblePdfPages();
118
- viewport.scrollLeft = horizontalCenter * viewport.scrollWidth - viewport.clientWidth / 2;
119
- if (persist) scheduleSave();
120
- }
121
- for (const [id, delta] of [["#zoom-out", -10], ["#zoom-in", 10]]) {
122
- document.querySelector(id).addEventListener("click", () => {
123
- setZoom(Number(zoomInput.value) + delta);
124
- });
125
- }
126
- zoomInput.addEventListener("change", () => setZoom(zoomInput.value));
127
- zoomInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { setZoom(zoomInput.value); zoomInput.blur(); } });
128
- pageInput.addEventListener("change", () => goToPage(pageInput.value));
129
- pageInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { goToPage(pageInput.value); pageInput.blur(); } });
130
- document.querySelector("#page-prev").addEventListener("click", () => epubRendition ? epubRendition.prev() : goToPage(currentPage - 1));
131
- document.querySelector("#page-next").addEventListener("click", () => epubRendition ? epubRendition.next() : goToPage(currentPage + 1));
132
-
133
- async function goToPage(value) {
134
- if (!pageCount) return;
135
- const page = VoiceOfMLReader.clampNumber(value, 1, pageCount, 1);
136
- await pdfShellsReady;
137
- const shell = content.querySelector(`.reader-page[data-page="${page}"]`);
138
- if (shell) {
139
- await renderPdfShell(shell, false, true);
140
- shell.scrollIntoView({ block: "start" });
141
- if (!restorationApplied && restoredEntry && page === restoredEntry.page && restoredEntry.pageOffset) { viewport.scrollTop += restoredEntry.pageOffset; restorationApplied = true; }
142
- }
143
- currentPage = page; pageInput.value = String(page); scheduleSave();
144
- }
145
- function scheduleSave() { clearTimeout(saveTimer); saveTimer = setTimeout(saveProgress, 500); }
146
- async function saveProgress() {
147
- if (!validSource(sourceUrl)) return;
148
- const shell = pageCount ? content.querySelector(`.reader-page[data-page="${currentPage}"]`) : null;
149
- const pageOffset = shell ? Math.max(0, viewport.scrollTop - shell.offsetTop) : 0;
150
- const progress = { url: sourceUrl, title, extension, readerUrl: location.href, page: currentPage, pageCount, pageOffset, epubLocation, scrollTop: viewport.scrollTop, zoom: Math.round(zoom * 100) };
151
- const signature = JSON.stringify(progress);
152
- if (signature === lastSavedProgress) return progressSaveChain;
153
- lastSavedProgress = signature;
154
- progressSaveChain = progressSaveChain.catch(() => {}).then(() => VoiceOfMLReaderStore.put({ ...progress, lastReadAt: Date.now() })).catch((error) => {
155
- if (lastSavedProgress === signature) lastSavedProgress = "";
156
- console.warn("Reader progress was not saved", error);
157
- });
158
- return progressSaveChain;
159
- }
160
- async function renderHistory() {
161
- const list = document.querySelector("#history-list"); list.textContent = "";
162
- try {
163
- for (const entry of await VoiceOfMLReaderStore.list()) {
164
- const row = document.createElement("div"); row.className = "history-item";
165
- const link = document.createElement("a"); link.href = entry.readerUrl; link.textContent = entry.title || entry.url;
166
- const meta = document.createElement("small"); meta.textContent = `${entry.pageCount ? `第 ${entry.page || 1} / ${entry.pageCount} 页 · ` : ""}${new Date(entry.lastReadAt).toLocaleString()}`;
167
- const remove = document.createElement("button"); remove.type = "button"; remove.textContent = "删除";
168
- remove.addEventListener("click", async () => { await VoiceOfMLReaderStore.remove(entry.url); row.remove(); });
169
- row.append(link, meta, remove); list.appendChild(row);
170
- }
171
- if (!list.childElementCount) list.textContent = "暂无阅读记录";
172
- } catch (_) { list.textContent = "无法读取本地记录"; }
173
- }
174
- document.querySelector("#history").addEventListener("click", async () => { const panel = document.querySelector("#history-panel"); panel.hidden = !panel.hidden; if (!panel.hidden) await renderHistory(); });
175
- document.querySelector("#history-close").addEventListener("click", () => { document.querySelector("#history-panel").hidden = true; });
176
- viewport.addEventListener("scroll", () => {
177
- scheduleSave();
178
- }, { passive: true });
179
- window.addEventListener("pagehide", saveProgress);
180
- document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") saveProgress(); });
181
-
182
- function validSource(raw) {
183
- try {
184
- const url = new URL(raw);
185
- if (url.protocol !== "https:" || !["huggingface.co", "hf-mirror.com"].includes(url.hostname)) return false;
186
- const readerAsset = /^\/datasets\/vomebook\/Reader-Assets\/resolve\/[^/]+\/objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/(?:[a-z0-9-]+\/)?(document\.pdf|book\.epub|document\.docx|document\.html)$/.test(url.pathname);
187
- if (extension === "docx") return readerAsset;
188
- return /^\/datasets\/VoiceOfML\/[^/]+\/(resolve|raw)\//.test(url.pathname) || readerAsset;
189
- } catch (_) { return false; }
190
- }
191
- function validOcr(raw) {
192
- try { const url = new URL(raw, location.origin); return url.origin === location.origin && url.pathname.startsWith("/txt/"); }
193
- catch (_) { return false; }
194
- }
195
- function loadScript(url) {
196
- return new Promise((resolve, reject) => {
197
- const script = document.createElement("script"); script.src = url; script.onload = resolve; script.onerror = reject;
198
- document.head.appendChild(script);
199
- });
200
- }
201
- function fail(message) { loadingStatus.hidden = true; loadingIndicator.remove(); content.innerHTML = `<div class="reader-error">${message}</div>`; status.textContent = "无法打开"; }
202
-
203
- async function renderPdf(prepared) {
204
- const pdf = await prepared;
205
- pdfDocument = pdf;
206
- pageCount = pdf.numPages; pageInput.max = String(pageCount); document.querySelector("#page-total").textContent = `/ ${pageCount}`;
207
- status.textContent = `${pdf.numPages} 页`;
208
- const firstPage = await pdf.getPage(1);
209
- const firstViewport = firstPage.getViewport({ scale: 1 });
210
- const observer = new IntersectionObserver((entries) => entries.forEach((entry) => {
211
- entry.target.dataset.renderVisible = entry.isIntersecting ? "1" : "0";
212
- if (entry.isIntersecting) renderPdfShell(entry.target);
213
- }), { root: document.querySelector("#viewport"), rootMargin: "1200px 0px" });
214
- const pageObserver = new IntersectionObserver((entries) => {
215
- const visible = entries.filter((entry) => entry.isIntersecting).sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
216
- if (visible) { currentPage = Number(visible.target.dataset.page); pageInput.value = String(currentPage); scheduleSave(); }
217
- }, { root: document.querySelector("#viewport"), threshold: [0.2, 0.5, 0.8] });
218
- const createShell = (page) => {
219
- const shell = document.createElement("section"); shell.className = "reader-page"; shell.dataset.page = String(page);
220
- shell.style.aspectRatio = `${firstViewport.width} / ${firstViewport.height}`;
221
- const canvas = document.createElement("canvas"); canvas.setAttribute("aria-label", `第 ${page} 页`); shell.appendChild(canvas);
222
- observer.observe(shell); pageObserver.observe(shell); return shell;
223
- };
224
- const firstShell = createShell(1); content.appendChild(firstShell);
225
- await renderPdfShell(firstShell, false, true);
226
- pdfShellsReady = (async () => {
227
- for (let start = 2; start <= pdf.numPages; start += 24) {
228
- const fragment = document.createDocumentFragment();
229
- for (let page = start; page < Math.min(start + 24, pdf.numPages + 1); page++) fragment.appendChild(createShell(page));
230
- content.appendChild(fragment);
231
- await new Promise((resolve) => setTimeout(resolve, 0));
232
- }
233
- })();
234
- await pdfShellsReady;
235
- if (restoredEntry && restoredEntry.page) await goToPage(restoredEntry.page);
236
- }
237
-
238
- async function renderPdfShell(shell, force = false, priority = false) {
239
- if (!pdfDocument) return;
240
- if (shell.dataset.renderState === "rendering") { if (force) shell.dataset.pendingRerender = "1"; return shell._renderPromise; }
241
- if (!force && shell.dataset.renderState === "rendered") return;
242
- let finishRender;
243
- shell._renderPromise = new Promise((resolve) => { finishRender = resolve; });
244
- const generation = pdfRenderGeneration;
245
- shell.dataset.renderState = "rendering";
246
- await acquirePdfRenderSlot(priority);
247
- try {
248
- const page = await pdfDocument.getPage(Number(shell.dataset.page));
249
- const base = page.getViewport({ scale: 1 });
250
- const scale = Math.min(3, Math.max(0.5, shell.clientWidth / base.width));
251
- const rendered = page.getViewport({ scale });
252
- const canvas = shell.querySelector("canvas");
253
- canvas.width = rendered.width; canvas.height = rendered.height;
254
- shell.style.aspectRatio = `${rendered.width} / ${rendered.height}`;
255
- await page.render({ canvasContext: canvas.getContext("2d"), viewport: rendered }).promise;
256
- if (generation !== pdfRenderGeneration || shell.dataset.pendingRerender) {
257
- shell.dataset.renderState = "idle";
258
- delete shell.dataset.pendingRerender;
259
- setTimeout(() => renderPdfShell(shell, true, priority), 0);
260
- return;
261
- }
262
- canvas.classList.add("ready"); shell.dataset.renderState = "rendered";
263
- shell.dataset.renderUsedAt = String(Date.now());
264
- trimPdfCanvases();
265
- } catch (error) {
266
- shell.dataset.renderState = "idle";
267
- console.warn(`PDF page ${shell.dataset.page} render failed`, error);
268
- const retries = Number(shell.dataset.renderRetries || 0);
269
- if (priority) throw error;
270
- if (retries < 3) { shell.dataset.renderRetries = String(retries + 1); setTimeout(() => renderPdfShell(shell, true), 400 * (retries + 1)); }
271
- } finally { releasePdfRenderSlot(); finishRender(); delete shell._renderPromise; }
272
- }
273
-
274
- function acquirePdfRenderSlot(priority = false) {
275
- const limit = matchMedia("(max-width: 700px)").matches ? 1 : 2;
276
- if (pdfActiveRenders < limit) { pdfActiveRenders++; return Promise.resolve(); }
277
- return new Promise((resolve) => {
278
- const resume = () => { pdfActiveRenders++; resolve(); };
279
- if (priority) pdfRenderWaiters.unshift(resume); else pdfRenderWaiters.push(resume);
280
- });
281
- }
282
- function releasePdfRenderSlot() {
283
- pdfActiveRenders = Math.max(0, pdfActiveRenders - 1);
284
- const resume = pdfRenderWaiters.shift();
285
- if (resume) resume();
286
- }
287
- function trimPdfCanvases() {
288
- const limit = matchMedia("(max-width: 700px)").matches ? 7 : 11;
289
- const rendered = [...content.querySelectorAll('.reader-page[data-render-state="rendered"]')];
290
- if (rendered.length <= limit) return;
291
- rendered.sort((a, b) => {
292
- const aVisible = a.dataset.renderVisible === "1" || Number(a.dataset.page) === currentPage;
293
- const bVisible = b.dataset.renderVisible === "1" || Number(b.dataset.page) === currentPage;
294
- if (aVisible !== bVisible) return aVisible ? 1 : -1;
295
- const distance = Math.abs(Number(b.dataset.page) - currentPage) - Math.abs(Number(a.dataset.page) - currentPage);
296
- return distance || Number(a.dataset.renderUsedAt || 0) - Number(b.dataset.renderUsedAt || 0);
297
- });
298
- while (rendered.length > limit) {
299
- const shell = rendered.shift();
300
- if (shell.dataset.renderVisible === "1" || Number(shell.dataset.page) === currentPage) continue;
301
- const canvas = shell.querySelector("canvas");
302
- canvas.width = 0; canvas.height = 0; canvas.classList.remove("ready");
303
- shell.dataset.renderState = "idle";
304
- }
305
- }
306
-
307
- function rerenderVisiblePdfPages() {
308
- pdfRenderGeneration++;
309
- for (const shell of content.querySelectorAll(".reader-page")) {
310
- const rect = shell.getBoundingClientRect();
311
- if (rect.bottom >= -1200 && rect.top <= innerHeight + 1200) {
312
- renderPdfShell(shell, true);
313
- }
314
- }
315
- }
316
- async function renderText(markdown, prepared) {
317
- let response = await prepared.response;
318
- if (!response.ok) response = await fetch(sourceUrl);
319
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
320
- if (!markdown) await renderPlainText(response);
321
- else {
322
- const bytes = new Uint8Array(await response.arrayBuffer());
323
- const text = new TextDecoder(detectTextEncoding(bytes, title)).decode(bytes);
324
- await prepared.engines;
325
- const article = document.createElement("article"); article.className = "reader-markdown";
326
- article.innerHTML = DOMPurify.sanitize(marked.parse(text), { USE_PROFILES: { html: true } }); content.appendChild(article);
327
- }
328
- status.textContent = "已加载";
329
- }
330
- async function renderHtml(prepared) {
331
- const response = await prepared.response;
332
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
333
- const bytes = new Uint8Array(await response.arrayBuffer());
334
- const text = new TextDecoder(detectTextEncoding(bytes, title)).decode(bytes);
335
- const offlineText = text.replace(/@import[^;]+;|url\s*\([^)]*\)/gi, "");
336
- await prepared.engine;
337
- const clean = DOMPurify.sanitize(offlineText, {
338
- USE_PROFILES: { html: true },
339
- ADD_TAGS: ["style"],
340
- FORBID_TAGS: ["base", "embed", "form", "iframe", "object", "script"],
341
- FORBID_ATTR: ["action", "formaction", "srcdoc"],
342
- ALLOWED_URI_REGEXP: /^data:image\/(?:gif|png|jpeg|webp);/i,
343
- });
344
- const frame = document.createElement("iframe");
345
- frame.className = "html-frame";
346
- frame.setAttribute("sandbox", "allow-same-origin");
347
- frame.setAttribute("referrerpolicy", "no-referrer");
348
- frame.srcdoc = clean;
349
- content.appendChild(frame);
350
- status.textContent = "HTML";
351
- }
352
- async function renderPlainText(response) {
353
- const pre = document.createElement("pre"); pre.className = "reader-text";
354
- const textNode = document.createTextNode(""); pre.appendChild(textNode); content.appendChild(pre);
355
- if (!response.body || !response.body.getReader) {
356
- const bytes = new Uint8Array(await response.arrayBuffer());
357
- textNode.data = new TextDecoder(detectTextEncoding(bytes, title)).decode(bytes);
358
- return;
359
- }
360
- const reader = response.body.getReader(), chunks = [];
361
- let sampleSize = 0, displayedSampleSize = 0, streamDone = false;
362
- while (!streamDone && sampleSize < 65536) {
363
- const { value, done } = await reader.read();
364
- streamDone = done;
365
- if (value && value.length) {
366
- chunks.push(value); sampleSize += value.length;
367
- if (displayedSampleSize === sampleSize - value.length) {
368
- let asciiLength = 0; while (asciiLength < value.length && value[asciiLength] < 128) asciiLength++;
369
- if (asciiLength) { textNode.appendData(new TextDecoder("utf-8").decode(value.subarray(0, asciiLength))); displayedSampleSize += asciiLength; }
370
- }
371
- }
372
- }
373
- const sample = new Uint8Array(sampleSize);
374
- let sampleOffset = 0;
375
- for (const chunk of chunks) { sample.set(chunk, sampleOffset); sampleOffset += chunk.length; }
376
- const decoder = new TextDecoder(detectTextEncoding(sample, title));
377
- let pending = "", frame = 0;
378
- const flush = () => { frame = 0; if (pending) { textNode.appendData(pending); pending = ""; } };
379
- const scheduleFlush = () => { if (!frame) frame = requestAnimationFrame(flush); };
380
- pending = decoder.decode(sample.subarray(displayedSampleSize), { stream: !streamDone });
381
- scheduleFlush();
382
- while (!streamDone) {
383
- const { value, done } = await reader.read();
384
- if (done) { streamDone = true; break; }
385
- pending += decoder.decode(value, { stream: true });
386
- scheduleFlush();
387
- }
388
- pending += decoder.decode();
389
- if (frame) cancelAnimationFrame(frame);
390
- flush();
391
- }
392
- function detectTextEncoding(bytes, hint = "") {
393
- if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) return "utf-8";
394
- if (bytes[0] === 0xff && bytes[1] === 0xfe) return "utf-16le";
395
- if (bytes[0] === 0xfe && bytes[1] === 0xff) return "utf-16be";
396
- const evenNulls = bytes.filter((value, index) => !value && index % 2 === 0).length;
397
- const oddNulls = bytes.filter((value, index) => !value && index % 2 === 1).length;
398
- if (oddNulls > bytes.length / 8 && oddNulls > evenNulls * 4) return "utf-16le";
399
- if (evenNulls > bytes.length / 8 && evenNulls > oddNulls * 4) return "utf-16be";
400
- try { new TextDecoder("utf-8", { fatal: true }).decode(bytes); return "utf-8"; } catch (_) {}
401
- if (/[\u0400-\u04ff]/.test(hint)) return "windows-1251";
402
- const candidates = ["gb18030", "big5", "windows-1251", "windows-1252"];
403
- let best = "gb18030", bestScore = -Infinity;
404
- for (const encoding of candidates) {
405
- try {
406
- const text = new TextDecoder(encoding, { fatal: true }).decode(bytes);
407
- const controls = (text.match(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g) || []).length;
408
- const cjk = (text.match(/[\u3400-\u9fff]/g) || []).length;
409
- const cyrillic = (text.match(/[\u0400-\u04ff]/g) || []).length;
410
- const commonCjk = (text.match(/[的一是在不了有和人这中大为上个国我以要他时来用们生到作地于出就分对成会可主发年动同工也能下过子说产种面而方后多定行学法所民得经之进着等部家自理起现实都体制当本性应开合因由然前外政社义事相全与关各重新内正反明原利质向道命此变结解问意建公系军情者立代通题党程展料员革文总品活长求老基资级图统知组别期论运农指区战任处理世]/g) || []).length;
411
- const commonCyrillic = (text.toLowerCase().match(/[оеаинтсрвлкмдпуяызьгчбйхжюшцщэфъ]/g) || []).length;
412
- const score = Math.max(cjk + commonCjk * 5, cyrillic + commonCyrillic) - controls * 20;
413
- if (score > bestScore) { best = encoding; bestScore = score; }
414
- } catch (_) {}
415
- }
416
- return best;
417
- }
418
- async function renderEpub(prepared) {
419
- const [response] = await prepared;
420
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
421
- const frame = document.createElement("div"); frame.className = "epub-frame"; content.appendChild(frame);
422
- await displayEpub(await response.arrayBuffer(), frame);
423
- status.textContent = "EPUB";
424
- }
425
- async function displayEpub(url, frame) {
426
- const book = ePub(url); epubRendition = book.renderTo(frame, { width: "100%", height: "100%", spread: "none", flow: "scrolled-doc" });
427
- epubRendition.themes.fontSize(`${Math.round(zoom * 100)}%`);
428
- epubRendition.on("relocated", (location) => { epubLocation = location && location.start ? location.start.cfi : ""; scheduleSave(); });
429
- const restoredLocation = restoredEntry && restoredEntry.epubLocation;
430
- try { await epubRendition.display(restoredLocation || undefined); }
431
- catch (error) { if (!restoredLocation) throw error; epubLocation = ""; await epubRendition.display(); }
432
- }
433
- async function renderDocx(prepared) {
434
- const [response] = await prepared;
435
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
436
- const bytes = await response.arrayBuffer();
437
- const styles = document.createElement("div"); styles.className = "docx-styles";
438
- const body = document.createElement("div"); body.className = "docx-body";
439
- content.append(styles, body);
440
- await docx.renderAsync(bytes, body, styles, {
441
- className: "reader-docx", inWrapper: true, breakPages: true,
442
- ignoreLastRenderedPageBreak: false, useBase64URL: true,
443
- renderHeaders: true, renderFooters: true, renderFootnotes: true, renderEndnotes: true,
444
- renderChanges: false, renderComments: false, renderAltChunks: false, debug: false,
445
- });
446
- if (!(body.textContent || "").trim() && !body.querySelector("img, table, svg, canvas")) throw new Error("DOCX rendered no supported content");
447
- for (const link of body.querySelectorAll("a[href]")) {
448
- const href = link.getAttribute("href") || "";
449
- if (!href.startsWith("#") && !/^https?:\/\//i.test(href)) link.removeAttribute("href");
450
- else if (!href.startsWith("#")) { link.target = "_blank"; link.rel = "noopener noreferrer"; }
451
- }
452
- status.textContent = "DOCX";
453
- }
454
- async function start() {
455
- if (!validSource(sourceUrl) || capability.readerMode === VoiceOfMLReader.ReaderMode.UNSUPPORTED) return fail("此文件暂不支持在线阅读,请下载原文件。");
456
- document.querySelector("#download").href = `/api/download?file=${encodeURIComponent(title)}&link=${encodeURIComponent(downloadUrl)}`;
457
- if (validOcr(ocrUrl)) { const ocr = document.querySelector("#ocr"); ocr.href = ocrUrl; ocr.hidden = false; }
458
- try {
459
- let prepared;
460
- [restoredEntry, prepared] = await Promise.all([
461
- VoiceOfMLReaderStore.get(sourceUrl).catch(() => null),
462
- prepareDocument(),
463
- ]);
464
- if (restoredEntry && restoredEntry.zoom) setZoom(restoredEntry.zoom, false);
465
- if (capability.mode === "pdf") await renderPdf(prepared);
466
- else if (capability.mode === "image") { content.appendChild(prepared); status.textContent = "图片"; }
467
- else if (capability.mode === "text") await renderText(false, prepared);
468
- else if (capability.mode === "markdown") await renderText(true, prepared);
469
- else if (capability.mode === "html") await renderHtml(prepared);
470
- else if (capability.mode === "epub") await renderEpub(prepared);
471
- else if (capability.mode === "docx") await renderDocx(prepared);
472
- loadingIndicator.remove();
473
- loadingStatus.hidden = true;
474
- if (!pageCount && restoredEntry) viewport.scrollTop = restoredEntry.scrollTop || 0;
475
- scheduleSave();
476
- } catch (error) { console.error(error); fail("原文件加载失败,请检查网络后重试,或下载原文件。"); }
477
- }
478
- function prepareDocument() {
479
- if (capability.mode === "pdf") return import(PDFJS_URL).then((pdfjs) => { pdfjs.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL; const options = (url) => ({ url, wasmUrl: PDFJS_WASM_URL, cMapUrl: PDFJS_CMAP_URL, cMapPacked: true, standardFontDataUrl: PDFJS_STANDARD_FONT_URL, withCredentials: false }); return pdfjs.getDocument(options(contentUrl)).promise.catch(() => pdfjs.getDocument(options(sourceUrl)).promise); });
480
- if (capability.mode === "markdown") return Promise.all([fetch(contentUrl), Promise.all([loadScript(MARKED_URL), loadScript(PURIFY_URL)])]).then(([response, engines]) => ({ response, engines }));
481
- if (capability.mode === "html") return Promise.all([fetch(contentUrl), loadScript(PURIFY_URL)]).then(([response, engine]) => ({ response, engine }));
482
- if (capability.mode === "text") return fetch(contentUrl).then((response) => ({ response }));
483
- if (capability.mode === "epub") return Promise.all([fetch(contentUrl), loadScript(JSZIP_URL).then(() => loadScript(EPUB_URL))]);
484
- if (capability.mode === "docx") return Promise.all([fetch(contentUrl), loadScript(JSZIP_URL).then(() => loadScript(DOCX_PREVIEW_URL))]);
485
- 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; });
486
- return Promise.resolve(null);
487
- }
488
- start();