mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-05 07:21:34 -07:00
afa66ee508
- Cap only blind shrinking-window retries (not backend-guided shrinks); a line that exhausts the cap escalates to one parseText fallback instead of dropping to raw text - Bound the cross-line termsFind cache by retained dictionary-entry weight, not just key count, and re-check it when a lookup resolves - Keep halfwidth katakana character names in the greedy pre-pass, and let a generic word beat a name it fully contains - Share one Han code-point table between the character dictionary and the scanner's name pre-pass; narrow the mob-disambiguator filter to the split letters, not every one-character term - Release the subtitle prefetch pause on a new onProcessingSettled signal instead of the tokenized emit, so duplicate/suppressed/failed lines no longer pause prefetch indefinitely - Split the Yomitan scan runtime's injected helper script into its own file
514 lines
24 KiB
TypeScript
514 lines
24 KiB
TypeScript
// In-page Yomitan scan runtime: the scan walk that gets installed once per
|
||
// parser window as globalThis.__subminerYomitanScan, plus the tiny per-line
|
||
// call script. Kept separate from the host runtime module so the injected
|
||
// script text (which is data, not executed here) does not dominate that file;
|
||
// the helper bundle it embeds lives in yomitan-scanning-helpers-script.ts.
|
||
import { YOMITAN_SCANNING_HELPERS } from './yomitan-scanning-helpers-script';
|
||
|
||
export { CHARACTER_DICTIONARY_TITLE_PREFIX } from './yomitan-scanning-helpers-script';
|
||
|
||
export type YomitanFrequencyMode = 'occurrence-based' | 'rank-based';
|
||
|
||
// Bump whenever the install script below changes so already-loaded parser
|
||
// windows re-install the new scan runtime instead of running the stale one.
|
||
export const YOMITAN_SCAN_RUNTIME_VERSION = 6;
|
||
export const YOMITAN_SCAN_RUNTIME_MISSING_SENTINEL = '__subminer-yomitan-scan-runtime-missing__';
|
||
|
||
export interface YomitanScanRequestParams {
|
||
text: string;
|
||
profileIndex: number;
|
||
scanLength: number;
|
||
includeNameMatchMetadata: boolean;
|
||
greedyNameScanEnabled: boolean;
|
||
currentCharacterDictionaryMediaId: number | null;
|
||
dictionaryPriorityByName: Record<string, number>;
|
||
dictionaryFrequencyModeByName: Partial<Record<string, YomitanFrequencyMode>>;
|
||
cacheEpoch: number;
|
||
/**
|
||
* Key of the character-name candidate list installed for the current media,
|
||
* or null to scan every Japanese position (see the pre-pass prefilter).
|
||
*/
|
||
nameCandidateKey: string | null;
|
||
}
|
||
|
||
// Installed once per parser window (and re-installed after in-page reloads):
|
||
// keeps V8 from re-parsing the helper bundle on every subtitle line, and hosts
|
||
// the cross-line termsFind cache. Each subtitle line then only evaluates a tiny
|
||
// call into globalThis.__subminerYomitanScan.
|
||
export const YOMITAN_SCAN_RUNTIME_INSTALL_SCRIPT = String.raw`
|
||
(() => {
|
||
if (globalThis.__subminerYomitanScanVersion === ${YOMITAN_SCAN_RUNTIME_VERSION}) {
|
||
return true;
|
||
}
|
||
const invoke = (action, params) =>
|
||
new Promise((resolve, reject) => {
|
||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||
if (chrome.runtime.lastError) {
|
||
reject(new Error(chrome.runtime.lastError.message));
|
||
return;
|
||
}
|
||
if (!response || typeof response !== "object") {
|
||
reject(new Error("Invalid response from Yomitan backend"));
|
||
return;
|
||
}
|
||
if (response.error) {
|
||
reject(new Error(response.error.message || "Yomitan backend error"));
|
||
return;
|
||
}
|
||
resolve(response.result);
|
||
});
|
||
});
|
||
// Cross-line termsFind LRU keyed by profile + substring: subtitle lines
|
||
// repeat particles and inflections constantly, so most lookups hit here.
|
||
// Entries hold in-flight promises so concurrent identical lookups dedupe.
|
||
const termsFindCache = new Map();
|
||
// Two bounds. The key count keeps the map itself small; the accumulated
|
||
// dictionary-entry count stands in for retained bytes, because a single
|
||
// lookup over a common prefix can hold hundreds of entries with their full
|
||
// glossaries and a key-count cap alone would not bound that.
|
||
const TERMS_FIND_CACHE_LIMIT = 2000;
|
||
const TERMS_FIND_CACHE_DICTIONARY_ENTRY_LIMIT = 20000;
|
||
let termsFindCacheDictionaryEntries = 0;
|
||
let termsFindCacheEpoch = -1;
|
||
function dropCachedTermsFind(cacheKey, entry) {
|
||
if (termsFindCache.get(cacheKey) !== entry) { return; }
|
||
termsFindCache.delete(cacheKey);
|
||
termsFindCacheDictionaryEntries -= entry.dictionaryEntryCount;
|
||
}
|
||
// Runs on insert and again once a lookup resolves: an entry is only worth
|
||
// its estimated weight of 1 until then, so a single oversized response
|
||
// would otherwise sit in the cache forever, over the limit and reused.
|
||
function evictOverflowingTermsFindEntries() {
|
||
while (
|
||
termsFindCache.size > TERMS_FIND_CACHE_LIMIT ||
|
||
termsFindCacheDictionaryEntries > TERMS_FIND_CACHE_DICTIONARY_ENTRY_LIMIT
|
||
) {
|
||
const oldest = termsFindCache.entries().next().value;
|
||
if (oldest === undefined) { break; }
|
||
dropCachedTermsFind(oldest[0], oldest[1]);
|
||
}
|
||
}
|
||
// Only blind ladder steps are capped (see the retry loop): those are the
|
||
// ones that would otherwise degrade into O(scanLength) lookups at a single
|
||
// position. Steps the backend guides by reporting a shorter consumed length
|
||
// stay uncapped, so a valid prefix term is still found on lines where
|
||
// normalization eats a long tail.
|
||
const MAX_BLIND_SHRINKING_WINDOW_RETRIES = 4;
|
||
// Character-name candidate forms for the current media, installed
|
||
// separately from the per-line scan call so the per-line script stays tiny.
|
||
// Stored raw here; the normalized lookup index is built inside the scan,
|
||
// where the kana-normalization helper is in scope, and reused by key.
|
||
let rawNameCandidates = null;
|
||
let nameCandidateIndex = null;
|
||
globalThis.__subminerYomitanScanSetNameCandidates = (key, forms) => {
|
||
if (!key || !Array.isArray(forms) || forms.length === 0) {
|
||
rawNameCandidates = null;
|
||
nameCandidateIndex = null;
|
||
return false;
|
||
}
|
||
rawNameCandidates = { key, forms };
|
||
nameCandidateIndex = null;
|
||
return true;
|
||
};
|
||
globalThis.__subminerYomitanScanVersion = ${YOMITAN_SCAN_RUNTIME_VERSION};
|
||
globalThis.__subminerYomitanScan = async (scanParams) => {
|
||
const {
|
||
text,
|
||
profileIndex,
|
||
scanLength,
|
||
includeNameMatchMetadata,
|
||
greedyNameScanEnabled,
|
||
currentCharacterDictionaryMediaId,
|
||
dictionaryPriorityByName,
|
||
dictionaryFrequencyModeByName,
|
||
cacheEpoch,
|
||
nameCandidateKey
|
||
} = scanParams;
|
||
if (cacheEpoch !== termsFindCacheEpoch) {
|
||
termsFindCache.clear();
|
||
termsFindCacheDictionaryEntries = 0;
|
||
termsFindCacheEpoch = cacheEpoch;
|
||
}
|
||
${YOMITAN_SCANNING_HELPERS}
|
||
const CAPTION_OPENING_BRACKETS = new Set(["(", "(", "[", "[", "{", "{", "「", "『", "【", "〈", "《", "≪", "<", "<"]);
|
||
function shouldEmitUnparsedRunAsToken(runText) {
|
||
if (!/[\p{L}\p{N}]/u.test(runText)) { return false; }
|
||
const firstChar = Array.from(runText.trim())[0];
|
||
return firstChar !== undefined && !CAPTION_OPENING_BRACKETS.has(firstChar);
|
||
}
|
||
function isLookupWorthyCodePoint(codePoint) {
|
||
if (isCodePointJapanese(codePoint)) { return true; }
|
||
return /[\p{L}\p{N}]/u.test(String.fromCodePoint(codePoint));
|
||
}
|
||
function isKanaOnlyRunText(runText) {
|
||
const chars = Array.from(runText);
|
||
return chars.length > 0 && chars.every((char) => isCodePointKana(char.codePointAt(0)));
|
||
}
|
||
const details = {matchType: "exact", deinflect: true};
|
||
const tokens = [];
|
||
async function termsFindAt(position, windowLength) {
|
||
const substring = text.substring(position, position + windowLength);
|
||
const cacheKey = profileIndex + "\u0000" + substring;
|
||
const cached = termsFindCache.get(cacheKey);
|
||
if (cached !== undefined) {
|
||
termsFindCache.delete(cacheKey);
|
||
termsFindCache.set(cacheKey, cached);
|
||
return await cached.promise;
|
||
}
|
||
// An in-flight lookup counts as one entry until it resolves; the real
|
||
// weight replaces that estimate once the result is known.
|
||
const entry = { promise: null, dictionaryEntryCount: 1 };
|
||
entry.promise = invoke("termsFind", { text: substring, details, optionsContext: { index: profileIndex } })
|
||
.then((result) => {
|
||
const resolvedCount =
|
||
1 + (Array.isArray(result?.dictionaryEntries) ? result.dictionaryEntries.length : 0);
|
||
const isCached = termsFindCache.get(cacheKey) === entry;
|
||
if (isCached) {
|
||
termsFindCacheDictionaryEntries += resolvedCount - entry.dictionaryEntryCount;
|
||
}
|
||
entry.dictionaryEntryCount = resolvedCount;
|
||
// The real weight can push the cache over its budget, and a single
|
||
// response can exceed it on its own, so re-check here.
|
||
if (isCached) { evictOverflowingTermsFindEntries(); }
|
||
return result;
|
||
});
|
||
termsFindCache.set(cacheKey, entry);
|
||
termsFindCacheDictionaryEntries += entry.dictionaryEntryCount;
|
||
evictOverflowingTermsFindEntries();
|
||
try {
|
||
return await entry.promise;
|
||
} catch (error) {
|
||
dropCachedTermsFind(cacheKey, entry);
|
||
throw error;
|
||
}
|
||
}
|
||
// Text the walk skips accumulates into unparsed runs, mirroring the
|
||
// filler chunks the parseText segmentation used to provide: runs stay
|
||
// hoverable (flagged isUnparsedRun) unless they are punctuation-only or
|
||
// caption-style asides, and kana continuations of a longer headword
|
||
// extend the previous token instead.
|
||
function flushUnparsedRun(runStart, runEnd) {
|
||
if (runStart === null || runEnd <= runStart) { return; }
|
||
const runText = text.substring(runStart, runEnd);
|
||
const previousToken = tokens[tokens.length - 1];
|
||
if (
|
||
previousToken &&
|
||
previousToken.endPos === runStart &&
|
||
isKanaOnlyRunText(runText) &&
|
||
typeof previousToken.headword === "string" &&
|
||
previousToken.headword.length > previousToken.surface.length &&
|
||
previousToken.headword.startsWith(previousToken.surface + runText)
|
||
) {
|
||
previousToken.surface += runText;
|
||
// The run is kana-only, so its reading is itself: append it or the
|
||
// reading stops covering the surface, which disables the known-word
|
||
// reading fallback (isCompleteReadingForSurface) downstream.
|
||
previousToken.reading += runText;
|
||
// The run is kana-only, so its reading is itself: append it or the
|
||
// reading stops covering the surface, which disables the known-word
|
||
// reading fallback (isCompleteReadingForSurface) downstream.
|
||
previousToken.endPos = runEnd;
|
||
return;
|
||
}
|
||
if (!shouldEmitUnparsedRunAsToken(runText)) { return; }
|
||
tokens.push({
|
||
surface: runText,
|
||
reading: "",
|
||
headword: runText,
|
||
startPos: runStart,
|
||
endPos: runEnd,
|
||
isUnparsedRun: true
|
||
});
|
||
}
|
||
function buildScanToken(position, source, preferredHeadword) {
|
||
const reading = typeof preferredHeadword.reading === "string" ? preferredHeadword.reading : "";
|
||
const segments = distributeFuriganaInflected(preferredHeadword.term, reading, source);
|
||
const tokenPayload = {
|
||
surface: segments.map((segment) => segment.text).join("") || source,
|
||
reading: segments.map(getSegmentReadingContribution).join(""),
|
||
headword: preferredHeadword.term,
|
||
headwordReading: reading || undefined,
|
||
startPos: position,
|
||
endPos: position + source.length,
|
||
isNameMatch: includeNameMatchMetadata && preferredHeadword.isNameMatch === true,
|
||
frequencyRank:
|
||
typeof preferredHeadword.frequencyRank === "number" && Number.isFinite(preferredHeadword.frequencyRank)
|
||
? Math.max(1, Math.floor(preferredHeadword.frequencyRank))
|
||
: undefined,
|
||
};
|
||
if (Array.isArray(preferredHeadword.wordClasses) && preferredHeadword.wordClasses.length > 0) {
|
||
tokenPayload.wordClasses = preferredHeadword.wordClasses;
|
||
}
|
||
return tokenPayload;
|
||
}
|
||
// findTokenAt plus the shrinking-window ladder below it: Yomitan text
|
||
// normalization can consume characters (whitespace, punctuation) beyond
|
||
// the matched term, leaving no headword whose source equals the consumed
|
||
// text. Retry with shorter windows so a valid prefix term (e.g. a
|
||
// character name before a paren) still tokenizes instead of the position
|
||
// being skipped.
|
||
// Every window at or above the consumed length repeats the same result,
|
||
// so the next informative window sits just below it. A lookup that
|
||
// consumed its whole window reports nothing to aim at, and the step down
|
||
// from it is a blind guess: only those are budgeted.
|
||
// The window can run past the end of the line, so blindness is judged
|
||
// against the text the lookup actually saw.
|
||
// Set when a position stopped short of windows an uncapped ladder would
|
||
// still have tried; the line then escalates to parseText at the end.
|
||
let blindRetryBudgetExhausted = false;
|
||
async function resolveTokenAt(position, windowLength) {
|
||
let attempt = await findTokenAt(position, windowLength);
|
||
const scannedLength = Math.min(windowLength, text.length - position);
|
||
let retryLength = Math.min(attempt.matchedLength, scannedLength) - 1;
|
||
let stepIsBlind = attempt.matchedLength >= scannedLength;
|
||
let blindRetriesRemaining = MAX_BLIND_SHRINKING_WINDOW_RETRIES;
|
||
while (!attempt.token && retryLength >= 1) {
|
||
if (stepIsBlind) {
|
||
if (blindRetriesRemaining <= 0) {
|
||
blindRetryBudgetExhausted = true;
|
||
break;
|
||
}
|
||
blindRetriesRemaining -= 1;
|
||
}
|
||
const retry = await findTokenAt(position, retryLength);
|
||
if (retry.token) { return retry; }
|
||
const guidedLength = retry.matchedLength - 1;
|
||
stepIsBlind = guidedLength >= retryLength - 1;
|
||
retryLength = Math.min(retryLength - 1, guidedLength);
|
||
}
|
||
return attempt;
|
||
}
|
||
async function findTokenAt(position, windowLength) {
|
||
const codePoint = text.codePointAt(position);
|
||
const character = String.fromCodePoint(codePoint);
|
||
const result = await termsFindAt(position, windowLength);
|
||
const dictionaryEntries = Array.isArray(result?.dictionaryEntries) ? result.dictionaryEntries : [];
|
||
const originalTextLength = typeof result?.originalTextLength === "number" ? result.originalTextLength : 0;
|
||
if (dictionaryEntries.length === 0 || originalTextLength <= 0 || (originalTextLength === character.length && !isCodePointJapanese(codePoint))) {
|
||
return { token: null, matchedLength: 0 };
|
||
}
|
||
const source = text.substring(position, position + originalTextLength);
|
||
const preferredHeadword = getPreferredHeadword(
|
||
dictionaryEntries,
|
||
source,
|
||
dictionaryPriorityByName,
|
||
dictionaryFrequencyModeByName
|
||
);
|
||
if (!preferredHeadword || typeof preferredHeadword.term !== "string") {
|
||
return { token: null, matchedLength: originalTextLength };
|
||
}
|
||
return { token: buildScanToken(position, source, preferredHeadword), matchedLength: originalTextLength };
|
||
}
|
||
// Halfwidth katakana survives kana normalization unchanged, so a name
|
||
// written that way would not prefix-match a candidate form. Those
|
||
// positions bypass the prefilter rather than risk a missed name.
|
||
function isHalfwidthKatakanaCodePoint(codePoint) {
|
||
return codePoint >= 0xff66 && codePoint <= 0xff9f;
|
||
}
|
||
// Build (once per candidate list) a first-character bucket index of the
|
||
// normalized name forms, so the pre-pass can reject a position with a
|
||
// single map hit instead of a backend round trip.
|
||
if (rawNameCandidates && nameCandidateIndex?.key !== rawNameCandidates.key) {
|
||
const byFirstChar = new Map();
|
||
for (const form of rawNameCandidates.forms) {
|
||
const normalized = typeof form === "string" ? convertKatakanaToHiragana(form.trim()) : "";
|
||
if (!normalized) { continue; }
|
||
const bucket = byFirstChar.get(normalized[0]);
|
||
if (bucket) { bucket.push(normalized); } else { byFirstChar.set(normalized[0], [normalized]); }
|
||
}
|
||
nameCandidateIndex = byFirstChar.size > 0 ? { key: rawNameCandidates.key, byFirstChar } : null;
|
||
} else if (!rawNameCandidates) {
|
||
nameCandidateIndex = null;
|
||
}
|
||
// Only meaningful when the installed list matches the media this scan is
|
||
// for; otherwise fall back to scanning every position.
|
||
const activeNameCandidateIndex =
|
||
nameCandidateKey !== null && nameCandidateIndex?.key === nameCandidateKey
|
||
? nameCandidateIndex
|
||
: null;
|
||
const normalizedText = activeNameCandidateIndex ? convertKatakanaToHiragana(text) : "";
|
||
// Yomitan collapses emphatic sequences before matching (すっっごーーい →
|
||
// すごい), so a stretched name still resolves to its entry. Skipping these
|
||
// characters keeps such spellings candidates; the filter only ever grows
|
||
// the probe set, so a false positive costs one lookup, never a name.
|
||
const EMPHATIC_SKIP_CHARS = new Set(["ぁ", "ぃ", "ぅ", "ぇ", "ぉ", "っ", "ゃ", "ゅ", "ょ", "ー"]);
|
||
function matchesCandidateFormAt(form, position) {
|
||
let textIndex = position;
|
||
for (let formIndex = 0; formIndex < form.length; formIndex += 1) {
|
||
while (
|
||
textIndex < normalizedText.length &&
|
||
normalizedText[textIndex] !== form[formIndex] &&
|
||
EMPHATIC_SKIP_CHARS.has(normalizedText[textIndex])
|
||
) {
|
||
textIndex += 1;
|
||
}
|
||
if (normalizedText[textIndex] !== form[formIndex]) { return false; }
|
||
textIndex += 1;
|
||
}
|
||
return true;
|
||
}
|
||
function couldNameStartAt(position, codePoint) {
|
||
if (!activeNameCandidateIndex) { return true; }
|
||
if (isHalfwidthKatakanaCodePoint(codePoint)) { return true; }
|
||
const bucket = activeNameCandidateIndex.byFirstChar.get(normalizedText[position]);
|
||
if (!bucket) { return false; }
|
||
for (const form of bucket) {
|
||
if (matchesCandidateFormAt(form, position)) { return true; }
|
||
}
|
||
return false;
|
||
}
|
||
// Greedy name pre-pass: character-name matches claim their spans before
|
||
// the left-to-right walk, so a longer generic match starting earlier
|
||
// (e.g. とヨー → 渡洋) cannot swallow the start of a name (ヨータ).
|
||
const nameTokens = [];
|
||
if (greedyNameScanEnabled) {
|
||
let namePos = 0;
|
||
while (namePos < text.length) {
|
||
const codePoint = text.codePointAt(namePos);
|
||
if (!isCodePointJapanese(codePoint) || !couldNameStartAt(namePos, codePoint)) {
|
||
namePos += String.fromCodePoint(codePoint).length;
|
||
continue;
|
||
}
|
||
const result = await termsFindAt(namePos, scanLength);
|
||
const dictionaryEntries = Array.isArray(result?.dictionaryEntries) ? result.dictionaryEntries : [];
|
||
const textWindow = text.substring(namePos, namePos + scanLength);
|
||
const nameMatch = findLongestNameMatch(dictionaryEntries, textWindow);
|
||
// A name only claims its span when no strictly longer generic word
|
||
// starts at the same position (a character named 空 must not split
|
||
// 空気). Ties go to the name. Generic matches that start earlier and
|
||
// overlap the name are still blocked by the reservation.
|
||
if (
|
||
!nameMatch ||
|
||
findLongestGenericMatchLength(dictionaryEntries, textWindow) > nameMatch.sourceLength
|
||
) {
|
||
namePos += String.fromCodePoint(codePoint).length;
|
||
continue;
|
||
}
|
||
const source = text.substring(namePos, namePos + nameMatch.sourceLength);
|
||
nameTokens.push(buildScanToken(namePos, source, {
|
||
term: nameMatch.headword.term,
|
||
reading: nameMatch.headword.reading,
|
||
wordClasses: normalizeWordClasses(nameMatch.headword),
|
||
isNameMatch: true,
|
||
frequencyRank: getBestFrequencyRank(
|
||
nameMatch.dictionaryEntry,
|
||
nameMatch.headwordIndex,
|
||
dictionaryPriorityByName,
|
||
dictionaryFrequencyModeByName
|
||
)
|
||
}));
|
||
namePos += nameMatch.sourceLength;
|
||
}
|
||
}
|
||
// First reserved name span that a match ending at endPos would leave
|
||
// half-consumed. Spans the match covers entirely are not returned: those
|
||
// lose to the longer word instead of splitting it.
|
||
function findSplitNameToken(startIndex, endPos) {
|
||
for (let index = startIndex; index < nameTokens.length; index += 1) {
|
||
const nameToken = nameTokens[index];
|
||
if (nameToken.startPos >= endPos) { return null; }
|
||
if (nameToken.endPos > endPos) { return nameToken; }
|
||
}
|
||
return null;
|
||
}
|
||
let i = 0;
|
||
let nameIndex = 0;
|
||
let unparsedRunStart = null;
|
||
while (i < text.length) {
|
||
while (nameIndex < nameTokens.length && nameTokens[nameIndex].startPos < i) { nameIndex += 1; }
|
||
const nextNameToken = nameIndex < nameTokens.length ? nameTokens[nameIndex] : null;
|
||
if (nextNameToken && nextNameToken.startPos === i) {
|
||
flushUnparsedRun(unparsedRunStart, i);
|
||
unparsedRunStart = null;
|
||
tokens.push(nextNameToken);
|
||
i = nextNameToken.endPos;
|
||
nameIndex += 1;
|
||
continue;
|
||
}
|
||
const codePoint = text.codePointAt(i);
|
||
// Punctuation and whitespace can never start a token: skip the backend
|
||
// round trip entirely. Latin letters and digits stay lookup-worthy
|
||
// (terms like Tシャツ start on an ASCII letter).
|
||
if (!isLookupWorthyCodePoint(codePoint)) {
|
||
if (unparsedRunStart === null) { unparsedRunStart = i; }
|
||
i += String.fromCodePoint(codePoint).length;
|
||
continue;
|
||
}
|
||
// A reservation only outranks generic matches that would cut into it.
|
||
// Look the position up unrestricted first: a generic word that starts
|
||
// earlier and covers the whole name span (写真 over a character named
|
||
// 真) is the better reading, so the reservation yields rather than
|
||
// splitting the word. Only a match that ends inside a name span gets
|
||
// re-run against a window capped at that span.
|
||
let attempt = await resolveTokenAt(i, scanLength);
|
||
if (attempt.token) {
|
||
const splitNameToken = findSplitNameToken(nameIndex, attempt.token.endPos);
|
||
if (splitNameToken) {
|
||
attempt = await resolveTokenAt(i, splitNameToken.startPos - i);
|
||
}
|
||
}
|
||
if (attempt.token) {
|
||
flushUnparsedRun(unparsedRunStart, i);
|
||
unparsedRunStart = null;
|
||
tokens.push(attempt.token);
|
||
i += attempt.matchedLength;
|
||
continue;
|
||
}
|
||
if (unparsedRunStart === null) { unparsedRunStart = i; }
|
||
i += String.fromCodePoint(text.codePointAt(i)).length;
|
||
}
|
||
flushUnparsedRun(unparsedRunStart, text.length);
|
||
if (blindRetryBudgetExhausted) {
|
||
// A position gave up with shorter windows still worth trying. The walk
|
||
// is the only tokenizer now, so stopping there would leave a real term
|
||
// as an unparsed run; report it so the host can spend one parseText on
|
||
// the line instead of letting the ladder run to O(scanLength) lookups.
|
||
return { tokens, retryBudgetExhausted: true };
|
||
}
|
||
return tokens;
|
||
};
|
||
return true;
|
||
})();
|
||
`;
|
||
|
||
// Installs (or clears) the character-name candidate forms for the current
|
||
// media. Runs only when the list changes, not per line. Passing null restores
|
||
// the exhaustive every-position pre-pass.
|
||
export function buildYomitanScanNameCandidatesScript(
|
||
nameCandidates: { key: string; forms: string[] } | null,
|
||
): string {
|
||
if (!nameCandidates) {
|
||
return `
|
||
(() => {
|
||
if (typeof globalThis.__subminerYomitanScanSetNameCandidates !== "function") {
|
||
return false;
|
||
}
|
||
return globalThis.__subminerYomitanScanSetNameCandidates(null, null);
|
||
})();
|
||
`;
|
||
}
|
||
|
||
return `
|
||
(() => {
|
||
if (typeof globalThis.__subminerYomitanScanSetNameCandidates !== "function") {
|
||
return false;
|
||
}
|
||
return globalThis.__subminerYomitanScanSetNameCandidates(
|
||
${JSON.stringify(nameCandidates.key)},
|
||
${JSON.stringify(nameCandidates.forms)}
|
||
);
|
||
})();
|
||
`;
|
||
}
|
||
|
||
export function buildYomitanScanCallScript(params: YomitanScanRequestParams): string {
|
||
return `
|
||
(async () => {
|
||
if (typeof globalThis.__subminerYomitanScan !== "function") {
|
||
return ${JSON.stringify(YOMITAN_SCAN_RUNTIME_MISSING_SENTINEL)};
|
||
}
|
||
return await globalThis.__subminerYomitanScan(${JSON.stringify(params)});
|
||
})();
|
||
`;
|
||
}
|