vomebook commited on
Commit
ee47c3c
·
1 Parent(s): 323f21e

feat: pilot explicit preview and selection reading with Hitokoto

Browse files

Reuse the lazy independent CPU reader through a strict two-origin window/channel bridge. Preserve exact preview text and selection context, require explicit synthesis and active replacement, and keep search/filter/preview independent of the persistent player. No index/corpus/backend/model dependencies or SW cache-name changes.

README.md CHANGED
@@ -9,4 +9,6 @@ license: gpl-3.0
9
  short_description: 给CCRD和CW的TXT提供搜索与全文搜索功能
10
  ---
11
 
12
- VOMEBOOK Search is a txt-focused search Space for CCRD and CW bundles.
 
 
 
9
  short_description: 给CCRD和CW的TXT提供搜索与全文搜索功能
10
  ---
11
 
12
+ VOMEBOOK Search is a txt-focused search Space for CCRD and CW bundles.
13
+
14
+ CCRD 的选文/预览朗读试点见 [docs/TTS_PILOT.md](docs/TTS_PILOT.md)。
docs/TTS_PILOT.md ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CCRD 朗读试点
2
+
3
+ 在文章预览上方,可选“朗读选中文字”或“朗读当前预览”。首次点击后才载入
4
+ Hitokoto 的独立 CPU 播放器;文字通过严格限定来源的浏览器消息传入,不放进 URL。
5
+ **载入文字不等于提交合成**:在播放器确认正文、音色、合成语速和试验音高后,
6
+ 主动点击“合成并试听”,才分批提交到 Hitokoto API。
7
+
8
+ - 选文必须完全在当前预览正文里,含原文 Unicode 与有限相邻读音上下文。
9
+ - 最多两万字;超长请自行选一段,不自动截断、不自动发送整本书。
10
+ - 当前快照开始朗读后,更换搜索词、排序、过滤、打开另一篇或关闭预览不会替换它。
11
+ - 播放器内支持暂停、句段跳读、倍速、睡眠定时、试验音高;高亮是句段级。
12
+ - “收起”或 Escape 保留朗读,“停止并收起”停止。重新载入会要求明确确认替换。
13
+ - 桌面保留搜索可操作;手机播放器独立弹层,收起后恢复预览。
14
+ - 试点播放器不自动读取/保存独立试听页的本机正文书签,页面内可续读。
15
+ - 模型、音色、缓存与任务限制来自 Hitokoto;API 不可用不影响搜索、预览和下载。
16
+ - 旧 Service Worker 客户端可能需第二次刷新/重开,缓存名不变。
17
+ - 实际手机后台、锁屏、自动播放限制、人工听感仍需真机确认,不承诺无缝。
18
+
19
+ 生产搜索站:<https://vomebook-search.hf.space/>;语音服务:
20
+ <https://vomebook-hitokoto.hf.space/tts/>。这次只接入 CCRD,其他三个搜索站未改。
21
+
22
+ 开发/验收命令见 `TESTING.md` 的 CCRD / Hitokoto reader pilot tier。
static/app.js CHANGED
@@ -1174,6 +1174,7 @@ function restorePreviewFocus() {
1174
  }
1175
 
1176
  function clearPreviewDom() {
 
1177
  setPreviewFullscreen(false);
1178
  DOM.previewPanel.innerHTML = "";
1179
  DOM.previewPanel.hidden = true;
@@ -1197,6 +1198,7 @@ async function openPreview(docId, keyword, options = {}) {
1197
  syncUrl(history.state && history.state.preview === true);
1198
  }
1199
  if (previewController) previewController.abort();
 
1200
  pauseSnippetLoadingForPreview();
1201
  const controller = new AbortController();
1202
  previewController = controller;
@@ -1231,7 +1233,8 @@ async function openPreview(docId, keyword, options = {}) {
1231
  <button type="button" class="icon-btn-sm" data-action="close-preview" aria-label="关闭预览">×</button>
1232
  <button type="button" class="icon-btn-sm preview-theme-btn" data-action="toggle-preview-theme" aria-label="${STATE.isDark ? "切换到白天模式" : "切换到黑夜模式"}" title="${STATE.isDark ? "白天模式" : "黑夜模式"}">${themeIcon(STATE.isDark)}</button>
1233
  </div>
1234
- </div>
 
1235
  <pre class="preview-body"></pre>`;
1236
  const previewTitle = DOM.previewPanel.querySelector(".preview-title");
1237
  if (previewTitle) previewTitle.id = "preview-title";
@@ -1240,6 +1243,7 @@ async function openPreview(docId, keyword, options = {}) {
1240
  const previewBody = DOM.previewPanel.querySelector(".preview-body");
1241
  if (shouldHighlight) previewBody.innerHTML = text;
1242
  else previewBody.textContent = rawText;
 
1243
  DOM.previewPanel.style.display = STATE.isMobile ? "flex" : "block";
1244
  setPreviewFullscreen(STATE.previewFullscreen);
1245
  document.body.classList.add("preview-open");
@@ -2246,6 +2250,13 @@ function attachEvents() {
2246
  }
2247
  });
2248
  DOM.previewPanel.addEventListener("click", (event) => {
 
 
 
 
 
 
 
2249
  const closeBtn = event.target.closest("[data-action='close-preview']");
2250
  if (closeBtn) {
2251
  closePreview();
@@ -2257,7 +2268,9 @@ function attachEvents() {
2257
  }
2258
  if (event.target.closest("[data-action='toggle-preview-theme']")) toggleTheme();
2259
  });
 
2260
  document.addEventListener("keydown", (event) => {
 
2261
  if (STATE.previewDocId && !DOM.previewPanel.hidden && (previewIsModal() || DOM.previewPanel.contains(event.target))) {
2262
  if (event.key === "Escape") {
2263
  event.preventDefault();
 
1174
  }
1175
 
1176
  function clearPreviewDom() {
1177
+ window.CCRDSpeech?.previewClosed();
1178
  setPreviewFullscreen(false);
1179
  DOM.previewPanel.innerHTML = "";
1180
  DOM.previewPanel.hidden = true;
 
1198
  syncUrl(history.state && history.state.preview === true);
1199
  }
1200
  if (previewController) previewController.abort();
1201
+ window.CCRDSpeech?.previewClosed();
1202
  pauseSnippetLoadingForPreview();
1203
  const controller = new AbortController();
1204
  previewController = controller;
 
1233
  <button type="button" class="icon-btn-sm" data-action="close-preview" aria-label="关闭预览">×</button>
1234
  <button type="button" class="icon-btn-sm preview-theme-btn" data-action="toggle-preview-theme" aria-label="${STATE.isDark ? "切换到白天模式" : "切换到黑夜模式"}" title="${STATE.isDark ? "白天模式" : "黑夜模式"}">${themeIcon(STATE.isDark)}</button>
1235
  </div>
1236
+ </div>
1237
+ <div class="preview-speech-tools"><button type="button" class="text-btn" data-speech="selection">朗读选中文字</button><button type="button" class="text-btn" data-speech="preview">朗读当前预览</button><button type="button" class="text-btn" data-speech="controls">播放器</button></div>
1238
  <pre class="preview-body"></pre>`;
1239
  const previewTitle = DOM.previewPanel.querySelector(".preview-title");
1240
  if (previewTitle) previewTitle.id = "preview-title";
 
1243
  const previewBody = DOM.previewPanel.querySelector(".preview-body");
1244
  if (shouldHighlight) previewBody.innerHTML = text;
1245
  else previewBody.textContent = rawText;
1246
+ window.CCRDSpeech?.previewLoaded({ id: docId, title: data.title, text: rawText });
1247
  DOM.previewPanel.style.display = STATE.isMobile ? "flex" : "block";
1248
  setPreviewFullscreen(STATE.previewFullscreen);
1249
  document.body.classList.add("preview-open");
 
2250
  }
2251
  });
2252
  DOM.previewPanel.addEventListener("click", (event) => {
2253
+ const speech = event.target.closest("[data-speech]");
2254
+ if (speech) {
2255
+ if (speech.dataset.speech === "controls") document.querySelector("#speech-launch")?.click();
2256
+ else if (window.CCRDSpeech) window.CCRDSpeech.read(speech.dataset.speech);
2257
+ else showToast("朗读组件尚未更新,请刷新页面后重试");
2258
+ return;
2259
+ }
2260
  const closeBtn = event.target.closest("[data-action='close-preview']");
2261
  if (closeBtn) {
2262
  closePreview();
 
2268
  }
2269
  if (event.target.closest("[data-action='toggle-preview-theme']")) toggleTheme();
2270
  });
2271
+ if (!window.CCRDSpeech) $("#speech-launch")?.addEventListener("click", () => showToast("朗读组件加载失败,请刷新重试;搜索仍可使用"));
2272
  document.addEventListener("keydown", (event) => {
2273
+ if (window.CCRDSpeech?.modal() || window.CCRDSpeech?.owns(event.target)) return;
2274
  if (STATE.previewDocId && !DOM.previewPanel.hidden && (previewIsModal() || DOM.previewPanel.contains(event.target))) {
2275
  if (event.key === "Escape") {
2276
  event.preventDefault();
static/index.html CHANGED
@@ -51,6 +51,7 @@
51
  <label class="multi-toggle" id="multi-toggle-label" style="display:none"><input type="checkbox" id="multi-select-toggle"><span>多选</span></label>
52
  </div>
53
  <div class="status-right">
 
54
  <button id="clear-filters-btn" class="text-btn" style="display:none">清空筛选</button>
55
  <div class="sort-group">
56
  <span class="sort-label">排序:</span>
@@ -147,8 +148,20 @@
147
  </footer>
148
  <div id="overlay" class="overlay" style="display:none"></div>
149
  <div id="toast" class="toast" role="status" aria-live="polite" style="display:none"></div>
 
 
 
 
 
 
 
 
 
 
 
150
 
151
  <script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js", { scope: "/" }); }</script>
 
152
  <script src="/static/app.js"></script>
153
  </body>
154
  </html>
 
51
  <label class="multi-toggle" id="multi-toggle-label" style="display:none"><input type="checkbox" id="multi-select-toggle"><span>多选</span></label>
52
  </div>
53
  <div class="status-right">
54
+ <button type="button" id="speech-launch" class="text-btn" aria-controls="speech-dialog" aria-expanded="false">朗读</button>
55
  <button id="clear-filters-btn" class="text-btn" style="display:none">清空筛选</button>
56
  <div class="sort-group">
57
  <span class="sort-label">排序:</span>
 
148
  </footer>
149
  <div id="overlay" class="overlay" style="display:none"></div>
150
  <div id="toast" class="toast" role="status" aria-live="polite" style="display:none"></div>
151
+ <dialog id="speech-dialog" class="speech-dialog" aria-labelledby="speech-title">
152
+ <div class="speech-heading">
153
+ <strong id="speech-title">CPU 朗读(试点)</strong>
154
+ <button type="button" id="speech-collapse" class="text-btn">收起(保留朗读)</button>
155
+ <button type="button" id="speech-stop" class="text-btn">停止并收起</button>
156
+ </div>
157
+ <p id="speech-status" class="speech-note" role="status">打开后连接 Hitokoto 独立语音服务。</p>
158
+ <button type="button" id="speech-retry" class="text-btn" hidden>重新连接(停止原朗读)</button>
159
+ <div id="speech-frame-host"></div>
160
+ <p class="speech-note">关闭预览或切换搜索不打断朗读。正文通过浏览器交给播放器,点击合成才提交语音 API。手机后台效果待真机确认。</p>
161
+ </dialog>
162
 
163
  <script>if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js", { scope: "/" }); }</script>
164
+ <script src="/static/tts-client.js"></script>
165
  <script src="/static/app.js"></script>
166
  </body>
167
  </html>
static/style.css CHANGED
@@ -48,6 +48,16 @@
48
  color-scheme: dark;
49
  }
50
 
 
 
 
 
 
 
 
 
 
 
51
  body.light {
52
  --surface: #fdfbff;
53
  --surface-variant: #f0eff4;
 
48
  color-scheme: dark;
49
  }
50
 
51
+ .preview-speech-tools { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px 12px; flex-shrink: 0; border-bottom: 1px solid var(--reader-divider); }
52
+ .speech-dialog { position: fixed; inset: auto 12px 12px auto; margin: 0; width: min(560px, calc(100vw - 24px)); max-height: calc(100dvh - 24px); padding: 10px; background: var(--surface-container); color: var(--on-surface); border: 1px solid var(--outline); border-radius: 12px; box-shadow: var(--shadow-lg); z-index: 180; overflow: auto; user-select: text; }
53
+ .speech-dialog:not([open]) { display: none; }
54
+ .speech-dialog::backdrop { background: rgba(0, 0, 0, .45); }
55
+ .speech-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
56
+ .speech-heading strong { margin-right: auto; }
57
+ .speech-note { font-size: 12px; color: var(--on-surface-variant); margin: 8px 0; overflow-wrap: anywhere; }
58
+ #speech-frame-host iframe { display: block; width: 100%; height: min(62dvh, 660px); min-height: 220px; border: 0; background: var(--surface); }
59
+ body.mobile .speech-dialog { inset: 12px; width: auto; margin: auto; max-width: 650px; }
60
+
61
  body.light {
62
  --surface: #fdfbff;
63
  --surface-variant: #f0eff4;
static/tts-client.js ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use strict";
2
+ // CCRD pilot: shared Hitokoto player in a persistent, isolated browsing context.
3
+ // No model code, browser speechSynthesis, remote text URLs or automatic TTS POSTs.
4
+ window.CCRDSpeech = (() => {
5
+ const ORIGIN = "https://vomebook-hitokoto.hf.space";
6
+ const PLAYER = ORIGIN + "/tts/?embed=ccrd-v1";
7
+ const $ = (selector) => document.querySelector(selector);
8
+ const dialog = $("#speech-dialog");
9
+ const host = $("#speech-frame-host");
10
+ const status = $("#speech-status");
11
+ const launch = $("#speech-launch");
12
+ const collapse = $("#speech-collapse");
13
+ const stopButton = $("#speech-stop");
14
+ const retry = $("#speech-retry");
15
+ if (!dialog || !host || !launch) return null; // Older cached HTML stays usable.
16
+ let frame = null, channel = "", connected = false, timeout, modalActive = false;
17
+ let pending = null, currentPreview = null, selected = null, focusReturn = null;
18
+
19
+ function notice(message) {
20
+ status.textContent = message;
21
+ if (typeof showToast === "function") showToast(message, 4000);
22
+ }
23
+ function token() {
24
+ return Array.from(crypto.getRandomValues(new Uint8Array(24)), (value) => value.toString(16).padStart(2, "0")).join("");
25
+ }
26
+ function send(type, data = {}) {
27
+ frame?.contentWindow?.postMessage({ type, channel, ...data }, ORIGIN);
28
+ }
29
+ function theme() { if (connected) send("ccrd-reader-theme", { dark: !document.body.classList.contains("light") }); }
30
+ function modal() { return dialog.open && modalActive; }
31
+ function owns(node) { return !!node && dialog.contains(node); }
32
+ function outerControls() {
33
+ return Array.from(dialog.querySelectorAll("button, a[href]")).filter(node => !node.disabled && !node.hidden && node.getClientRects().length);
34
+ }
35
+ function show() {
36
+ if (!dialog.open) {
37
+ focusReturn = document.activeElement instanceof HTMLElement ? document.activeElement : null;
38
+ if (document.body.classList.contains("mobile")) dialog.showModal();
39
+ else dialog.show();
40
+ modalActive = document.body.classList.contains("mobile");
41
+ }
42
+ launch.setAttribute("aria-expanded", "true");
43
+ collapse.focus({ preventScroll: true });
44
+ if (!frame) createFrame();
45
+ else if (connected) send("ccrd-reader-layout", { modal: modal() });
46
+ }
47
+ function hide() {
48
+ if (dialog.open) dialog.close();
49
+ modalActive = false;
50
+ if (connected) send("ccrd-reader-layout", { modal: false });
51
+ launch.setAttribute("aria-expanded", "false");
52
+ const previous = focusReturn;
53
+ if (previous?.isConnected && !previous.closest("[inert]")) previous.focus({ preventScroll: true });
54
+ else if (!launch.closest("[inert]")) launch.focus({ preventScroll: true });
55
+ }
56
+ function createFrame() {
57
+ clearTimeout(timeout);
58
+ connected = false;
59
+ channel = token();
60
+ frame = document.createElement("iframe");
61
+ frame.id = "speech-frame";
62
+ frame.title = "Hitokoto CPU 朗读播放器";
63
+ frame.allow = "autoplay";
64
+ frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
65
+ frame.referrerPolicy = "no-referrer";
66
+ frame.addEventListener("load", () => {
67
+ connected = false;
68
+ send("ccrd-reader-init", { version: 1, modal: modal() });
69
+ });
70
+ frame.src = PLAYER; // Text/title/capabilities never go in the URL.
71
+ host.replaceChildren(frame);
72
+ status.textContent = "正在连接独立语音播放器,搜索和预览仍可使用…";
73
+ retry.hidden = true;
74
+ timeout = setTimeout(() => {
75
+ if (!connected) {
76
+ status.textContent = "播放器连接失败或仍在加载。可重连;不会影响搜索和原文预览。";
77
+ retry.hidden = false;
78
+ }
79
+ }, 20000);
80
+ }
81
+ function flush() {
82
+ if (!connected || !pending) return;
83
+ const payload = pending;
84
+ pending = null;
85
+ send("ccrd-reader-import", payload);
86
+ }
87
+ function previewLoaded(value) {
88
+ currentPreview = value;
89
+ selected = null;
90
+ }
91
+ function previewClosed() { currentPreview = null; selected = null; }
92
+ function selection() {
93
+ const body = $("#preview-panel .preview-body");
94
+ const value = window.getSelection();
95
+ if (!currentPreview || !body || !value || value.rangeCount !== 1) { selected = null; return; }
96
+ if (value.isCollapsed) {
97
+ if (!document.activeElement?.matches("[data-speech='selection']")) selected = null;
98
+ return;
99
+ }
100
+ const range = value.getRangeAt(0);
101
+ if (!body.contains(range.startContainer) || !body.contains(range.endContainer)) { selected = null; return; }
102
+ const prefix = range.cloneRange();
103
+ prefix.selectNodeContents(body);
104
+ prefix.setEnd(range.startContainer, range.startOffset);
105
+ const start = prefix.toString().length;
106
+ const text = range.toString();
107
+ if (currentPreview.text.slice(start, start + text.length) === text) selected = { id: currentPreview.id, start, text };
108
+ }
109
+ function read(mode) {
110
+ selection();
111
+ if (!currentPreview) { notice("预览仍在加载,请等待正文出现后重试"); return; }
112
+ let text = currentPreview.text, before = "", after = "";
113
+ if (mode === "selection") {
114
+ if (!selected || selected.id !== currentPreview.id || !selected.text.trim()) {
115
+ notice("请先在当前预览正文内选中要朗读的文字"); return;
116
+ }
117
+ text = selected.text;
118
+ before = Array.from(currentPreview.text.slice(Math.max(0, selected.start - 128), selected.start)).slice(-32).join("");
119
+ after = Array.from(currentPreview.text.slice(selected.start + text.length, selected.start + text.length + 128)).slice(0, 32).join("");
120
+ }
121
+ let count = 0;
122
+ for (const _ of text) { if (++count > 20000) break; }
123
+ if (count > 20000) { notice("单次朗读最多两万字,请选中较短的一部分;不会截掉正文"); return; }
124
+ if (!text.trim()) { notice("当前预览没有可朗读正文"); return; }
125
+ pending = { text, title: String(currentPreview.title || "当前预览").slice(0, 200),
126
+ context_before: before, context_after: after };
127
+ show();
128
+ flush();
129
+ }
130
+ document.addEventListener("selectionchange", selection);
131
+ window.addEventListener("message", (event) => {
132
+ const data = event.data;
133
+ if (event.origin !== ORIGIN || event.source !== frame?.contentWindow || !data || data.channel !== channel) return;
134
+ if (data.type === "ccrd-reader-ready" && data.version === 1) {
135
+ clearTimeout(timeout);
136
+ connected = true;
137
+ retry.hidden = true;
138
+ theme();
139
+ status.textContent = "播放器已连接。载入正文后,确认文字并点击合成才向语音 API 提交。";
140
+ flush();
141
+ } else if (data.type === "ccrd-reader-loaded") {
142
+ status.textContent = `已载入 ${data.characters} 字。请在播放器确认文字、音色和音高后点击合成。`;
143
+ } else if (data.type === "ccrd-reader-rejected") {
144
+ status.textContent = "未替换正在朗读的文字;原朗读保留。";
145
+ } else if (data.type === "ccrd-reader-pending") {
146
+ status.textContent = "播放器正在朗读,请在播放器中选择替换文字或保留原朗读。";
147
+ } else if (data.type === "ccrd-reader-stopped") {
148
+ status.textContent = "朗读已停止,可收起或重新载入文字。";
149
+ } else if (data.type === "ccrd-reader-collapse") {
150
+ hide();
151
+ } else if (data.type === "ccrd-reader-focus-out" && modal() && typeof data.reverse === "boolean") {
152
+ const buttons = outerControls();
153
+ (data.reverse ? buttons[buttons.length - 1] : buttons[0])?.focus();
154
+ }
155
+ });
156
+ launch.addEventListener("click", show);
157
+ collapse.addEventListener("click", hide);
158
+ stopButton.addEventListener("click", () => {
159
+ if (connected) send("ccrd-reader-stop");
160
+ else { clearTimeout(timeout); frame?.remove(); frame = null; connected = false; pending = null; }
161
+ hide();
162
+ });
163
+ retry.addEventListener("click", () => {
164
+ if (!confirm("重新连接会停止此播放器中尚未完成的朗读,是否继续?")) return;
165
+ frame?.remove();
166
+ frame = null;
167
+ createFrame();
168
+ });
169
+ dialog.addEventListener("cancel", (event) => { event.preventDefault(); hide(); });
170
+ dialog.addEventListener("keydown", (event) => {
171
+ if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); hide(); }
172
+ if (event.key === "Tab" && modal() && connected) {
173
+ const buttons = outerControls();
174
+ if (event.shiftKey && event.target === buttons[0]) {
175
+ event.preventDefault(); send("ccrd-reader-focus", { last: true });
176
+ } else if (!event.shiftKey && event.target === buttons[buttons.length - 1]) {
177
+ event.preventDefault(); send("ccrd-reader-focus", { last: false });
178
+ }
179
+ }
180
+ });
181
+ new MutationObserver(() => {
182
+ theme();
183
+ if (dialog.open) {
184
+ const targetModal = document.body.classList.contains("mobile");
185
+ if (modal() !== targetModal) { dialog.close(); targetModal ? dialog.showModal() : dialog.show(); modalActive = targetModal; }
186
+ if (connected) send("ccrd-reader-layout", { modal: modal() });
187
+ }
188
+ }).observe(document.body, { attributes: true, attributeFilter: ["class"] });
189
+ window.addEventListener("pagehide", () => { if (connected) send("ccrd-reader-stop"); });
190
+ return { read, previewLoaded, previewClosed, modal, owns };
191
+ })();
tests/test_search.py CHANGED
@@ -967,6 +967,7 @@ class FrontendContractTests(unittest.TestCase):
967
  def test_every_static_control_exists_is_cached_and_has_behavior(self):
968
  html = (Path(app.BASE_DIR) / "static" / "index.html").read_text(encoding="utf-8")
969
  source = (Path(app.BASE_DIR) / "static" / "app.js").read_text(encoding="utf-8")
 
970
  parser = InteractiveIdParser()
971
  parser.feed(html)
972
  expected = {
@@ -978,6 +979,7 @@ class FrontendContractTests(unittest.TestCase):
978
  "folder-toggle-all",
979
  "source-filter-cancel", "folder-filter-cancel",
980
  "filter-min-size", "filter-min-unit", "filter-max-size", "filter-max-unit", "random-book-btn",
 
981
  }
982
  self.assertEqual(parser.ids, expected)
983
  selector_exceptions = {"header-logo", "search-input"}
@@ -1002,6 +1004,7 @@ class FrontendContractTests(unittest.TestCase):
1002
  "filter-min-size": "DOM.filterMinSize", "filter-min-unit": "DOM.filterMinUnit",
1003
  "filter-max-size": "DOM.filterMaxSize", "filter-max-unit": "DOM.filterMaxUnit",
1004
  "random-book-btn": "DOM.randomBookBtn",
 
1005
  }
1006
  for control_id, object_name in event_objects.items():
1007
  self.assertIn(f'{object_name}.addEventListener(', source, control_id)
 
967
  def test_every_static_control_exists_is_cached_and_has_behavior(self):
968
  html = (Path(app.BASE_DIR) / "static" / "index.html").read_text(encoding="utf-8")
969
  source = (Path(app.BASE_DIR) / "static" / "app.js").read_text(encoding="utf-8")
970
+ source += (Path(app.BASE_DIR) / "static" / "tts-client.js").read_text(encoding="utf-8")
971
  parser = InteractiveIdParser()
972
  parser.feed(html)
973
  expected = {
 
979
  "folder-toggle-all",
980
  "source-filter-cancel", "folder-filter-cancel",
981
  "filter-min-size", "filter-min-unit", "filter-max-size", "filter-max-unit", "random-book-btn",
982
+ "speech-launch", "speech-collapse", "speech-stop", "speech-retry",
983
  }
984
  self.assertEqual(parser.ids, expected)
985
  selector_exceptions = {"header-logo", "search-input"}
 
1004
  "filter-min-size": "DOM.filterMinSize", "filter-min-unit": "DOM.filterMinUnit",
1005
  "filter-max-size": "DOM.filterMaxSize", "filter-max-unit": "DOM.filterMaxUnit",
1006
  "random-book-btn": "DOM.randomBookBtn",
1007
+ "speech-launch": "launch", "speech-collapse": "collapse", "speech-stop": "stopButton", "speech-retry": "retry",
1008
  }
1009
  for control_id, object_name in event_objects.items():
1010
  self.assertIn(f'{object_name}.addEventListener(', source, control_id)
tests/test_tts_browser.py ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Explicit offline CCRD/Hitokoto cross-origin bridge and reader acceptance.
2
+
3
+ Requires Playwright, HITOKOTO_SOURCE_DIR and an existing CCRD_TTS_SAMPLE MP3.
4
+ Requests are routed fixtures; no production, corpus scan or model loading.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ import re
12
+ import unittest
13
+ from urllib.parse import unquote, urlparse
14
+
15
+ try:
16
+ from playwright.sync_api import sync_playwright
17
+ except ImportError:
18
+ sync_playwright = None
19
+
20
+ ROOT = Path(__file__).resolve().parents[1]
21
+ SEARCH = "https://vomebook-search.hf.space"
22
+ SPEECH = "https://vomebook-hitokoto.hf.space"
23
+
24
+
25
+ @unittest.skipUnless(sync_playwright and os.environ.get("HITOKOTO_SOURCE_DIR") and os.environ.get("CCRD_TTS_SAMPLE"),
26
+ "explicit: set HITOKOTO_SOURCE_DIR and CCRD_TTS_SAMPLE, install Playwright/Chromium")
27
+ class SpeechReaderBrowserTests(unittest.TestCase):
28
+ def test_preview_selection_player_isolation_and_cross_origin_contract(self):
29
+ speech_root = Path(os.environ["HITOKOTO_SOURCE_DIR"])
30
+ mp3 = Path(os.environ["CCRD_TTS_SAMPLE"]).read_bytes()
31
+ source_text = "前言。𠮷这是一篇长文,校长文章写得很好,我得亲自处理。\n版本1.2.3与日期2026-10-18。" * 4
32
+ sources = [{"slug": "CCRD", "name": "CCRD", "count": 2}]
33
+ items = [{"doc_id": f"CCRD:{index}", "File": f"朗读测试{index}", "Source": "CCRD", "SourceName": "CCRD",
34
+ "Folder": ["测试"], "Size": 1234, "snippet": "长文与手机测试", "snippet_complete": True} for index in range(2)]
35
+ with sync_playwright() as playwright:
36
+ browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"])
37
+ try:
38
+ for mobile in (False, True):
39
+ with self.subTest(mobile=mobile):
40
+ context = browser.new_context(viewport={"width": 390, "height": 844} if mobile else {"width": 1440, "height": 900},
41
+ reduced_motion="reduce")
42
+ context.add_init_script("delete Navigator.prototype.serviceWorker")
43
+ context.add_init_script("document.addEventListener('DOMContentLoaded',()=>{const audio=document.querySelector('#audio');if(audio)audio.muted=true;})")
44
+ context.add_init_script("window.__bridge=[];addEventListener('message',event=>{if(event.data?.type?.startsWith('ccrd-reader-'))__bridge.push({origin:event.origin,data:event.data});})")
45
+ jobs, tts_posts, connections, deleted = {}, [], [], []
46
+ blocked = [False]
47
+ missing_client, old_html = [False], [False]
48
+ errors = []
49
+
50
+ def reply(route, data, status=200, headers=None):
51
+ route.fulfill(status=status, content_type="application/json", body=json.dumps(data), headers=headers or {})
52
+
53
+ def handler(route):
54
+ request = route.request
55
+ parsed = urlparse(request.url)
56
+ path = unquote(parsed.path)
57
+ if request.url.startswith(SEARCH):
58
+ if path == "/":
59
+ html = (ROOT / "static/index.html").read_text()
60
+ if old_html[0]:
61
+ html = re.sub(r'<button[^>]+id="speech-launch"[^>]*>.*?</button>', '', html)
62
+ html = re.sub(r'<dialog id="speech-dialog".*?</dialog>', '', html, flags=re.S)
63
+ html = html.replace('<script src="/static/tts-client.js"></script>', '')
64
+ route.fulfill(content_type="text/html", body=html)
65
+ elif path.startswith("/static/"):
66
+ if path == "/static/tts-client.js" and missing_client[0]:
67
+ route.abort("connectionfailed"); return
68
+ file = ROOT / path.lstrip("/")
69
+ if file.is_file():
70
+ route.fulfill(path=file)
71
+ else:
72
+ reply(route, {}, 404)
73
+ elif path == "/api/ping":
74
+ route.fulfill(status=204)
75
+ elif path == "/api/sources":
76
+ reply(route, sources)
77
+ elif path.startswith("/data/sidebar/"):
78
+ reply(route, {"sources": sources})
79
+ elif path.startswith("/data/initial/") or path.startswith("/api/search"):
80
+ reply(route, {"results": items, "total": 2, "page": 1, "page_size": 100, "q": "",
81
+ "sort": "relevance", "exact": True, "fulltext": True, "search_paths": True, "source": None})
82
+ elif path.startswith("/api/preview/"):
83
+ id = path.rsplit("/", 1)[-1]
84
+ reply(route, {"doc_id": id, "title": "朗读测试 <b>原文</b>", "source": "CCRD", "path": "测试/原文.txt",
85
+ "text": source_text if id == "CCRD:0" else "长"*20001 if id == "CCRD:long" else "另一个预览,不应该打断原朗读。"})
86
+ elif path == "/manifest.json":
87
+ reply(route, {"name": "Test"})
88
+ else:
89
+ reply(route, {}, 404)
90
+ elif request.url.startswith(SPEECH):
91
+ connections.append(request.url)
92
+ if blocked[0] and path == "/tts/":
93
+ route.abort("connectionfailed"); return
94
+ if path == "/tts/":
95
+ route.fulfill(content_type="text/html", body=(speech_root / "static/tts.html").read_bytes(),
96
+ headers={"Content-Security-Policy": "frame-ancestors " + SEARCH})
97
+ elif path in {"/tts/app.js", "/tts/reader.js", "/tts/embed.js", "/tts/style.css"}:
98
+ name = {"app.js": "tts.js", "style.css": "tts.css"}.get(path.rsplit("/", 1)[-1], path.rsplit("/", 1)[-1])
99
+ route.fulfill(path=speech_root / "static" / name)
100
+ elif path == "/tts/health":
101
+ reply(route, {"ready": True, "engine": {"threads": 2}, "queued": 0})
102
+ elif path == "/tts/voices":
103
+ reply(route, {"voices": [{"id": id, "name": id} for id in ("zf_001", "zf_017", "zm_010", "zm_029")]})
104
+ elif path == "/tts/jobs" and request.method == "POST":
105
+ body = request.post_data_json
106
+ tts_posts.append(body)
107
+ id = f"{len(jobs)+1:032d}"
108
+ length = len(body["text"].encode("utf-16-le")) // 2
109
+ jobs[id] = {"id": id, "state": "ready", "cache_hit": False, "result": {
110
+ "audio_url": f"/tts/audio/{id}.mp3", "audio_seconds": 10.6, "synthesis_seconds": .2,
111
+ "pitch_semitones": body["pitch_semitones"]}, "source_segments": [{"start": 0, "end": length}],
112
+ "progress": {"complete": True, "total_parts": 1, "parts": [{"index": 0, "start": 0, "end": length,
113
+ "audio_url": f"/tts/audio/{id}/parts/0.mp3", "first_audio_seconds": .2,
114
+ "audio_seconds": 10.6, "pitch_semitones": body["pitch_semitones"]}]}}
115
+ reply(route, jobs[id], 202)
116
+ elif path.startswith("/tts/jobs/"):
117
+ id = path.rsplit("/", 1)[-1]
118
+ if request.method == "DELETE": deleted.append(id)
119
+ reply(route, jobs.get(id, {"state": "error"}))
120
+ elif path.startswith("/tts/audio/"):
121
+ route.fulfill(content_type="audio/mpeg", body=mp3)
122
+ else:
123
+ reply(route, {}, 404)
124
+ else:
125
+ route.fulfill(status=204)
126
+
127
+ context.route("**/*", handler)
128
+ page = context.new_page()
129
+ page.on("pageerror", lambda error: errors.append(str(error)))
130
+ page.set_default_timeout(30000)
131
+ try:
132
+ page.goto(SEARCH + "/", wait_until="domcontentloaded")
133
+ page.locator(".result-item").first.wait_for()
134
+ self.assertFalse(connections, "Opening search must not load the speech service")
135
+ self.assertFalse(tts_posts)
136
+ page.locator("[data-action=preview]").first.click()
137
+ page.locator(".preview-body").wait_for()
138
+ self.assertEqual(page.locator(".preview-body").text_content(), source_text)
139
+ # No selection is an explicit error, not a fallback to full text.
140
+ page.locator("[data-speech=selection]").click()
141
+ self.assertFalse(connections)
142
+ selected_text = "这是一篇长文,校长文章写得很好,我��亲自处理。"
143
+ page.evaluate("""text=>{
144
+ const body=document.querySelector('.preview-body');const start=body.textContent.indexOf(text);
145
+ const walker=document.createTreeWalker(body,NodeFilter.SHOW_TEXT);let node;let offset=0;
146
+ const range=document.createRange();let begun=false;
147
+ while(node=walker.nextNode()){
148
+ if(!begun&&start<offset+node.length){range.setStart(node,start-offset);begun=true;}
149
+ if(begun&&start+text.length<=offset+node.length){range.setEnd(node,start+text.length-offset);break;}offset+=node.length;
150
+ }
151
+ getSelection().removeAllRanges();getSelection().addRange(range);
152
+ }""", selected_text)
153
+ page.locator("[data-speech=selection]").click()
154
+ page.wait_for_function("document.querySelector('#speech-status').textContent.includes('已载入')")
155
+ frame = page.frame_locator("#speech-frame")
156
+ self.assertEqual(frame.locator("#speech-text").input_value(), selected_text)
157
+ self.assertFalse(tts_posts, "Import alone must not submit speech")
158
+ self.assertFalse(frame.locator("#storage-controls").is_visible())
159
+ self.assertFalse(frame.locator("#remember").is_checked())
160
+ self.assertTrue(page.evaluate("CCRDSpeech.modal()") if mobile else not page.evaluate("CCRDSpeech.modal()"))
161
+ self.assertTrue(page.evaluate("document.documentElement.scrollWidth<=innerWidth+1"))
162
+ self.assertEqual(frame.locator("main b").count(), 0)
163
+ if mobile:
164
+ frame.locator("#playback-rate").focus()
165
+ page.keyboard.press("Tab")
166
+ page.wait_for_function("document.activeElement.id==='speech-collapse'")
167
+ # Same-origin spoof from the wrong Window/channel is ignored.
168
+ page.evaluate("""()=>window.postMessage({type:'ccrd-reader-loaded',channel:'0'.repeat(48),characters:99999},location.origin)""")
169
+ self.assertNotIn("99999", page.locator("#speech-status").text_content())
170
+ bridge_channel = page.evaluate("__bridge.find(event=>event.data.type==='ccrd-reader-ready').data.channel")
171
+ # The source origin alone is insufficient: a sibling
172
+ # Window with that origin and nonce is not the parent.
173
+ page.evaluate("""({channel,target})=>{
174
+ const sibling=document.createElement('iframe');sibling.hidden=true;document.body.append(sibling);
175
+ const data={type:'ccrd-reader-import',channel,text:'不应该载入',title:'伪造',context_before:'',context_after:''};
176
+ sibling.contentWindow.eval(`parent.document.querySelector('#speech-frame').contentWindow.postMessage(${JSON.stringify(data)},${JSON.stringify(target)})`);
177
+ setTimeout(()=>sibling.remove(),100);
178
+ }""", {"channel": bridge_channel, "target": SPEECH})
179
+ page.wait_for_timeout(200)
180
+ self.assertEqual(frame.locator("#speech-text").input_value(), selected_text)
181
+ frame.locator("#pitch").evaluate("n=>{n.value=-2;n.dispatchEvent(new Event('input',{bubbles:true}));}")
182
+ frame.locator("#playback-rate").evaluate("n=>{n.value=.5;n.dispatchEvent(new Event('input',{bubbles:true}));}")
183
+ frame.locator("#generate").click()
184
+ page.wait_for_function("document.querySelector('#speech-frame')!==null")
185
+ speech_page = next(f for f in page.frames if f.url.startswith(SPEECH))
186
+ speech_page.wait_for_function("session && document.querySelector('#audio').currentTime>.1")
187
+ self.assertEqual(tts_posts[0]["text"], selected_text)
188
+ self.assertEqual(tts_posts[0]["pitch_semitones"], -2)
189
+ self.assertTrue(tts_posts[0]["context_before"].endswith("前言。𠮷"))
190
+ self.assertNotIn("原文.txt", json.dumps(tts_posts))
191
+ # Hiding/reopening keeps the exact iframe and audio session alive.
192
+ original_src = page.locator("#speech-frame").get_attribute("src")
193
+ page.locator("#speech-collapse").click()
194
+ self.assertFalse(page.locator("#speech-dialog").is_visible())
195
+ speech_page.wait_for_function("document.querySelector('#audio').currentTime>.3", polling=100)
196
+ self.assertFalse(deleted)
197
+ if mobile:
198
+ page.locator("[data-action=close-preview]").click()
199
+ page.wait_for_function("!STATE.previewDocId")
200
+ page.locator("#search-input").fill("手机")
201
+ page.wait_for_function("STATE.query==='手机' && !STATE.isLoading")
202
+ page.locator("#sort-select").select_option("name")
203
+ page.locator("[data-action=preview]").last.click()
204
+ page.wait_for_function("STATE.previewDocId==='CCRD:1'")
205
+ self.assertTrue(speech_page.evaluate("session!==null"))
206
+ self.assertFalse(deleted)
207
+ page.locator("[data-speech=controls]").click()
208
+ self.assertEqual(page.locator("#speech-frame").get_attribute("src"), original_src)
209
+ self.assertEqual(frame.locator("#speech-text").input_value(), selected_text)
210
+ page.locator("#speech-collapse").click()
211
+ page.locator("[data-speech=preview]").click()
212
+ frame.locator("#embed-keep").wait_for(state="visible")
213
+ self.assertEqual(frame.locator("#speech-text").input_value(), selected_text)
214
+ self.assertEqual(len(tts_posts), 1)
215
+ frame.locator("#embed-keep").click()
216
+ self.assertTrue(speech_page.evaluate("session!==null"))
217
+ page.locator("#speech-collapse").click()
218
+ page.locator("[data-speech=preview]").click()
219
+ frame.locator("#embed-replace").click()
220
+ self.assertEqual(frame.locator("#speech-text").input_value(), "另一个预览,不应该打断原朗读。")
221
+ self.assertEqual(len(tts_posts), 1, "Replacement requires a separate explicit synthesis click")
222
+ frame.locator("#generate").click()
223
+ speech_page.wait_for_function("session && document.querySelector('#audio').currentTime>.1")
224
+ self.assertEqual(tts_posts[-1]["text"], "另一个预览,不应该打断原朗读。")
225
+ frame.locator("#speech-text").press("Escape")
226
+ page.wait_for_function("!document.querySelector('#speech-dialog').open")
227
+ page.locator("[data-speech=controls]").click()
228
+ page.locator("#speech-stop").click()
229
+ speech_page.wait_for_function("session===null", polling=100)
230
+ self.assertTrue(deleted)
231
+ count = len(tts_posts)
232
+ page.evaluate("openPreview('CCRD:long','')")
233
+ page.wait_for_function("document.querySelector('.preview-body').textContent.length===20001")
234
+ page.locator("[data-speech=preview]").click()
235
+ self.assertEqual(len(tts_posts), count)
236
+ self.assertIn("两万字", page.locator("#speech-status").text_content())
237
+ # Offline player failure leaves real search/preview
238
+ # code working; no auto retry loop or speech POST.
239
+ blocked[0] = True
240
+ page.goto(SEARCH + "/", wait_until="domcontentloaded")
241
+ page.locator(".result-item").first.wait_for()
242
+ page.wait_for_function("!STATE.isLoading")
243
+ page.evaluate("const native=setTimeout;window.setTimeout=(fn,ms,...args)=>native(fn,ms===20000?100:ms,...args)")
244
+ if not page.locator(".preview-body").count():
245
+ page.locator("[data-action=preview]").first.click()
246
+ page.locator("[data-speech=preview]").click()
247
+ page.locator("#speech-retry").wait_for(state="visible")
248
+ page.locator("#speech-collapse").click()
249
+ if mobile:
250
+ page.locator("[data-action=close-preview]").click()
251
+ page.wait_for_function("!STATE.previewDocId")
252
+ page.locator("#search-input").fill("朗读故障后仍能搜索")
253
+ page.wait_for_function("STATE.query==='朗读故障后仍能搜索' && !STATE.isLoading")
254
+ self.assertEqual(page.locator(".result-item").count(), 2)
255
+ self.assertEqual(len(tts_posts), count)
256
+ # New HTML with a missing bridge, and an old cached
257
+ # shell with new app.js, must both retain search.
258
+ for mode in ('missing-client', 'old-html'):
259
+ missing_client[0] = mode == 'missing-client'
260
+ old_html[0] = mode == 'old-html'
261
+ page.goto(SEARCH + "/", wait_until="domcontentloaded")
262
+ page.locator(".result-item").first.wait_for()
263
+ page.locator("#search-input").fill("缓存兼容")
264
+ page.wait_for_function("STATE.query==='缓存兼容' && !STATE.isLoading")
265
+ self.assertEqual(page.locator(".result-item").count(), 2)
266
+ self.assertEqual(len(tts_posts), count)
267
+ self.assertFalse(errors, errors)
268
+ finally:
269
+ context.close()
270
+ finally:
271
+ browser.close()