Hitokoto / static /reader.js
vomebook's picture
fix(tts): read list numbers as enumerations, not separate sentences
8c72c22
Raw History Blame Contribute Delete
9.39 kB
"use strict";
// 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 };
})();