"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]+)*" + "|(? ({ 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 }; })();