Download static/reader.js from vomebook/Hitokoto: direct link, hf CLI and curl.
- Browser
- Download file 9.39 kB
-
https://huggingface.co/spaces/vomebook/Hitokoto/resolve/main/static/reader.js
- Command line
-
hf download hf://spaces/vomebook/Hitokoto/static/reader.js
-
curl -L -o reader.js https://huggingface.co/spaces/vomebook/Hitokoto/resolve/main/static/reader.js
9.39 kB
| ; | |
| // Pure planning/bookmark helpers; positions are original JavaScript UTF-16 offsets. | |
| globalThis.ReaderPlan = (() => { | |
| const maxText = 20000; | |
| const batchSize = 600; | |
| const bookmarkKey = "hitokoto-reader-v1"; | |
| // "1." / "一." / "(3)." numbering its item at a line or clause start: the period | |
| // is enumeration, not a sentence end. Mirrors tts/text.py list_marker. | |
| function listMarker(chars, index) { | |
| let cursor = index; | |
| const closed = cursor > 0 && /[))]/u.test(chars[cursor - 1]); | |
| if (closed) cursor -= 1; | |
| const digits = cursor; | |
| while (cursor > 0 && digits - cursor < 4 && /[0-90-9一二三四五六七八九十百]/u.test(chars[cursor - 1])) cursor -= 1; | |
| if (cursor === digits) return false; | |
| if (cursor > 0 && /[((]/u.test(chars[cursor - 1])) cursor -= 1; | |
| else if (closed) return false; | |
| return cursor === 0 || /[ \t\r\n。!?!?;;::,,、]/u.test(chars[cursor - 1]); | |
| } | |
| function numberSpans(text) { | |
| // Same recognised atoms as tts.numbers; source positions remain UTF-16. | |
| const label = "(?:订单编号|訂單編號|订单号|訂單號|编号|編號|手机号|手機號|电话|電話|学号|學號|邮编|郵編|ISBN(?:-13|-10)?|IP)"; | |
| const pattern = new RegExp(label + "[ \\t]*[::]?[ \\t]*[A-Za-z0-9]+(?:[._/\\-][A-Za-z0-9]+)*" + | |
| "|(?<![A-Za-z0-9])(?:版本|[Vv]ersion|[Vv])[ \\t]*[::]?[ \\t]*[vV]?[0-9]+(?:\\.[0-9]+)+" + | |
| "|(?<![A-Za-z0-9])[0-9]{4}[-/][0-9]{1,2}[-/][0-9]{1,2}(?![0-9])" + | |
| "|(?<![A-Za-z0-9])[0-9]{4}年[0-9]{1,2}月[0-9]{1,2}[日号]" + | |
| "|(?<![A-Za-z0-9])[0-9]{4}年|(?<![0-9])[0-9]+(?:\\.[0-9]+){2,}" + | |
| "|(?<![A-Za-z0-9])[+-]?[0-9]+/[0-9]+(?![0-9/])" + | |
| "|[+-]?[0-9]+(?:,[0-9]{3})*(?:\\.[0-9]+)?(?:[%%]|℃)?", "gu"); | |
| return Array.from(text.matchAll(pattern), (match) => ({ start: match.index, end: match.index + match[0].length })); | |
| } | |
| function split(text, limit = batchSize, firstLimit = Math.min(limit, 180)) { | |
| const chars = Array.from(text); | |
| if (!text.trim() || chars.length > maxText || !Number.isInteger(limit) || limit < 1 || limit > 800 || | |
| !Number.isInteger(firstLimit) || firstLimit < 1 || firstLimit > limit) { | |
| throw new Error(`请输入 1–${maxText.toLocaleString()} 个 Unicode 字符的正文`); | |
| } | |
| const offsets = [0]; | |
| for (const char of chars) offsets.push(offsets[offsets.length - 1] + char.length); | |
| const positions = new Map(offsets.map((offset, index) => [offset, index])); | |
| const atoms = numberSpans(text).map((part) => ({ start: positions.get(part.start), end: positions.get(part.end) })); | |
| const result = []; | |
| for (let start = 0; start < chars.length;) { | |
| const width = result.length ? limit : firstLimit; | |
| let end = Math.min(start + width, chars.length); | |
| if (end < chars.length) { | |
| // Prefer a recent sentence boundary, without reducing every batch to a | |
| // tiny sentence or splitting decimal points, years and ASCII words. | |
| for (let index = end - 1; index >= start + Math.floor(width / 2); index -= 1) { | |
| if (/[。!?!?;;\n]/u.test(chars[index]) || | |
| (chars[index] === "." && !(/[0-9]/u.test(chars[index - 1] || "") && /[0-9]/u.test(chars[index + 1] || "")) && !listMarker(chars, index))) { | |
| end = index + 1; | |
| break; | |
| } | |
| } | |
| const insideToken = (index) => index < chars.length && | |
| ((/[a-zA-Z0-9]/u.test(chars[index - 1]) && /[a-zA-Z0-9]/u.test(chars[index])) || | |
| (/[0-9.]/u.test(chars[index - 1]) && /[0-9.]/u.test(chars[index]))); | |
| while (end > start && insideToken(end)) end -= 1; | |
| // A long but otherwise valid first English word need not fit the short | |
| // first-batch target. Fall back to the ordinary bounded batch size. | |
| if (end === start && width < limit) { | |
| end = Math.min(start + limit, chars.length); | |
| while (end > start && insideToken(end)) end -= 1; | |
| } | |
| if (end === start) throw new Error("单个英文词或数字过长,无法在保留原文的同时分批朗读"); | |
| for (const atom of atoms) { | |
| if (atom.start < end && end < atom.end) { | |
| end = atom.start > start ? atom.start : atom.end; | |
| break; | |
| } | |
| } | |
| if (end - start > limit) throw new Error("单个日期、数字或编号超过批次上限,请整理后重试"); | |
| } | |
| const body = chars.slice(start, end).join(""); | |
| if (!/[\p{L}\p{N}]/u.test(body)) throw new Error("正文含有过长的连续空白或标点,请整理后重试"); | |
| result.push({ text: body, start: offsets[start], end: offsets[end], | |
| context_before: chars.slice(Math.max(0, start - 32), start).join(""), | |
| context_after: chars.slice(end, end + 32).join("") }); | |
| start = end; | |
| } | |
| return result; | |
| } | |
| function readBookmark(storage) { | |
| try { | |
| const value = JSON.parse(storage.getItem(bookmarkKey)); | |
| if (value?.version !== 1 || typeof value.text !== "string" || Array.from(value.text).length > maxText || | |
| !value.text.trim() || !["zf_001", "zf_017", "zm_010", "zm_029"].includes(value.voice) || | |
| !Number.isFinite(value.speed) || value.speed < 0.7 || value.speed > 1.4 || | |
| !Number.isInteger(value.offset) || value.offset < 0 || value.offset > value.text.length || | |
| !Number.isFinite(value.seconds) || value.seconds < 0 || value.seconds > 240 || | |
| (value.pitchSemitones !== undefined && (!Number.isInteger(value.pitchSemitones) || value.pitchSemitones < -3 || value.pitchSemitones > 3)) || | |
| (value.playbackRate !== undefined && (!Number.isFinite(value.playbackRate) || value.playbackRate < .5 || value.playbackRate > 2))) return null; | |
| return value; | |
| } catch (_) { return null; } | |
| } | |
| function clipPlan(text) { | |
| // Mirror tts.text's lossless source segmentation for navigation before audio | |
| // is generated. The API's published spans remain authoritative for playback. | |
| const chars = Array.from(text), offsets = [0], raw = [], result = []; | |
| for (const char of chars) offsets.push(offsets[offsets.length - 1] + char.length); | |
| const positions = new Map(offsets.map((offset, index) => [offset, index])); | |
| const atoms = numberSpans(text).map((part) => ({ start: positions.get(part.start), end: positions.get(part.end) })); | |
| const digit = (char) => /\p{Nd}/u.test(char || ""); | |
| const ascii = (char) => /^[a-zA-Z0-9]$/u.test(char || ""); | |
| for (let start = 0; start < chars.length;) { | |
| let end = Math.min(chars.length, start + 60); | |
| for (let index = start; index < end; index += 1) { | |
| if (/[。!?!?;;\n]/u.test(chars[index]) || (chars[index] === "." && !(digit(chars[index - 1]) && digit(chars[index + 1])) && !listMarker(chars, index))) { | |
| end = index + 1; | |
| while (end < chars.length && end - start < 60 && /[”’"'」』)) \t\r]/u.test(chars[end])) end += 1; | |
| break; | |
| } | |
| } | |
| if (end < chars.length) { | |
| let boundary = end; | |
| while (boundary > start && ((ascii(chars[boundary - 1]) && ascii(chars[boundary])) || | |
| (/^[0-9.]$/u.test(chars[boundary - 1]) && /^[0-9.]$/u.test(chars[boundary])))) boundary -= 1; | |
| if (boundary > start) end = boundary; | |
| } | |
| for (const atom of atoms) { | |
| if (atom.start < end && end < atom.end) { end = atom.start > start ? atom.start : atom.end; break; } | |
| } | |
| raw.push(end); | |
| start = end; | |
| } | |
| let start = 0; | |
| for (const end of raw) { | |
| const body = chars.slice(start, end).join(""); | |
| const speech = /[\p{L}\p{N}]/u.test(body); | |
| if (end === chars.length && !speech) { | |
| if (!result.length) throw new Error("正文不能只有标点或空白"); | |
| result[result.length - 1].end = offsets[end]; | |
| start = end; | |
| } else if (speech && (end === chars.length || (result.length < 63 && | |
| (end - start >= (result.length ? 40 : 20) || /\n[ \t\r]*$/u.test(body))))) { | |
| result.push({ start: offsets[start], end: offsets[end] }); | |
| start = end; | |
| } | |
| } | |
| return result; | |
| } | |
| function locations(plan) { | |
| return plan.flatMap((batch, batchIndex) => clipPlan(batch.text).map((part, index) => ( | |
| { batch: batchIndex, index, start: batch.start + part.start, end: batch.start + part.end } | |
| ))); | |
| } | |
| class SleepTimer { | |
| constructor(clock = Date.now) { this.clock = clock; this.clear(); } | |
| clear() { this.mode = "off"; this.deadline = 0; this.anchor = null; } | |
| arm(mode, anchor = null) { | |
| if (mode === "off") { this.clear(); return; } | |
| if (mode === "clip") { this.mode = mode; this.deadline = 0; this.anchor = anchor; return; } | |
| if (!["5", "15", "30", "60"].includes(mode)) throw new Error("无效的睡眠定时"); | |
| this.mode = mode; this.deadline = this.clock() + Number(mode) * 60000; this.anchor = null; | |
| } | |
| remaining() { return Math.max(0, Math.ceil((this.deadline - this.clock()) / 1000)); } | |
| due() { return this.deadline > 0 && this.clock() >= this.deadline; } | |
| bind(anchor) { if (this.mode === "clip" && this.anchor === null) this.anchor = anchor; } | |
| ended(anchor) { return this.mode === "clip" && this.anchor === anchor; } | |
| } | |
| return { maxText, batchSize, bookmarkKey, numberSpans, split, clipPlan, locations, readBookmark, SleepTimer }; | |
| })(); | |