mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-05 19:21:35 -07:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
c305bf34c2
|
|||
|
64534299ed
|
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: subtitles
|
||||||
|
|
||||||
|
- Heavily typeset ASS scripts (karaoke OP/ED, sign work) no longer fill the subtitle sidebar with garbage. Vector drawing runs (`\p1` … `\p0`) are no longer shown as subtitle text (e.g. `m 20 0 b 10 0 0 10 0 20 …`), and duplicate events in a parsed subtitle file collapse into one cue: identical text over an identical span (layered "shadow" copies), and per-frame animation bursts. An ASS burst has to prove itself with authoring evidence — a temporal tag (`\t`, `\move`, karaoke timing), an animated `Effect` column, or override values that change from event to event — plus one shared style and actor, so three rapid `えっ` reactions from three characters, or a sign repeated with the same static `\clip`, stay separate. SRT and VTT carry no such metadata, so there the run has to be at least five contiguous events all shorter than 0.1s, which is where ASS-to-SRT conversion leaves karaoke frames.
|
||||||
|
- Subtitle text is now decoded from ASS exactly once, where it enters the app, and matches how mpv renders the same line (including `\N`, `\n`, `\h`, unclosed `{`, and the fact that `\{` is not an escape). Renderer, timing tracker, tokenizer and the tokenization cache take that decoded text as-is instead of each re-deriving it, so one authored line can no longer produce two different cache keys, and a cue that normalizes to nothing is no longer stored as subtitle text or cached under an empty key.
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
type: changed
|
|
||||||
area: subtitles
|
|
||||||
|
|
||||||
- Subtitle tokenization no longer runs a duplicate full `parseText` pass per line: the termsFind scanner walk is now the only tokenizer and emits its own hoverable filler runs for unmatched text (parseText is kept only as an error fallback). This roughly halves the dictionary work per line.
|
|
||||||
- The Yomitan scanning helpers are now installed once per parser window (`__subminerYomitanScan`) instead of re-shipping and re-parsing a ~500-line script for every subtitle line; each line only evaluates a tiny call.
|
|
||||||
- termsFind lookups are cached across subtitle lines in a window-persistent LRU keyed by substring, so repeated particles and verb forms stop costing backend round trips. The cache invalidates on dictionary/settings changes and window reloads.
|
|
||||||
- The scanner walk now skips lookups at punctuation and whitespace positions (latin letters and digits still look up, e.g. Tシャツ). The shrinking-window retry ladder keeps following the consumed lengths the backend reports, and only blind guesses (windows the backend consumed whole, which tell it nothing) are capped at four per position. A line that hits that cap escalates to a single `parseText` for the whole line, so a hard line still resolves to dictionary tokens instead of an unparsed run, without letting the ladder run to one lookup per window length.
|
|
||||||
- Tokenizer runtime dependencies are built once instead of per line, fixing a JLPT lookup cache that never hit (it was keyed on a per-call closure identity and leaked a Map per line) and a `which mecab` availability check that re-ran synchronously on every line when MeCab is absent.
|
|
||||||
- Subtitle changes no longer restart the prefetch run per line (which discarded in-flight tokenization work); prefetch now only pauses for the live line and restarts on real seeks, cache invalidation, or option changes. Prefetch also stays paused for the whole time the subtitle processing controller is working on the line, including the provisional raw emit that precedes tokenization, so it never competes with the on-screen line for the parser window. The pause is released when the controller reports it has settled, which also covers the lines that finish without an emit (a suppressed duplicate or a failed tokenization) and used to leave prefetching paused indefinitely.
|
|
||||||
- Added per-stage debug timings (`scanMs`, `mecabMs`, `frequencyMs`, `annotateMs`) to the subtitle tokenization pipeline log.
|
|
||||||
- Fixed a reading that stopped covering its surface when an unmatched kana run extended the preceding token (for example a trailing る on 待ち合わせ), which silently disabled the known-word reading fallback for those tokens.
|
|
||||||
- Subtitle prefetching no longer stays paused for the rest of a cue when the same subtitle text is reported twice and there is nothing to tokenize. This covers the startup and overlay priming paths as well as ordinary subtitle changes.
|
|
||||||
- Character name and image lookups are now refreshed centrally whenever a character dictionary sync changes its content, so a newly added name can no longer be skipped by a stale candidate list.
|
|
||||||
- A subtitle that was on screen when its annotations were invalidated (by mining a card, for example) is now re-annotated instead of staying plain for the rest of the line.
|
|
||||||
- Character name annotations no longer cost a dictionary lookup at every position in a line. The scanner now knows which name forms the current title's character dictionary actually contains and only checks where one can start, which removes the whole overhead of having the character dictionary enabled (measured: 21 lookups per line down to 10, the same as with it disabled). Titles with no cached character data keep the previous exhaustive scan, so a missing snapshot costs speed rather than a missing name.
|
|
||||||
- The cross-line termsFind cache is now bounded by the number of retained dictionary entries as well as by key count, so a run of lookups that each carry hundreds of entries with full glossaries cannot grow the parser window's memory without limit. The budget is re-checked when a lookup resolves, so a single oversized response is dropped rather than parked in the cache and reused.
|
|
||||||
- The unnamed-mob disambiguator filter (Girl A / Girl B) now only drops a single letter or digit split off a name, instead of every one-character term: a name that is genuinely one character keeps its terms whatever the script (𠮷, あ, 별 김, ア・ベ). The character dictionary and the scanner's name pre-pass also share one Han code-point table now, so a name the dictionary accepts is a name the scanner will look for.
|
|
||||||
- A character name written in halfwidth katakana takes part in the greedy name pre-pass again, so a longer generic word can no longer swallow the start of it, and it now carries a reading (it used to come out blank, which disables known-word matching and frequency lookups for the token). Voiced halfwidth kana compose properly, so ガク reads ガク rather than ガク, and kana normalization folds halfwidth throughout so those tokens compare equal to the same word written fullwidth.
|
|
||||||
- Dictionary-entry classification (source dictionaries, character-dictionary media ids) is memoized per entry object for as long as the entry is cached, instead of being recomputed for every headword comparison and every retry window.
|
|
||||||
- Autoplay priming no longer broadcasts the plain subtitle twice: it tells the processing controller the line has already been painted, so the controller goes straight to the annotated payload.
|
|
||||||
@@ -64,18 +64,23 @@ External subtitle files only (SRT, VTT, ASS). Embedded subtitle tracks are out o
|
|||||||
A cue parser extracts both timing and text content from subtitle files for prefetching.
|
A cue parser extracts both timing and text content from subtitle files for prefetching.
|
||||||
|
|
||||||
**Parsed cue structure:**
|
**Parsed cue structure:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface SubtitleCue {
|
interface SubtitleCue {
|
||||||
startTime: number; // seconds
|
startTime: number; // seconds
|
||||||
endTime: number; // seconds
|
endTime: number; // seconds
|
||||||
text: string; // raw subtitle text
|
text: string; // plain text, decoded from the source format
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Supported formats:**
|
**Supported formats:**
|
||||||
|
|
||||||
- SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks.
|
- SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks.
|
||||||
- ASS: Parse `[Events]` section, extract `Dialogue:` lines, split on the first 9 commas only (ASS v4+ has 10 fields; the last field is Text which can itself contain commas). Strip ASS override tags (`{\...}`) from the text before storing.
|
- ASS: Parse `[Events]` section, extract `Dialogue:` lines, read the field order from the `Format:` row, and take everything after the Text field index as the text (Text can itself contain commas).
|
||||||
ASS text fields contain inline override tags like `{\b1}`, `{\an8}`, `{\fad(200,300)}`. The cue parser strips these during extraction so the tokenizer receives clean text.
|
|
||||||
|
**ASS decoding.** The parser is where ASS text is decoded, once, via `assToPlainText()` in `src/core/services/ass-text.ts`. That decoder mirrors mpv's `ass_to_plaintext` so a cue read from a file reads identically to the same line arriving live on `sub-text`: `{...}` override blocks are markup, `\pN … \p0` vector drawing runs are dropped rather than shown as text, `\N`/`\n`/`\h` are the only escapes (`\{`, `\}` and `\\` are not), and an unclosed `{` is rendered verbatim. Every layer downstream — renderer, timing tracker, tokenizer, tokenization cache keys — receives plain text and uses `normalizePlainSubtitleText()` for whitespace only, so nothing decodes the same string twice and one authored line always maps to one cache key.
|
||||||
|
|
||||||
|
**Duplicate collapsing.** Typeset scripts emit one `Dialogue:` event per animation frame, plus layered copies of the same line. The parser collapses identical text over an identical span unconditionally, and collapses contiguous same-text runs of at least three events when the run looks like an animation. For ASS that means shared style and actor plus authoring evidence: a temporal tag (`\t`, `\move`, `\k`/`\kf`/`\ko`/`\K`, or anything wrapped in `\t(...)`), an animated `Effect` column (`Karaoke`, `Banner`, `Scroll`), or override values that change across the run. Static tags shared by every event (`\pos`, an identical `\clip`) are not evidence. SRT/VTT carry no such metadata, so there collapsing needs at least five contiguous events all under 0.1s — the frame timing left behind by ASS-to-SRT conversion. The parser keeps this authoring metadata (style, actor, layer, `Effect`, parsed override commands, source order) private; `parseSubtitleCues()` returns only `SubtitleCue`.
|
||||||
|
|
||||||
#### Prefetch Service Lifecycle
|
#### Prefetch Service Lifecycle
|
||||||
|
|
||||||
@@ -153,6 +158,7 @@ tokens (already have frequencyRank values from parser-level applyFrequencyRanks)
|
|||||||
### Dependency Analysis
|
### Dependency Analysis
|
||||||
|
|
||||||
All annotations either depend on MeCab POS data or benefit from running after it:
|
All annotations either depend on MeCab POS data or benefit from running after it:
|
||||||
|
|
||||||
- **Known word marking:** Needs base tokens (surface/headword). No POS dependency, but no reason to run separately.
|
- **Known word marking:** Needs base tokens (surface/headword). No POS dependency, but no reason to run separately.
|
||||||
- **Frequency filtering:** Uses `pos1Exclusions` and `pos2Exclusions` to clear frequency ranks on excluded tokens (particles, noise). Depends on MeCab POS data.
|
- **Frequency filtering:** Uses `pos1Exclusions` and `pos2Exclusions` to clear frequency ranks on excluded tokens (particles, noise). Depends on MeCab POS data.
|
||||||
- **JLPT marking:** Uses `shouldIgnoreJlptForMecabPos1` to filter. Depends on MeCab POS data.
|
- **JLPT marking:** Uses `shouldIgnoreJlptForMecabPos1` to filter. Depends on MeCab POS data.
|
||||||
@@ -169,18 +175,14 @@ function annotateTokens(tokens, deps, options): MergedToken[] {
|
|||||||
|
|
||||||
// Single pass: known word + frequency filtering + JLPT computed together
|
// Single pass: known word + frequency filtering + JLPT computed together
|
||||||
const annotated = tokens.map((token) => {
|
const annotated = tokens.map((token) => {
|
||||||
const isKnown = nPlusOneEnabled
|
const isKnown = nPlusOneEnabled ? token.isKnown || computeIsKnown(token, deps) : false;
|
||||||
? token.isKnown || computeIsKnown(token, deps)
|
|
||||||
: false;
|
|
||||||
|
|
||||||
// Filter frequency rank using POS exclusions (rank values already set at parser level)
|
// Filter frequency rank using POS exclusions (rank values already set at parser level)
|
||||||
const frequencyRank = frequencyEnabled
|
const frequencyRank = frequencyEnabled
|
||||||
? filterFrequencyRank(token, pos1Exclusions, pos2Exclusions)
|
? filterFrequencyRank(token, pos1Exclusions, pos2Exclusions)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const jlptLevel = jlptEnabled
|
const jlptLevel = jlptEnabled ? computeJlptLevel(token, deps.getJlptLevel) : undefined;
|
||||||
? computeJlptLevel(token, deps.getJlptLevel)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return { ...token, isKnown, frequencyRank, jlptLevel };
|
return { ...token, isKnown, frequencyRank, jlptLevel };
|
||||||
});
|
});
|
||||||
@@ -221,6 +223,7 @@ Replace `document.createElement('span')` calls in the renderer with `templateSpa
|
|||||||
### Current Behavior
|
### Current Behavior
|
||||||
|
|
||||||
In `renderWithTokens` (`subtitle-render.ts`), each render cycle:
|
In `renderWithTokens` (`subtitle-render.ts`), each render cycle:
|
||||||
|
|
||||||
1. Clears DOM with `innerHTML = ''`
|
1. Clears DOM with `innerHTML = ''`
|
||||||
2. Creates a `DocumentFragment`
|
2. Creates a `DocumentFragment`
|
||||||
3. Calls `document.createElement('span')` for each token (~10-15 per subtitle)
|
3. Calls `document.createElement('span')` for each token (~10-15 per subtitle)
|
||||||
@@ -257,7 +260,7 @@ Full recycling (collecting old nodes, clearing attributes, reusing them) require
|
|||||||
## Combined Impact Summary
|
## Combined Impact Summary
|
||||||
|
|
||||||
| Scenario | Before | After | Improvement |
|
| Scenario | Before | After | Improvement |
|
||||||
|----------|--------|-------|-------------|
|
| --------------------------------- | ---------- | ---------- | ----------- |
|
||||||
| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% |
|
| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% |
|
||||||
| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% |
|
| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% |
|
||||||
| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% |
|
| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% |
|
||||||
@@ -267,16 +270,19 @@ Full recycling (collecting old nodes, clearing attributes, reusing them) require
|
|||||||
## Files Summary
|
## Files Summary
|
||||||
|
|
||||||
### New Files
|
### New Files
|
||||||
|
|
||||||
- `src/core/services/subtitle-prefetch.ts`
|
- `src/core/services/subtitle-prefetch.ts`
|
||||||
- `src/core/services/subtitle-cue-parser.ts`
|
- `src/core/services/subtitle-cue-parser.ts`
|
||||||
|
|
||||||
### Modified Files
|
### Modified Files
|
||||||
|
|
||||||
- `src/core/services/subtitle-processing-controller.ts` (expose `preCacheTokenization`)
|
- `src/core/services/subtitle-processing-controller.ts` (expose `preCacheTokenization`)
|
||||||
- `src/core/services/tokenizer/annotation-stage.ts` (batched single-pass)
|
- `src/core/services/tokenizer/annotation-stage.ts` (batched single-pass)
|
||||||
- `src/renderer/subtitle-render.ts` (template cloneNode)
|
- `src/renderer/subtitle-render.ts` (template cloneNode)
|
||||||
- `src/main.ts` (wire up prefetch service)
|
- `src/main.ts` (wire up prefetch service)
|
||||||
|
|
||||||
### Test Files
|
### Test Files
|
||||||
|
|
||||||
- New tests for subtitle cue parser (SRT, VTT, ASS formats)
|
- New tests for subtitle cue parser (SRT, VTT, ASS formats)
|
||||||
- New tests for subtitle prefetch service (priority window, seek, pause/resume)
|
- New tests for subtitle prefetch service (priority window, seek, pause/resume)
|
||||||
- Updated tests for annotation stage (same behavior, new implementation)
|
- Updated tests for annotation stage (same behavior, new implementation)
|
||||||
|
|||||||
@@ -50,22 +50,10 @@ subtitles do not draw.
|
|||||||
7. Cache miss: call `refreshCurrentSubtitle(text)`. Normal processing emits a plain payload
|
7. Cache miss: call `refreshCurrentSubtitle(text)`. Normal processing emits a plain payload
|
||||||
synchronously, then replaces it with the tokenized payload when ready.
|
synchronously, then replaces it with the tokenized payload when ready.
|
||||||
|
|
||||||
Both `onSubtitleChange` and `refreshCurrentSubtitle` pause `subtitlePrefetchService` and then call
|
In `src/main.ts`, both `onSubtitleChange` and `refreshCurrentSubtitle` pause
|
||||||
the matching `subtitleProcessingController` method, giving the visible overlay priority over
|
`subtitlePrefetchService`, notify it with `onSeek(lastObservedTimePos)`, and then call the matching
|
||||||
background prefetch work. Prefetch is not re-centered here: restarting the run per line
|
`subtitleProcessingController` method. This gives the visible overlay priority over background
|
||||||
(`onSeek`) discarded the in-flight tokenization every time the subtitle changed, so only real
|
prefetch work and re-centers prefetch around the live playback time.
|
||||||
seeks restart it (see `onTimePosUpdate` in `src/main.ts`).
|
|
||||||
|
|
||||||
On an uncached autoplay prime the raw payload is emitted here and reported to the controller with
|
|
||||||
`notePlainSubtitleEmitted`, so the controller skips its own plain emit for that line and the
|
|
||||||
overlay receives one plain payload followed by the annotated one.
|
|
||||||
|
|
||||||
The pause is released by the controller's `onProcessingSettled` callback, which fires once it has
|
|
||||||
no work left. Emits do not release it: the first emit for an uncached line is the plain payload
|
|
||||||
that precedes tokenization, and a run can finish without emitting at all (a suppressed duplicate,
|
|
||||||
a failed tokenization). Both controller methods return whether processing is now pending, and the
|
|
||||||
caller resumes immediately when it is not — a repeated subtitle schedules no work, so no settle is
|
|
||||||
coming and prefetching would otherwise idle for the rest of the cue.
|
|
||||||
|
|
||||||
## Live Cue Delivery
|
## Live Cue Delivery
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
assOverrideSignature,
|
||||||
|
assToPlainText,
|
||||||
|
collectAssOverrideCommands,
|
||||||
|
extractAssOverrideBlocks,
|
||||||
|
hasAssTemporalOverride,
|
||||||
|
isAnimatedAssEffectKind,
|
||||||
|
isAssTemporalCommand,
|
||||||
|
normalizePlainSubtitleText,
|
||||||
|
parseAssEffectField,
|
||||||
|
} from './ass-text';
|
||||||
|
|
||||||
|
test('assToPlainText drops vector drawing runs', () => {
|
||||||
|
assert.equal(
|
||||||
|
assToPlainText(
|
||||||
|
'{\\an5\\pos(730,1042)\\p1\\blur1}m 20 0 b 10 0 0 10 0 20 b 0 31 10 40 20 40 {\\p0}',
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assToPlainText keeps text around drawing runs on the same event', () => {
|
||||||
|
assert.equal(
|
||||||
|
assToPlainText('{\\p1}m 0 0 l 10 10{\\p0}本文{\\p1}m 5 5 l 6 6{\\p0}続き'),
|
||||||
|
'本文続き',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assToPlainText leaves \\pos alone when no drawing mode is active', () => {
|
||||||
|
assert.equal(assToPlainText('{\\pos(960,1068)\\bord3}位置指定'), '位置指定');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assToPlainText does not read \\pos as a drawing tag', () => {
|
||||||
|
assert.equal(assToPlainText('{\\p1\\pos(1,2)}m 0 0 l 5 5'), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assToPlainText resolves line-break and space escapes', () => {
|
||||||
|
assert.equal(assToPlainText('一行目\\N二行目'), '一行目\n二行目');
|
||||||
|
assert.equal(assToPlainText('一行目\\n二行目'), '一行目\n二行目');
|
||||||
|
assert.equal(assToPlainText('一行目\\N二行目', ' '), '一行目 二行目');
|
||||||
|
assert.equal(assToPlainText('間\\h隔'), '間 隔');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assToPlainText matches mpv on brace and backslash sequences', () => {
|
||||||
|
// mpv has no `\{` / `\}` / `\\` escapes: the backslashes are literal text and the
|
||||||
|
// braces still open and close an override block.
|
||||||
|
assert.equal(assToPlainText('\\{注\\}'), '\\');
|
||||||
|
assert.equal(assToPlainText('\\\\N'), '\\\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assToPlainText renders an unclosed override block verbatim', () => {
|
||||||
|
// mpv shows the stray brace; guessing where the block ended can eat a whole line.
|
||||||
|
assert.equal(assToPlainText('本文{\\pos(1,2)'), '本文{\\pos(1,2)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assToPlainText is idempotent', () => {
|
||||||
|
const samples = [
|
||||||
|
'{\\an5\\p1}m 0 0 l 5 5{\\p0}本文',
|
||||||
|
'\\{注\\}',
|
||||||
|
'\\\\N',
|
||||||
|
'本文{\\pos(1,2)',
|
||||||
|
'一行目\\N二行目\\h終わり',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const sample of samples) {
|
||||||
|
const once = assToPlainText(sample);
|
||||||
|
assert.equal(assToPlainText(once), once, sample);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assToPlainText normalizes CRLF before converting', () => {
|
||||||
|
assert.equal(assToPlainText('一行目\r\n二行目'), '一行目\n二行目');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizePlainSubtitleText settles whitespace without decoding ASS', () => {
|
||||||
|
// A brace reaching this layer is literal text mpv chose to show, not markup.
|
||||||
|
assert.equal(normalizePlainSubtitleText('本文{\\pos(1,2)'), '本文{\\pos(1,2)');
|
||||||
|
assert.equal(normalizePlainSubtitleText('一行目\\N二行目'), '一行目\n二行目');
|
||||||
|
assert.equal(
|
||||||
|
normalizePlainSubtitleText('一行目\\N二行目', { collapseLineBreaks: true }),
|
||||||
|
'一行目 二行目',
|
||||||
|
);
|
||||||
|
assert.equal(normalizePlainSubtitleText(' 余白 ', { trim: false }), ' 余白 ');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizePlainSubtitleText is idempotent', () => {
|
||||||
|
for (const sample of ['一行目\\N二行目', '間\\h隔', '本文{\\pos(1,2)', ' 余白 ']) {
|
||||||
|
const once = normalizePlainSubtitleText(sample);
|
||||||
|
assert.equal(normalizePlainSubtitleText(once), once, sample);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractAssOverrideBlocks returns block contents', () => {
|
||||||
|
assert.deepEqual(extractAssOverrideBlocks('{\\an8}上{\\fad(200,200)}下'), [
|
||||||
|
'\\an8',
|
||||||
|
'\\fad(200,200)',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(extractAssOverrideBlocks('括弧なし'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectAssOverrideCommands captures names and arguments from blocks only', () => {
|
||||||
|
const commands = collectAssOverrideCommands('{\\pos(1,2)\\1c&HFFFFFF&\\kf30}歌詞');
|
||||||
|
|
||||||
|
assert.deepEqual(commands, [
|
||||||
|
{ name: 'pos', args: '1,2', animated: false },
|
||||||
|
{ name: '1c', args: '&HFFFFFF&', animated: false },
|
||||||
|
{ name: 'kf', args: '30', animated: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// A `\pos(...)` sitting in visible text is not typesetting markup.
|
||||||
|
assert.deepEqual(collectAssOverrideCommands('\\pos(730,1042) と書いてある'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectAssOverrideCommands marks tags animated by a wrapping \\t', () => {
|
||||||
|
const commands = collectAssOverrideCommands('{\\clip(0,0,10,10)\\t(0,500,\\frz30)}文字');
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
commands.map((command) => [command.name, command.animated]),
|
||||||
|
[
|
||||||
|
['clip', false],
|
||||||
|
['t', false],
|
||||||
|
['frz', true],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.equal(hasAssTemporalOverride(commands), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectAssOverrideCommands survives pathologically nested \\t tags', () => {
|
||||||
|
const depth = 200000;
|
||||||
|
const block = `{${'\\t(0,500,'.repeat(depth)}\\frz30${')'.repeat(depth)}}文字`;
|
||||||
|
|
||||||
|
const commands = collectAssOverrideCommands(block);
|
||||||
|
|
||||||
|
// Recursion stops at the nesting cap; the outer tags are still reported, and nothing
|
||||||
|
// blows the call stack.
|
||||||
|
assert.equal(commands[0]!.name, 't');
|
||||||
|
assert.equal(hasAssTemporalOverride(commands), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasAssTemporalOverride ignores static placement and shape tags', () => {
|
||||||
|
assert.equal(
|
||||||
|
hasAssTemporalOverride(collectAssOverrideCommands('{\\pos(1,2)\\clip(m 1 1)\\blur2}文字')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(hasAssTemporalOverride(collectAssOverrideCommands('{\\move(1,2,3,4)}文字')), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isAssTemporalCommand covers only intrinsically animated tags', () => {
|
||||||
|
for (const command of ['t', 'move', 'k', 'kf', 'ko', 'K']) {
|
||||||
|
assert.equal(isAssTemporalCommand(command), true, command);
|
||||||
|
}
|
||||||
|
for (const command of ['clip', 'iclip', 'frz', 'fscx', 'blur', 'be', 'pos', 'fad']) {
|
||||||
|
assert.equal(isAssTemporalCommand(command), false, command);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assOverrideSignature distinguishes events by their override values', () => {
|
||||||
|
const first = assOverrideSignature(collectAssOverrideCommands('{\\clip(m 1 1)}歌詞'));
|
||||||
|
const second = assOverrideSignature(collectAssOverrideCommands('{\\clip(m 2 2)}歌詞'));
|
||||||
|
const repeat = assOverrideSignature(collectAssOverrideCommands('{\\clip(m 1 1)}別の行'));
|
||||||
|
|
||||||
|
assert.notEqual(first, second);
|
||||||
|
assert.equal(first, repeat);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseAssEffectField classifies the event-level Effect column', () => {
|
||||||
|
assert.equal(parseAssEffectField(''), 'none');
|
||||||
|
assert.equal(parseAssEffectField(' '), 'none');
|
||||||
|
assert.equal(parseAssEffectField('Banner;20;1;0'), 'banner');
|
||||||
|
assert.equal(parseAssEffectField('Scroll up;0;0;30;10'), 'scroll');
|
||||||
|
assert.equal(parseAssEffectField('Scroll down;0;0;30;10'), 'scroll');
|
||||||
|
assert.equal(parseAssEffectField('Karaoke'), 'karaoke');
|
||||||
|
assert.equal(parseAssEffectField('fx-template'), 'other');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseAssEffectField matches stock effect names exactly', () => {
|
||||||
|
// Custom effect names that merely start with a stock name are not stock effects.
|
||||||
|
assert.equal(parseAssEffectField('scrolling-credit'), 'other');
|
||||||
|
assert.equal(parseAssEffectField('bannerfx;1'), 'other');
|
||||||
|
assert.equal(parseAssEffectField('karaoke-template'), 'other');
|
||||||
|
assert.equal(parseAssEffectField('Scroll'), 'other');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isAnimatedAssEffectKind covers the stock animated effects only', () => {
|
||||||
|
assert.equal(isAnimatedAssEffectKind('karaoke'), true);
|
||||||
|
assert.equal(isAnimatedAssEffectKind('banner'), true);
|
||||||
|
assert.equal(isAnimatedAssEffectKind('scroll'), true);
|
||||||
|
// Typesetting groups put static template names in this column too.
|
||||||
|
assert.equal(isAnimatedAssEffectKind('other'), false);
|
||||||
|
assert.equal(isAnimatedAssEffectKind('none'), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
/*
|
||||||
|
* ASS/SSA text handling, split into two deliberately distinct contracts:
|
||||||
|
*
|
||||||
|
* assToPlainText() raw ASS event text -> plain text. Ingestion only.
|
||||||
|
* normalizePlainSubtitleText() already-decoded text -> display/lookup form.
|
||||||
|
*
|
||||||
|
* Subtitle text is decoded from ASS exactly once, at the point it enters the app: the
|
||||||
|
* file cue parser does it for sidecar/embedded scripts, and mpv does it for live text
|
||||||
|
* (`sub-text` is already run through mpv's own `ass_to_plaintext`). Everything
|
||||||
|
* downstream -- renderer, timing tracker, tokenizer, tokenization cache keys -- gets
|
||||||
|
* plain text and only normalizes whitespace, so no layer decodes the same string twice.
|
||||||
|
*
|
||||||
|
* assToPlainText mirrors mpv's `ass_to_plaintext` rather than inventing its own rules,
|
||||||
|
* so a cue parsed from a file reads the same as the same line arriving live:
|
||||||
|
* - `{...}` override blocks are markup
|
||||||
|
* - `\pN ... \p0` runs are vector paths, not dialogue
|
||||||
|
* - `\N`, `\n` and `\h` are the only escapes; `\{`, `\}` and `\\` are NOT escapes,
|
||||||
|
* so `\{注\}` decodes to a lone backslash exactly as mpv renders it
|
||||||
|
* - an unclosed `{` is rendered verbatim instead of swallowing the rest of the line
|
||||||
|
* Because the decoder never emits an escape or a closed brace, running it twice is a
|
||||||
|
* no-op -- but downstream code should still use normalizePlainSubtitleText.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** What `\N` and `\n` become. */
|
||||||
|
export type AssLineBreak = '\n' | ' ';
|
||||||
|
|
||||||
|
// `\p<n>` with n > 0 switches libass into vector-drawing mode: everything until the
|
||||||
|
// next `\p0` is a path (`m 20 0 b 10 0 ...`), not dialogue. The negative lookahead keeps
|
||||||
|
// `\pos(...)` from being read as a drawing tag.
|
||||||
|
const ASS_DRAWING_SCALE_PATTERN = /\\p(?![a-zA-Z])(\d*)/g;
|
||||||
|
|
||||||
|
function readDrawingScale(block: string): number | null {
|
||||||
|
ASS_DRAWING_SCALE_PATTERN.lastIndex = 0;
|
||||||
|
let scale: number | null = null;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
// Drawing mode is whatever the last `\p` tag in this block set it to.
|
||||||
|
while ((match = ASS_DRAWING_SCALE_PATTERN.exec(block)) !== null) {
|
||||||
|
scale = match[1] ? Number(match[1]) : 0;
|
||||||
|
}
|
||||||
|
return scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve `\N`, `\n` and `\h`. The only text-level escapes libass recognises. */
|
||||||
|
function resolveWhitespaceEscapes(text: string, lineBreak: AssLineBreak): string {
|
||||||
|
return text.replace(/\\([Nnh])/g, (_match, escaped: string) =>
|
||||||
|
escaped === 'h' ? ' ' : lineBreak,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip `{...}` override blocks and the drawing runs they enable. */
|
||||||
|
function stripAssMarkup(raw: string): string {
|
||||||
|
let out = '';
|
||||||
|
let cursor = 0;
|
||||||
|
let drawing = false;
|
||||||
|
|
||||||
|
while (cursor < raw.length) {
|
||||||
|
if (raw[cursor] !== '{') {
|
||||||
|
if (!drawing) {
|
||||||
|
out += raw[cursor];
|
||||||
|
}
|
||||||
|
cursor += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = raw.indexOf('}', cursor + 1);
|
||||||
|
if (close === -1) {
|
||||||
|
// mpv shows an unclosed `{` and everything after it. Guessing where the block was
|
||||||
|
// meant to end can eat a whole line of dialogue.
|
||||||
|
if (!drawing) {
|
||||||
|
out += raw.slice(cursor);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = readDrawingScale(raw.slice(cursor, close + 1));
|
||||||
|
if (scale !== null) {
|
||||||
|
drawing = scale > 0;
|
||||||
|
}
|
||||||
|
cursor = close + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a raw ASS/SSA event text field. Call this once, where the text enters the app;
|
||||||
|
* downstream layers take the result as plain text.
|
||||||
|
*/
|
||||||
|
export function assToPlainText(text: string, lineBreak: AssLineBreak = '\n'): string {
|
||||||
|
if (!text) return '';
|
||||||
|
return resolveWhitespaceEscapes(stripAssMarkup(text.replace(/\r\n/g, '\n')), lineBreak);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizePlainSubtitleTextOptions {
|
||||||
|
/** Fold every line break into a single space. */
|
||||||
|
collapseLineBreaks?: boolean;
|
||||||
|
trim?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whitespace normalization for text that has already been decoded -- by mpv for live
|
||||||
|
* subtitles, by the cue parser for files. Override blocks and drawing runs are none of
|
||||||
|
* this function's business; a `{` that reaches here is literal text mpv chose to show.
|
||||||
|
*
|
||||||
|
* `\N`/`\n`/`\h` are still folded, because subtitle sources outside the ASS path (asbplayer
|
||||||
|
* and other websocket clients) forward them raw and the display layer has to cope.
|
||||||
|
*/
|
||||||
|
export function normalizePlainSubtitleText(
|
||||||
|
text: string,
|
||||||
|
options: NormalizePlainSubtitleTextOptions = {},
|
||||||
|
): string {
|
||||||
|
if (!text) return '';
|
||||||
|
const { collapseLineBreaks = false, trim = true } = options;
|
||||||
|
|
||||||
|
let normalized = resolveWhitespaceEscapes(
|
||||||
|
text.replace(/\r\n/g, '\n'),
|
||||||
|
collapseLineBreaks ? ' ' : '\n',
|
||||||
|
);
|
||||||
|
if (collapseLineBreaks) {
|
||||||
|
normalized = normalized.replace(/\n/g, ' ').replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim ? normalized.trim() : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The contents of each `{...}` block, without the braces. */
|
||||||
|
export function extractAssOverrideBlocks(text: string): string[] {
|
||||||
|
const blocks: string[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
while (cursor < text.length) {
|
||||||
|
const open = text.indexOf('{', cursor);
|
||||||
|
if (open === -1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const close = text.indexOf('}', open + 1);
|
||||||
|
if (close === -1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
blocks.push(text.slice(open + 1, close));
|
||||||
|
cursor = close + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssOverrideCommand {
|
||||||
|
/** Tag name without the backslash, e.g. `pos`, `kf`, `1c`. */
|
||||||
|
name: string;
|
||||||
|
/** Everything the tag was given, e.g. `960,1068` for `\pos(960,1068)`. */
|
||||||
|
args: string;
|
||||||
|
/** Nested inside a `\t(...)` argument, so its value is animated over the event. */
|
||||||
|
animated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ASS_OVERRIDE_NAME_PATTERN = /[1-4]?[a-zA-Z]+/y;
|
||||||
|
|
||||||
|
function readCommandArgs(block: string, start: number): { args: string; next: number } {
|
||||||
|
if (block[start] === '(') {
|
||||||
|
let depth = 0;
|
||||||
|
for (let i = start; i < block.length; i += 1) {
|
||||||
|
if (block[i] === '(') depth += 1;
|
||||||
|
else if (block[i] === ')') {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) {
|
||||||
|
return { args: block.slice(start + 1, i), next: i + 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { args: block.slice(start + 1), next: block.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTag = block.indexOf('\\', start);
|
||||||
|
const end = nextTag === -1 ? block.length : nextTag;
|
||||||
|
return { args: block.slice(start, end), next: end };
|
||||||
|
}
|
||||||
|
|
||||||
|
// `\t(...)` can wrap another `\t(...)`, and nothing in the format stops an author (or a
|
||||||
|
// malformed file) from nesting them thousands deep. Real typesetting never goes past one
|
||||||
|
// or two levels, so stop recursing well before the call stack is at risk.
|
||||||
|
const MAX_ANIMATION_NESTING_DEPTH = 8;
|
||||||
|
|
||||||
|
function parseOverrideBlock(
|
||||||
|
block: string,
|
||||||
|
animated: boolean,
|
||||||
|
into: AssOverrideCommand[],
|
||||||
|
depth = 0,
|
||||||
|
): void {
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
while (cursor < block.length) {
|
||||||
|
if (block[cursor] !== '\\') {
|
||||||
|
cursor += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ASS_OVERRIDE_NAME_PATTERN.lastIndex = cursor + 1;
|
||||||
|
const nameMatch = ASS_OVERRIDE_NAME_PATTERN.exec(block);
|
||||||
|
if (!nameMatch) {
|
||||||
|
cursor += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = nameMatch[0];
|
||||||
|
const { args, next } = readCommandArgs(block, cursor + 1 + name.length);
|
||||||
|
into.push({ name, args: args.trim(), animated });
|
||||||
|
// `\t(0,500,\frz30)` animates whatever it wraps, so record the inner tags too.
|
||||||
|
if (name === 't' && args.includes('\\') && depth < MAX_ANIMATION_NESTING_DEPTH) {
|
||||||
|
parseOverrideBlock(args, true, into, depth + 1);
|
||||||
|
}
|
||||||
|
cursor = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override commands with their arguments, in source order. Only `{...}` blocks are
|
||||||
|
* inspected, so a `\pos(...)` sitting in visible text is never mistaken for markup.
|
||||||
|
*/
|
||||||
|
export function collectAssOverrideCommands(text: string): AssOverrideCommand[] {
|
||||||
|
const commands: AssOverrideCommand[] = [];
|
||||||
|
for (const block of extractAssOverrideBlocks(text)) {
|
||||||
|
parseOverrideBlock(block, false, commands);
|
||||||
|
}
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags that are animated by definition: `\t` interpolates, `\move` travels, and the
|
||||||
|
// karaoke tags advance a highlight across the event's own duration. Everything else --
|
||||||
|
// `\pos`, `\clip`, `\frz`, `\blur`, `\fad` -- is a static value for the event, so its
|
||||||
|
// presence says nothing about whether neighbouring events form one animation.
|
||||||
|
const ASS_TEMPORAL_COMMANDS = new Set(['t', 'move', 'k', 'kf', 'ko', 'K']);
|
||||||
|
|
||||||
|
export function isAssTemporalCommand(name: string): boolean {
|
||||||
|
return ASS_TEMPORAL_COMMANDS.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the event animates on its own, or animates a static tag through `\t(...)`. */
|
||||||
|
export function hasAssTemporalOverride(commands: readonly AssOverrideCommand[]): boolean {
|
||||||
|
return commands.some((command) => command.animated || isAssTemporalCommand(command.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical form of an event's override values, for comparing consecutive events. Two
|
||||||
|
* events with the same signature were typeset identically, so neither is a frame of an
|
||||||
|
* animation the other belongs to.
|
||||||
|
*/
|
||||||
|
export function assOverrideSignature(commands: readonly AssOverrideCommand[]): string {
|
||||||
|
return commands.map((command) => `${command.name}(${command.args})`).join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AssEffectKind = 'none' | 'banner' | 'scroll' | 'karaoke' | 'other';
|
||||||
|
|
||||||
|
// The stock effects, matched exactly. Typesetting groups put their own template names in
|
||||||
|
// this column -- `scrolling-credit` is a static sign, not libass's `Scroll up` -- so a
|
||||||
|
// prefix match would hand out animation evidence to arbitrary custom effects.
|
||||||
|
const STOCK_ASS_EFFECTS = new Map<string, AssEffectKind>([
|
||||||
|
['banner', 'banner'],
|
||||||
|
['scroll up', 'scroll'],
|
||||||
|
['scroll down', 'scroll'],
|
||||||
|
['karaoke', 'karaoke'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event-level `Effect` column. The stock values (`Banner;...`, `Scroll up;...`,
|
||||||
|
* `Scroll down;...`, `Karaoke`) all animate; anything else is a custom name and lands in
|
||||||
|
* `other`.
|
||||||
|
*/
|
||||||
|
export function parseAssEffectField(raw: string): AssEffectKind {
|
||||||
|
const value = raw.trim().toLowerCase();
|
||||||
|
if (!value) return 'none';
|
||||||
|
|
||||||
|
const name = value.split(';', 1)[0]!.trim();
|
||||||
|
return STOCK_ASS_EFFECTS.get(name) ?? 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANIMATED_ASS_EFFECT_KINDS = new Set<AssEffectKind>(['banner', 'scroll', 'karaoke']);
|
||||||
|
|
||||||
|
export function isAnimatedAssEffectKind(kind: AssEffectKind): boolean {
|
||||||
|
return ANIMATED_ASS_EFFECT_KINDS.has(kind);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/*
|
||||||
|
* Duplicate/animation-burst collapsing for parsed subtitle cues.
|
||||||
|
*
|
||||||
|
* Split out of the cue parser so the parsing rules and the "is this run one animation?"
|
||||||
|
* heuristics can be read -- and tested -- on their own. The parser owns the cue shape;
|
||||||
|
* this module only decides which cues survive.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { hasAssTemporalOverride, isAnimatedAssEffectKind } from './ass-text';
|
||||||
|
import type {
|
||||||
|
AnnotatedSubtitleCue,
|
||||||
|
SubtitleCue,
|
||||||
|
SubtitleSourceFormat,
|
||||||
|
} from './subtitle-cue-parser';
|
||||||
|
|
||||||
|
// Back-to-back frames of the same animation are authored flush against each other; a
|
||||||
|
// tiny tolerance absorbs the centisecond rounding of the ASS timestamp format.
|
||||||
|
const DUPLICATE_CUE_GAP_TOLERANCE_SECONDS = 0.05;
|
||||||
|
// A burst is a *sequence*. Two adjacent events are two events, not an animation --
|
||||||
|
// characters do repeat each other, and a repeated line can legitimately be short.
|
||||||
|
const MIN_BURST_EVENTS = 3;
|
||||||
|
// Real dialogue holds on screen for about a second, so a run with a couple of much
|
||||||
|
// shorter events among them looks like frames. Used only alongside authoring evidence.
|
||||||
|
const ANIMATION_FRAME_MAX_SECONDS = 0.3;
|
||||||
|
// A karaoke run usually ends on a long "hold" frame, so not every event is short.
|
||||||
|
const MIN_TAGGED_BURST_FRAMES = 2;
|
||||||
|
// SRT and VTT carry no authoring metadata at all, so timing is the only signal available
|
||||||
|
// -- which makes it the easiest one to get wrong. ASS->SRT conversion leaves frames at
|
||||||
|
// ~0.04s, well under any real utterance, and a burst leaves many of them behind. Both
|
||||||
|
// bounds are deliberately far stricter than the ASS path: a run of ordinary short lines
|
||||||
|
// (`えっ` traded between characters) must not clear them.
|
||||||
|
const TIMING_ONLY_FRAME_MAX_SECONDS = 0.1;
|
||||||
|
const MIN_TIMING_ONLY_FRAMES = 5;
|
||||||
|
|
||||||
|
function cueKey(cue: SubtitleCue): string {
|
||||||
|
return `${cue.startTime}|${cue.endTime}|${cue.text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identical text over an identical span is redundant however it was authored -- most
|
||||||
|
* often a layered ASS event stacking a shadow copy under the visible one.
|
||||||
|
*/
|
||||||
|
function collapseExactDuplicates(cues: AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return cues.filter((cue) => {
|
||||||
|
const key = cueKey(cue);
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function countFramesShorterThan(run: AnnotatedSubtitleCue[], maxSeconds: number): number {
|
||||||
|
return run.filter((cue) => cue.endTime - cue.startTime < maxSeconds).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evidence that a run of ASS events is one animation rather than several authored lines.
|
||||||
|
* A static tag says nothing on its own -- three events sharing one `\clip(...)` are three
|
||||||
|
* signs -- so the tag has to be temporal by nature (`\t`, `\move`, karaoke timing, or
|
||||||
|
* anything wrapped in `\t(...)`), an animated `Effect` column, or a value that actually
|
||||||
|
* changes from event to event, which is how per-frame typesetting is authored.
|
||||||
|
*/
|
||||||
|
export function hasAssAnimationEvidence(run: AnnotatedSubtitleCue[]): boolean {
|
||||||
|
if (run.every((cue) => hasAssTemporalOverride(cue.overrides))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (run.every((cue) => isAnimatedAssEffectKind(cue.effectKind))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [first] = run;
|
||||||
|
const everyEventTypeset = run.every((cue) => cue.overrides.length > 0);
|
||||||
|
const signatureChanges = run.some((cue) => cue.overrideSignature !== first!.overrideSignature);
|
||||||
|
return everyEventTypeset && signatureChanges;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAnimationBurst(
|
||||||
|
run: AnnotatedSubtitleCue[],
|
||||||
|
format: SubtitleSourceFormat,
|
||||||
|
): boolean {
|
||||||
|
if (run.length < MIN_BURST_EVENTS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === 'srt') {
|
||||||
|
return (
|
||||||
|
run.length >= MIN_TIMING_ONLY_FRAMES &&
|
||||||
|
countFramesShorterThan(run, TIMING_ONLY_FRAME_MAX_SECONDS) === run.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (countFramesShorterThan(run, ANIMATION_FRAME_MAX_SECONDS) < MIN_TAGGED_BURST_FRAMES) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One animation belongs to one styled, one named source line. Two characters trading
|
||||||
|
// the same short word are two styles or two actors, and never merge.
|
||||||
|
const [first] = run;
|
||||||
|
if (run.some((cue) => cue.style !== first!.style || cue.name !== first!.name)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasAssAnimationEvidence(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Karaoke and sign typesetting emits one Dialogue event per animation frame, all carrying
|
||||||
|
* the same visible text over a contiguous span. Collapse each such run into a single cue.
|
||||||
|
*
|
||||||
|
* Only runs that look like animation collapse. Two ordinary lines that happen to repeat
|
||||||
|
* -- several characters each saying `おはよう` in turn, a positioned sign redrawn with a
|
||||||
|
* different fade -- stay separate, because merging them would destroy real mineable lines.
|
||||||
|
*/
|
||||||
|
function collapseAnimationBursts(
|
||||||
|
cues: AnnotatedSubtitleCue[],
|
||||||
|
format: SubtitleSourceFormat,
|
||||||
|
): AnnotatedSubtitleCue[] {
|
||||||
|
const indicesByText = new Map<string, number[]>();
|
||||||
|
cues.forEach((cue, index) => {
|
||||||
|
const bucket = indicesByText.get(cue.text);
|
||||||
|
if (bucket) {
|
||||||
|
bucket.push(index);
|
||||||
|
} else {
|
||||||
|
indicesByText.set(cue.text, [index]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const dropped = new Set<number>();
|
||||||
|
const extendedEnd = new Map<number, number>();
|
||||||
|
|
||||||
|
for (const indices of indicesByText.values()) {
|
||||||
|
if (indices.length < MIN_BURST_EVENTS) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let runStart = 0;
|
||||||
|
while (runStart < indices.length) {
|
||||||
|
let runEnd = runStart;
|
||||||
|
let chainEnd = cues[indices[runStart]!]!.endTime;
|
||||||
|
|
||||||
|
while (runEnd + 1 < indices.length) {
|
||||||
|
const next = cues[indices[runEnd + 1]!]!;
|
||||||
|
if (next.startTime > chainEnd + DUPLICATE_CUE_GAP_TOLERANCE_SECONDS) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
chainEnd = Math.max(chainEnd, next.endTime);
|
||||||
|
runEnd += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = indices.slice(runStart, runEnd + 1).map((index) => cues[index]!);
|
||||||
|
if (isAnimationBurst(run, format)) {
|
||||||
|
for (let i = runStart + 1; i <= runEnd; i += 1) {
|
||||||
|
dropped.add(indices[i]!);
|
||||||
|
}
|
||||||
|
extendedEnd.set(indices[runStart]!, chainEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
runStart = runEnd + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dropped.size === 0) {
|
||||||
|
return cues;
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged: AnnotatedSubtitleCue[] = [];
|
||||||
|
cues.forEach((cue, index) => {
|
||||||
|
if (dropped.has(index)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const end = extendedEnd.get(index);
|
||||||
|
merged.push(end !== undefined && end > cue.endTime ? { ...cue, endTime: end } : cue);
|
||||||
|
});
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeDuplicateCues(
|
||||||
|
cues: AnnotatedSubtitleCue[],
|
||||||
|
format: SubtitleSourceFormat,
|
||||||
|
): AnnotatedSubtitleCue[] {
|
||||||
|
return collapseAnimationBursts(collapseExactDuplicates(cues), format);
|
||||||
|
}
|
||||||
@@ -91,6 +91,17 @@ test('parseSrtCues skips malformed timing lines gracefully', () => {
|
|||||||
assert.equal(cues[0]!.text, '有効');
|
assert.equal(cues[0]!.text, '有効');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues strips complete brace blocks from SRT and VTT text', () => {
|
||||||
|
const content = ['1', '00:00:01,000 --> 00:00:02,000', '彼は{謎}と言った', ''].join('\n');
|
||||||
|
|
||||||
|
for (const filename of ['test.srt', 'test.vtt']) {
|
||||||
|
const cues = parseSubtitleCues(content, filename);
|
||||||
|
|
||||||
|
assert.equal(cues.length, 1, filename);
|
||||||
|
assert.equal(cues[0]!.text, '彼はと言った', filename);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('parseAssCues parses basic ASS dialogue lines', () => {
|
test('parseAssCues parses basic ASS dialogue lines', () => {
|
||||||
const content = [
|
const content = [
|
||||||
'[Script Info]',
|
'[Script Info]',
|
||||||
@@ -137,7 +148,9 @@ test('parseAssCues handles text containing commas', () => {
|
|||||||
assert.equal(cues[0]!.text, 'はい、そうです、ね');
|
assert.equal(cues[0]!.text, 'はい、そうです、ね');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseAssCues handles \\N line breaks', () => {
|
test('parseAssCues decodes \\N line breaks into real newlines', () => {
|
||||||
|
// ASS is decoded once, here at ingestion, so cue text matches what mpv hands over for
|
||||||
|
// the same line played live.
|
||||||
const content = [
|
const content = [
|
||||||
'[Events]',
|
'[Events]',
|
||||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
@@ -146,7 +159,7 @@ test('parseAssCues handles \\N line breaks', () => {
|
|||||||
|
|
||||||
const cues = parseAssCues(content);
|
const cues = parseAssCues(content);
|
||||||
|
|
||||||
assert.equal(cues[0]!.text, '一行目\\N二行目');
|
assert.equal(cues[0]!.text, '一行目\n二行目');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseAssCues strips HTML-like markup while preserving ASS line breaks', () => {
|
test('parseAssCues strips HTML-like markup while preserving ASS line breaks', () => {
|
||||||
@@ -158,7 +171,46 @@ test('parseAssCues strips HTML-like markup while preserving ASS line breaks', ()
|
|||||||
|
|
||||||
const cues = parseAssCues(content);
|
const cues = parseAssCues(content);
|
||||||
|
|
||||||
assert.equal(cues[0]!.text, '一行目\\N二行目');
|
assert.equal(cues[0]!.text, '一行目\n二行目');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseAssCues drops vector drawing runs enabled by \\p', () => {
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 1,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\an5\\pos(730,1042)\\p1\\blur1}m 20 0 b 10 0 0 10 0 20 b 0 31 10 40 20 40 {\\p0}',
|
||||||
|
'Dialogue: 0,0:00:05.00,0:00:08.00,Default,,0,0,0,,これは字幕',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseAssCues(content);
|
||||||
|
|
||||||
|
assert.equal(cues.length, 1);
|
||||||
|
assert.equal(cues[0]!.text, 'これは字幕');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseAssCues keeps text that follows a \\p0 reset on the same line', () => {
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\p1}m 0 0 l 10 10{\\p0}本文{\\p1}m 5 5 l 6 6{\\p0}続き',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseAssCues(content);
|
||||||
|
|
||||||
|
assert.equal(cues.length, 1);
|
||||||
|
assert.equal(cues[0]!.text, '本文続き');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseAssCues leaves \\pos untouched when no drawing mode is active', () => {
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\pos(960,1068)\\bord3}位置指定',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseAssCues(content);
|
||||||
|
|
||||||
|
assert.equal(cues[0]!.text, '位置指定');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseAssCues returns empty for content without Events section', () => {
|
test('parseAssCues returns empty for content without Events section', () => {
|
||||||
@@ -258,6 +310,344 @@ test('parseSubtitleCues returns cues sorted by start time', () => {
|
|||||||
assert.equal(cues[1]!.text, '二番目');
|
assert.equal(cues[1]!.text, '二番目');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues collapses per-frame karaoke duplicates into one cue', () => {
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:01.05,OP_JP,,0,0,0,,{\\clip(m 1 1)}過ぎ去ってしまう瞬間を',
|
||||||
|
'Dialogue: 0,0:00:01.05,0:00:01.09,OP_JP,,0,0,0,,{\\clip(m 2 2)}過ぎ去ってしまう瞬間を',
|
||||||
|
'Dialogue: 0,0:00:01.09,0:00:03.55,OP_JP,,0,0,0,,{\\clip(m 3 3)}過ぎ去ってしまう瞬間を',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'test.ass');
|
||||||
|
|
||||||
|
assert.equal(cues.length, 1);
|
||||||
|
assert.equal(cues[0]!.startTime, 1.0);
|
||||||
|
assert.equal(cues[0]!.endTime, 3.55);
|
||||||
|
assert.equal(cues[0]!.text, '過ぎ去ってしまう瞬間を');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues keeps back-to-back plain dialogue repeats separate', () => {
|
||||||
|
// Several characters greeting in turn: distinct utterances that happen to abut.
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:04:05.67,0:04:06.82,Dial_JP,,0,0,0,,おはよう',
|
||||||
|
'Dialogue: 0,0:04:06.82,0:04:07.56,Dial_JP,,0,0,0,,おはよう',
|
||||||
|
'Dialogue: 0,0:04:07.56,0:04:08.78,Dial_JP,,0,0,0,,おはよう',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'test.ass');
|
||||||
|
|
||||||
|
assert.equal(cues.length, 3);
|
||||||
|
assert.equal(cues[0]!.endTime, 246.82);
|
||||||
|
assert.equal(cues[2]!.startTime, 247.56);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues collapses exact duplicate cues even without effect tags', () => {
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,重なった行',
|
||||||
|
'Dialogue: 1,0:00:01.00,0:00:04.00,Default,,0,0,0,,重なった行',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'test.ass');
|
||||||
|
|
||||||
|
assert.equal(cues.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues collapses tag-less animation frames in converted SRT', () => {
|
||||||
|
// ASS -> SRT conversion drops override tags, so only the ~0.04s frame timing remains.
|
||||||
|
const lines = ['1', '00:00:07,870 --> 00:00:07,910', 'Kaguya Wants to be Confessed to', ''];
|
||||||
|
for (let i = 1; i < 8; i++) {
|
||||||
|
const start = 7910 + (i - 1) * 40;
|
||||||
|
const end = start + 40;
|
||||||
|
const at = (ms: number) =>
|
||||||
|
`00:00:0${Math.floor(ms / 1000)},${String(ms % 1000).padStart(3, '0')}`;
|
||||||
|
lines.push(String(i + 1), `${at(start)} --> ${at(end)}`, 'Kaguya Wants to be Confessed to', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(lines.join('\n'), 'test.srt');
|
||||||
|
|
||||||
|
assert.equal(cues.length, 1);
|
||||||
|
assert.equal(cues[0]!.startTime, 7.87);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues keeps identical lines that recur far apart', () => {
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,なんで',
|
||||||
|
'Dialogue: 0,0:05:00.00,0:05:01.00,Default,,0,0,0,,なんで',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'test.ass');
|
||||||
|
|
||||||
|
assert.equal(cues.length, 2);
|
||||||
|
assert.equal(cues[0]!.startTime, 1.0);
|
||||||
|
assert.equal(cues[1]!.startTime, 300.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues keeps two positioned signs that repeat the same text', () => {
|
||||||
|
// Both carry override tags, but `\pos` and `\fad` are static placement, not animation.
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:01:00.00,0:01:03.00,Sign,,0,0,0,,{\\pos(960,120)\\fad(200,200)}第一話',
|
||||||
|
'Dialogue: 0,0:01:03.00,0:01:06.00,Sign,,0,0,0,,{\\pos(960,900)\\fad(200,200)}第一話',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'test.ass');
|
||||||
|
|
||||||
|
assert.equal(cues.length, 2);
|
||||||
|
assert.equal(cues[1]!.startTime, 63.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues keeps a run of ordinary positioned lines separate', () => {
|
||||||
|
// Three events is a sequence, but none of them runs at animation-frame speed.
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:01:00.00,0:01:02.00,Sign,,0,0,0,,{\\pos(960,120)\\fad(100,100)}止まれ',
|
||||||
|
'Dialogue: 0,0:01:02.00,0:01:04.00,Sign,,0,0,0,,{\\pos(960,120)\\fad(100,100)}止まれ',
|
||||||
|
'Dialogue: 0,0:01:04.00,0:01:06.00,Sign,,0,0,0,,{\\pos(960,120)\\fad(100,100)}止まれ',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'test.ass');
|
||||||
|
|
||||||
|
assert.equal(cues.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues keeps a short repeated SRT pair without burst evidence', () => {
|
||||||
|
const content = [
|
||||||
|
'1',
|
||||||
|
'00:00:01,000 --> 00:00:01,200',
|
||||||
|
'えっ',
|
||||||
|
'',
|
||||||
|
'2',
|
||||||
|
'00:00:01,200 --> 00:00:01,400',
|
||||||
|
'えっ',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'test.srt');
|
||||||
|
|
||||||
|
assert.equal(cues.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues collapses a burst marked only by the Effect column', () => {
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:01.05,OP_JP,,0,0,0,Karaoke,歌詞',
|
||||||
|
'Dialogue: 0,0:00:01.05,0:00:01.09,OP_JP,,0,0,0,Karaoke,歌詞',
|
||||||
|
'Dialogue: 0,0:00:01.09,0:00:03.55,OP_JP,,0,0,0,Karaoke,歌詞',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'test.ass');
|
||||||
|
|
||||||
|
assert.equal(cues.length, 1);
|
||||||
|
assert.equal(cues[0]!.endTime, 3.55);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues keeps a second karaoke burst that starts after a gap', () => {
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:01.05,OP_JP,,0,0,0,,{\\clip(m 1 1)}リフレイン',
|
||||||
|
'Dialogue: 0,0:00:01.05,0:00:01.09,OP_JP,,0,0,0,,{\\clip(m 2 2)}リフレイン',
|
||||||
|
'Dialogue: 0,0:00:01.09,0:00:03.00,OP_JP,,0,0,0,,{\\clip(m 3 3)}リフレイン',
|
||||||
|
'Dialogue: 0,0:00:20.00,0:00:20.05,OP_JP,,0,0,0,,{\\clip(m 1 1)}リフレイン',
|
||||||
|
'Dialogue: 0,0:00:20.05,0:00:20.09,OP_JP,,0,0,0,,{\\clip(m 2 2)}リフレイン',
|
||||||
|
'Dialogue: 0,0:00:20.09,0:00:22.00,OP_JP,,0,0,0,,{\\clip(m 3 3)}リフレイン',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'test.ass');
|
||||||
|
|
||||||
|
assert.equal(cues.length, 2);
|
||||||
|
assert.equal(cues[0]!.endTime, 3.0);
|
||||||
|
assert.equal(cues[1]!.startTime, 20.0);
|
||||||
|
assert.equal(cues[1]!.endTime, 22.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues does not merge a burst into unrelated dialogue between frames', () => {
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:01.05,OP_JP,,0,0,0,,{\\clip(m 1 1)}歌詞',
|
||||||
|
'Dialogue: 0,0:00:01.02,0:00:03.00,Dial_JP,,0,0,0,,別のセリフ',
|
||||||
|
'Dialogue: 0,0:00:01.05,0:00:01.09,OP_JP,,0,0,0,,{\\clip(m 2 2)}歌詞',
|
||||||
|
'Dialogue: 0,0:00:01.09,0:00:03.55,OP_JP,,0,0,0,,{\\clip(m 3 3)}歌詞',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'test.ass');
|
||||||
|
|
||||||
|
assert.equal(cues.length, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
cues.map((cue) => cue.text),
|
||||||
|
['歌詞', '別のセリフ'],
|
||||||
|
);
|
||||||
|
assert.equal(cues[0]!.endTime, 3.55);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues keeps rapid ASS lines from different actors separate', () => {
|
||||||
|
// Three 200ms `えっ` reactions traded between characters. Fast, adjacent and identical,
|
||||||
|
// but authored as three lines: different styles and different actors.
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:01.20,Dial_A,アリス,0,0,0,,えっ',
|
||||||
|
'Dialogue: 0,0:00:01.20,0:00:01.40,Dial_B,ボブ,0,0,0,,えっ',
|
||||||
|
'Dialogue: 0,0:00:01.40,0:00:01.60,Dial_C,キャロル,0,0,0,,えっ',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'test.ass');
|
||||||
|
|
||||||
|
assert.equal(cues.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues reads the speaker column when it is spelled Actor', () => {
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Actor, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:01.20,Dial_JP,アリス,0,0,0,,えっ',
|
||||||
|
'Dialogue: 0,0:00:01.20,0:00:01.40,Dial_JP,ボブ,0,0,0,,えっ',
|
||||||
|
'Dialogue: 0,0:00:01.40,0:00:01.60,Dial_JP,キャロル,0,0,0,,えっ',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'test.ass');
|
||||||
|
|
||||||
|
assert.equal(cues.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues does not treat a custom Effect name as animation', () => {
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:01.20,Sign,,0,0,0,scrolling-credit,制作',
|
||||||
|
'Dialogue: 0,0:00:01.20,0:00:01.40,Sign,,0,0,0,scrolling-credit,制作',
|
||||||
|
'Dialogue: 0,0:00:01.40,0:00:01.60,Sign,,0,0,0,scrolling-credit,制作',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'test.ass');
|
||||||
|
|
||||||
|
assert.equal(cues.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues keeps rapid ASS lines that share a style but not an actor', () => {
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:01.20,Dial_JP,アリス,0,0,0,,えっ',
|
||||||
|
'Dialogue: 0,0:00:01.20,0:00:01.40,Dial_JP,ボブ,0,0,0,,えっ',
|
||||||
|
'Dialogue: 0,0:00:01.40,0:00:01.60,Dial_JP,キャロル,0,0,0,,えっ',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'test.ass');
|
||||||
|
|
||||||
|
assert.equal(cues.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues keeps untagged rapid ASS repeats separate', () => {
|
||||||
|
// No overrides at all: timing-only evidence is an SRT/VTT fallback and must not apply
|
||||||
|
// to ASS, where the absence of typesetting is itself evidence of plain dialogue.
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:01.05,Dial_JP,,0,0,0,,えっ',
|
||||||
|
'Dialogue: 0,0:00:01.05,0:00:01.10,Dial_JP,,0,0,0,,えっ',
|
||||||
|
'Dialogue: 0,0:00:01.10,0:00:01.15,Dial_JP,,0,0,0,,えっ',
|
||||||
|
'Dialogue: 0,0:00:01.15,0:00:01.20,Dial_JP,,0,0,0,,えっ',
|
||||||
|
'Dialogue: 0,0:00:01.20,0:00:01.25,Dial_JP,,0,0,0,,えっ',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'test.ass');
|
||||||
|
|
||||||
|
assert.equal(cues.length, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues keeps repeated signs sharing one static clip', () => {
|
||||||
|
// `\clip` is a static shape for the event. Three events with the identical clip were
|
||||||
|
// typeset the same way, so none of them is a frame of the others.
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:01.20,Sign,,0,0,0,,{\\clip(0,0,100,100)}注意',
|
||||||
|
'Dialogue: 0,0:00:01.20,0:00:01.40,Sign,,0,0,0,,{\\clip(0,0,100,100)}注意',
|
||||||
|
'Dialogue: 0,0:00:01.40,0:00:01.60,Sign,,0,0,0,,{\\clip(0,0,100,100)}注意',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'test.ass');
|
||||||
|
|
||||||
|
assert.equal(cues.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues collapses a sign animated through \\t', () => {
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:01.20,Sign,,0,0,0,,{\\pos(10,10)\\t(0,200,\\frz30)}回る',
|
||||||
|
'Dialogue: 0,0:00:01.20,0:00:01.40,Sign,,0,0,0,,{\\pos(10,10)\\t(0,200,\\frz30)}回る',
|
||||||
|
'Dialogue: 0,0:00:01.40,0:00:03.00,Sign,,0,0,0,,{\\pos(10,10)\\t(0,200,\\frz30)}回る',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'test.ass');
|
||||||
|
|
||||||
|
assert.equal(cues.length, 1);
|
||||||
|
assert.equal(cues[0]!.endTime, 3.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues keeps a short repeated SRT run above the frame threshold', () => {
|
||||||
|
// Five contiguous 200ms cues: a sequence, but nowhere near animation-frame speed.
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const start = 1000 + i * 200;
|
||||||
|
const at = (ms: number) =>
|
||||||
|
`00:00:0${Math.floor(ms / 1000)},${String(ms % 1000).padStart(3, '0')}`;
|
||||||
|
lines.push(String(i + 1), `${at(start)} --> ${at(start + 200)}`, 'えっ', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(lines.join('\n'), 'test.srt');
|
||||||
|
|
||||||
|
assert.equal(cues.length, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues keeps a short SRT frame run below the minimum length', () => {
|
||||||
|
// Four 40ms frames: frame-speed, but too few to tell an animation from an artefact.
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const start = 7870 + i * 40;
|
||||||
|
const at = (ms: number) =>
|
||||||
|
`00:00:0${Math.floor(ms / 1000)},${String(ms % 1000).padStart(3, '0')}`;
|
||||||
|
lines.push(String(i + 1), `${at(start)} --> ${at(start + 40)}`, 'タイトル', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(lines.join('\n'), 'test.srt');
|
||||||
|
|
||||||
|
assert.equal(cues.length, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues applies ASS burst rules to ASS content behind an .srt filename', () => {
|
||||||
|
// The extension lies, so the SRT parser finds nothing and the content-sniffing fallback
|
||||||
|
// takes over -- which has to carry the `ass` source format with it, or the far stricter
|
||||||
|
// timing-only thresholds would let this karaoke burst through as three cues.
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:01.20,Karaoke,,0,0,0,,{\\k20}歌詞',
|
||||||
|
'Dialogue: 0,0:00:01.20,0:00:01.40,Karaoke,,0,0,0,,{\\k20}歌詞',
|
||||||
|
'Dialogue: 0,0:00:01.40,0:00:03.00,Karaoke,,0,0,0,,{\\k20}歌詞',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'test.srt');
|
||||||
|
|
||||||
|
assert.equal(cues.length, 1);
|
||||||
|
assert.equal(cues[0]!.startTime, 1.0);
|
||||||
|
assert.equal(cues[0]!.endTime, 3.0);
|
||||||
|
assert.equal(cues[0]!.text, '歌詞');
|
||||||
|
});
|
||||||
|
|
||||||
test('parseSubtitleCues detects subtitle formats from remote URLs', () => {
|
test('parseSubtitleCues detects subtitle formats from remote URLs', () => {
|
||||||
const assContent = [
|
const assContent = [
|
||||||
'[Events]',
|
'[Events]',
|
||||||
|
|||||||
@@ -1,9 +1,46 @@
|
|||||||
|
import {
|
||||||
|
assOverrideSignature,
|
||||||
|
assToPlainText,
|
||||||
|
collectAssOverrideCommands,
|
||||||
|
parseAssEffectField,
|
||||||
|
type AssEffectKind,
|
||||||
|
type AssOverrideCommand,
|
||||||
|
} from './ass-text';
|
||||||
|
import { mergeDuplicateCues } from './subtitle-cue-dedup';
|
||||||
|
|
||||||
export interface SubtitleCue {
|
export interface SubtitleCue {
|
||||||
startTime: number;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the parser knows about a source event, shared only with the dedup engine.
|
||||||
|
* Deduplication needs the authoring context -- which style the line belongs to, which
|
||||||
|
* override commands it carries, whether the `Effect` column was set -- to tell a karaoke
|
||||||
|
* burst apart from two characters saying the same word in turn. None of it is meaningful
|
||||||
|
* outside the parser, so the public API stays `{startTime, endTime, text}`.
|
||||||
|
*/
|
||||||
|
export interface AnnotatedSubtitleCue extends SubtitleCue {
|
||||||
|
/** Text exactly as authored, override blocks and all. */
|
||||||
|
rawText: string;
|
||||||
|
style: string;
|
||||||
|
layer: number;
|
||||||
|
/** ASS `Name`/`Actor` column. */
|
||||||
|
name: string;
|
||||||
|
/** ASS `Effect` column, verbatim. */
|
||||||
|
effect: string;
|
||||||
|
effectKind: AssEffectKind;
|
||||||
|
/** Override commands found in `{...}` blocks, with their arguments. */
|
||||||
|
overrides: readonly AssOverrideCommand[];
|
||||||
|
/** Canonical form of `overrides`, for spotting values that change across a run. */
|
||||||
|
overrideSignature: string;
|
||||||
|
/** Position in the source file, so sorting by time stays deterministic across layers. */
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubtitleSourceFormat = 'ass' | 'srt';
|
||||||
|
|
||||||
const HTML_SUBTITLE_TAG_PATTERN = /<\/?[A-Za-z][^>\n]*>/g;
|
const HTML_SUBTITLE_TAG_PATTERN = /<\/?[A-Za-z][^>\n]*>/g;
|
||||||
|
|
||||||
const SRT_TIMING_PATTERN =
|
const SRT_TIMING_PATTERN =
|
||||||
@@ -23,12 +60,21 @@ function parseTimestamp(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single ASS decode for the file path: cues leave the parser as plain text with real
|
||||||
|
* line breaks, matching what mpv hands over for the same line played live. No layer
|
||||||
|
* downstream decodes ASS again.
|
||||||
|
*/
|
||||||
function sanitizeSubtitleCueText(text: string): string {
|
function sanitizeSubtitleCueText(text: string): string {
|
||||||
return text.replace(ASS_OVERRIDE_TAG_PATTERN, '').replace(HTML_SUBTITLE_TAG_PATTERN, '').trim();
|
return assToPlainText(text, '\n').replace(HTML_SUBTITLE_TAG_PATTERN, '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseSrtCues(content: string): SubtitleCue[] {
|
function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
|
||||||
const cues: SubtitleCue[] = [];
|
return cues.map(({ startTime, endTime, text }) => ({ startTime, endTime, text }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAnnotatedSrtCues(content: string): AnnotatedSubtitleCue[] {
|
||||||
|
const cues: AnnotatedSubtitleCue[] = [];
|
||||||
const lines = content.split(/\r?\n/);
|
const lines = content.split(/\r?\n/);
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
|
||||||
@@ -60,20 +106,39 @@ export function parseSrtCues(content: string): SubtitleCue[] {
|
|||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = sanitizeSubtitleCueText(textLines.join('\n'));
|
const rawText = textLines.join('\n');
|
||||||
|
const text = sanitizeSubtitleCueText(rawText);
|
||||||
if (text) {
|
if (text) {
|
||||||
cues.push({ startTime, endTime, text });
|
cues.push({
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
text,
|
||||||
|
rawText,
|
||||||
|
style: '',
|
||||||
|
layer: 0,
|
||||||
|
name: '',
|
||||||
|
effect: '',
|
||||||
|
effectKind: 'none',
|
||||||
|
// SRT and VTT carry no authoring metadata, and the dedup engine never reads
|
||||||
|
// overrides for those formats -- collecting them would be parsing for nobody.
|
||||||
|
overrides: [],
|
||||||
|
overrideSignature: '',
|
||||||
|
order: cues.length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return cues;
|
return cues;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ASS_OVERRIDE_TAG_PATTERN = /\{[^}]*\}/g;
|
export function parseSrtCues(content: string): SubtitleCue[] {
|
||||||
|
return toPublicCues(parseAnnotatedSrtCues(content));
|
||||||
|
}
|
||||||
|
|
||||||
const ASS_TIMING_PATTERN = /^(\d+):(\d{2}):(\d{2})\.(\d{1,2})$/;
|
const ASS_TIMING_PATTERN = /^(\d+):(\d{2}):(\d{2})\.(\d{1,2})$/;
|
||||||
const ASS_FORMAT_PREFIX = 'Format:';
|
const ASS_FORMAT_PREFIX = 'Format:';
|
||||||
const ASS_DIALOGUE_PREFIX = 'Dialogue:';
|
const ASS_DIALOGUE_PREFIX = 'Dialogue:';
|
||||||
|
const ASS_NAME_FIELD_ALIASES = ['name', 'actor'];
|
||||||
|
|
||||||
function parseAssTimestamp(raw: string): number | null {
|
function parseAssTimestamp(raw: string): number | null {
|
||||||
const match = ASS_TIMING_PATTERN.exec(raw.trim());
|
const match = ASS_TIMING_PATTERN.exec(raw.trim());
|
||||||
@@ -87,13 +152,43 @@ function parseAssTimestamp(raw: string): number | null {
|
|||||||
return hours * 3600 + minutes * 60 + seconds + centiseconds / 100;
|
return hours * 3600 + minutes * 60 + seconds + centiseconds / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseAssCues(content: string): SubtitleCue[] {
|
function readField(fields: string[], index: number): string {
|
||||||
const cues: SubtitleCue[] = [];
|
return index >= 0 && index < fields.length ? fields[index]!.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFieldIndex(formatFields: string[], aliases: string[]): number {
|
||||||
|
for (const alias of aliases) {
|
||||||
|
const index = formatFields.indexOf(alias);
|
||||||
|
if (index >= 0) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
||||||
|
const cues: AnnotatedSubtitleCue[] = [];
|
||||||
const lines = content.split(/\r?\n/);
|
const lines = content.split(/\r?\n/);
|
||||||
let inEventsSection = false;
|
let inEventsSection = false;
|
||||||
let startFieldIndex = -1;
|
const fieldIndex = {
|
||||||
let endFieldIndex = -1;
|
start: -1,
|
||||||
let textFieldIndex = -1;
|
end: -1,
|
||||||
|
text: -1,
|
||||||
|
style: -1,
|
||||||
|
layer: -1,
|
||||||
|
name: -1,
|
||||||
|
effect: -1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetFieldIndex = () => {
|
||||||
|
fieldIndex.start = -1;
|
||||||
|
fieldIndex.end = -1;
|
||||||
|
fieldIndex.text = -1;
|
||||||
|
fieldIndex.style = -1;
|
||||||
|
fieldIndex.layer = -1;
|
||||||
|
fieldIndex.name = -1;
|
||||||
|
fieldIndex.effect = -1;
|
||||||
|
};
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
@@ -101,9 +196,7 @@ export function parseAssCues(content: string): SubtitleCue[] {
|
|||||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||||
inEventsSection = trimmed.toLowerCase() === '[events]';
|
inEventsSection = trimmed.toLowerCase() === '[events]';
|
||||||
if (!inEventsSection) {
|
if (!inEventsSection) {
|
||||||
startFieldIndex = -1;
|
resetFieldIndex();
|
||||||
endFieldIndex = -1;
|
|
||||||
textFieldIndex = -1;
|
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -117,9 +210,15 @@ export function parseAssCues(content: string): SubtitleCue[] {
|
|||||||
.slice(ASS_FORMAT_PREFIX.length)
|
.slice(ASS_FORMAT_PREFIX.length)
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((field) => field.trim().toLowerCase());
|
.map((field) => field.trim().toLowerCase());
|
||||||
startFieldIndex = formatFields.indexOf('start');
|
fieldIndex.start = formatFields.indexOf('start');
|
||||||
endFieldIndex = formatFields.indexOf('end');
|
fieldIndex.end = formatFields.indexOf('end');
|
||||||
textFieldIndex = formatFields.indexOf('text');
|
fieldIndex.text = formatFields.indexOf('text');
|
||||||
|
fieldIndex.style = formatFields.indexOf('style');
|
||||||
|
fieldIndex.layer = formatFields.indexOf('layer');
|
||||||
|
// Aegisub writes the speaker column as `Actor`; the v4+ spec calls it `Name`.
|
||||||
|
// Missing it costs the burst check its speaker guard, so both spellings count.
|
||||||
|
fieldIndex.name = findFieldIndex(formatFields, ASS_NAME_FIELD_ALIASES);
|
||||||
|
fieldIndex.effect = formatFields.indexOf('effect');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,34 +226,57 @@ export function parseAssCues(content: string): SubtitleCue[] {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (startFieldIndex < 0 || endFieldIndex < 0 || textFieldIndex < 0) {
|
if (fieldIndex.start < 0 || fieldIndex.end < 0 || fieldIndex.text < 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fields = trimmed.slice(ASS_DIALOGUE_PREFIX.length).split(',');
|
const fields = trimmed.slice(ASS_DIALOGUE_PREFIX.length).split(',');
|
||||||
if (
|
if (
|
||||||
startFieldIndex >= fields.length ||
|
fieldIndex.start >= fields.length ||
|
||||||
endFieldIndex >= fields.length ||
|
fieldIndex.end >= fields.length ||
|
||||||
textFieldIndex >= fields.length
|
fieldIndex.text >= fields.length
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTime = parseAssTimestamp(fields[startFieldIndex]!);
|
const startTime = parseAssTimestamp(fields[fieldIndex.start]!);
|
||||||
const endTime = parseAssTimestamp(fields[endFieldIndex]!);
|
const endTime = parseAssTimestamp(fields[fieldIndex.end]!);
|
||||||
if (startTime === null || endTime === null) {
|
if (startTime === null || endTime === null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = sanitizeSubtitleCueText(fields.slice(textFieldIndex).join(','));
|
const rawText = fields.slice(fieldIndex.text).join(',');
|
||||||
if (text) {
|
const text = sanitizeSubtitleCueText(rawText);
|
||||||
cues.push({ startTime, endTime, text });
|
if (!text) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const effect = readField(fields, fieldIndex.effect);
|
||||||
|
const layer = Number(readField(fields, fieldIndex.layer));
|
||||||
|
const overrides = collectAssOverrideCommands(rawText);
|
||||||
|
cues.push({
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
text,
|
||||||
|
rawText,
|
||||||
|
style: readField(fields, fieldIndex.style),
|
||||||
|
layer: Number.isFinite(layer) ? layer : 0,
|
||||||
|
name: readField(fields, fieldIndex.name),
|
||||||
|
effect,
|
||||||
|
effectKind: parseAssEffectField(effect),
|
||||||
|
overrides,
|
||||||
|
overrideSignature: assOverrideSignature(overrides),
|
||||||
|
order: cues.length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return cues;
|
return cues;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseAssCues(content: string): SubtitleCue[] {
|
||||||
|
return toPublicCues(parseAnnotatedAssCues(content));
|
||||||
|
}
|
||||||
|
|
||||||
function detectSubtitleFormat(source: string): 'srt' | 'vtt' | 'ass' | 'ssa' | null {
|
function detectSubtitleFormat(source: string): 'srt' | 'vtt' | 'ass' | 'ssa' | null {
|
||||||
const [normalizedSource = source] =
|
const [normalizedSource = source] =
|
||||||
(() => {
|
(() => {
|
||||||
@@ -173,27 +295,31 @@ function detectSubtitleFormat(source: string): 'srt' | 'vtt' | 'ass' | 'ssa' | n
|
|||||||
|
|
||||||
export function parseSubtitleCues(content: string, filename: string): SubtitleCue[] {
|
export function parseSubtitleCues(content: string, filename: string): SubtitleCue[] {
|
||||||
const format = detectSubtitleFormat(filename);
|
const format = detectSubtitleFormat(filename);
|
||||||
let cues: SubtitleCue[];
|
let cues: AnnotatedSubtitleCue[];
|
||||||
|
let sourceFormat: SubtitleSourceFormat = 'srt';
|
||||||
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case 'srt':
|
case 'srt':
|
||||||
case 'vtt':
|
case 'vtt':
|
||||||
cues = parseSrtCues(content);
|
cues = parseAnnotatedSrtCues(content);
|
||||||
break;
|
break;
|
||||||
case 'ass':
|
case 'ass':
|
||||||
case 'ssa':
|
case 'ssa':
|
||||||
cues = parseAssCues(content);
|
cues = parseAnnotatedAssCues(content);
|
||||||
|
sourceFormat = 'ass';
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
cues = [];
|
cues = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cues.length === 0) {
|
if (cues.length === 0) {
|
||||||
const assCues = parseAssCues(content);
|
const assCues = parseAnnotatedAssCues(content);
|
||||||
const srtCues = parseSrtCues(content);
|
const srtCues = parseAnnotatedSrtCues(content);
|
||||||
cues = assCues.length >= srtCues.length ? assCues : srtCues;
|
const preferAss = assCues.length >= srtCues.length;
|
||||||
|
cues = preferAss ? assCues : srtCues;
|
||||||
|
sourceFormat = preferAss && assCues.length > 0 ? 'ass' : 'srt';
|
||||||
}
|
}
|
||||||
|
|
||||||
cues.sort((a, b) => a.startTime - b.startTime);
|
cues.sort((a, b) => a.startTime - b.startTime || a.endTime - b.endTime || a.order - b.order);
|
||||||
return cues;
|
return toPublicCues(mergeDuplicateCues(cues, sourceFormat));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,21 @@ test('subtitle processing does not emit plain payload for cached lines', async (
|
|||||||
assert.deepEqual(emitted, [{ text: '字幕', tokens: [] }]);
|
assert.deepEqual(emitted, [{ text: '字幕', tokens: [] }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('text that normalizes to nothing is never cached', () => {
|
||||||
|
const controller = createSubtitleProcessingController({
|
||||||
|
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
||||||
|
emitSubtitle: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Two different inputs both reduce to an empty key; sharing one entry would serve the
|
||||||
|
// first one's tokens for the second.
|
||||||
|
controller.preCacheTokenization(' ', { text: ' ', tokens: [] });
|
||||||
|
|
||||||
|
assert.equal(controller.hasCachedSubtitle(' '), false);
|
||||||
|
assert.equal(controller.hasCachedSubtitle('\\n'), false);
|
||||||
|
assert.equal(controller.consumeCachedSubtitle('\\n'), null);
|
||||||
|
});
|
||||||
|
|
||||||
test('subtitle processing shows plain line while tokenization is still pending', async () => {
|
test('subtitle processing shows plain line while tokenization is still pending', async () => {
|
||||||
const emitted: SubtitleData[] = [];
|
const emitted: SubtitleData[] = [];
|
||||||
let resolveTokenization: ((value: SubtitleData | null) => void) | undefined;
|
let resolveTokenization: ((value: SubtitleData | null) => void) | undefined;
|
||||||
@@ -539,125 +554,3 @@ test('default cache limit covers a full-length title without evicting', () => {
|
|||||||
assert.equal(controller.hasCachedSubtitle('line-0'), true);
|
assert.equal(controller.hasCachedSubtitle('line-0'), true);
|
||||||
assert.equal(controller.hasCachedSubtitle('line-1999'), true);
|
assert.equal(controller.hasCachedSubtitle('line-1999'), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('onSubtitleChange reports whether processing was scheduled', async () => {
|
|
||||||
const emitted: SubtitleData[] = [];
|
|
||||||
const controller = createSubtitleProcessingController({
|
|
||||||
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
|
||||||
emitSubtitle: (payload) => emitted.push(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
// New text schedules work, so an emit (and anything gated on it) will follow.
|
|
||||||
assert.equal(controller.onSubtitleChange('字幕'), true);
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
// A repeat emits nothing, so callers must not wait on an emit that is never
|
|
||||||
// coming (subtitle prefetching would stay paused for the rest of the cue).
|
|
||||||
const emittedCount = emitted.length;
|
|
||||||
assert.equal(controller.onSubtitleChange('字幕'), false);
|
|
||||||
await flushMicrotasks();
|
|
||||||
assert.equal(emitted.length, emittedCount);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('refreshCurrentSubtitle reports the empty-text emit that an in-flight run will deliver', async () => {
|
|
||||||
const emitted: SubtitleData[] = [];
|
|
||||||
let resolveFirst: ((value: SubtitleData | null) => void) | undefined;
|
|
||||||
const controller = createSubtitleProcessingController({
|
|
||||||
tokenizeSubtitle: async (text) => {
|
|
||||||
if (text === '字幕') {
|
|
||||||
return await new Promise<SubtitleData | null>((resolve) => {
|
|
||||||
resolveFirst = resolve;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { text, tokens: [] };
|
|
||||||
},
|
|
||||||
emitSubtitle: (payload) => emitted.push(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
controller.onSubtitleChange('字幕');
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
// Clearing the subtitle while tokenization is in flight: the running loop
|
|
||||||
// picks the empty text up and emits it, so callers gated on that emit (the
|
|
||||||
// prefetch pause) must be told one is coming.
|
|
||||||
assert.equal(controller.refreshCurrentSubtitle(''), true);
|
|
||||||
|
|
||||||
resolveFirst?.({ text: '字幕', tokens: [] });
|
|
||||||
await flushMicrotasks();
|
|
||||||
await flushMicrotasks();
|
|
||||||
// '字幕' is the provisional plain emit the in-flight run already made before
|
|
||||||
// the refresh; '' is the emit the refresh promised.
|
|
||||||
assert.deepEqual(
|
|
||||||
emitted.map((payload) => payload.text),
|
|
||||||
['字幕', ''],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('onProcessingSettled fires once after the queue drains, including runs that emit nothing', async () => {
|
|
||||||
const events: string[] = [];
|
|
||||||
let resolveFirst: ((value: SubtitleData | null) => void) | undefined;
|
|
||||||
let tokenizationFails = false;
|
|
||||||
const controller = createSubtitleProcessingController({
|
|
||||||
tokenizeSubtitle: async (text) => {
|
|
||||||
if (tokenizationFails) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (text === '一行目') {
|
|
||||||
return await new Promise<SubtitleData | null>((resolve) => {
|
|
||||||
resolveFirst = resolve;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { text, tokens: [] };
|
|
||||||
},
|
|
||||||
emitSubtitle: (payload) => events.push(`emit:${payload.text}`),
|
|
||||||
onProcessingSettled: () => events.push('settled'),
|
|
||||||
});
|
|
||||||
|
|
||||||
controller.onSubtitleChange('一行目');
|
|
||||||
await flushMicrotasks();
|
|
||||||
// A second line arrives before the first finishes: the controller still has
|
|
||||||
// work, so it must not report itself settled between the two.
|
|
||||||
controller.onSubtitleChange('二行目');
|
|
||||||
resolveFirst?.({ text: '一行目', tokens: [] });
|
|
||||||
await flushMicrotasks();
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
assert.deepEqual(events, ['emit:一行目', 'emit:二行目', 'emit:二行目', 'settled']);
|
|
||||||
|
|
||||||
// Tokenization failure on a line already shown plain: nothing is emitted, and
|
|
||||||
// the settle signal is the only way a caller learns the work is over.
|
|
||||||
events.length = 0;
|
|
||||||
tokenizationFails = true;
|
|
||||||
controller.invalidateTokenizationCache();
|
|
||||||
assert.equal(controller.refreshCurrentSubtitle('二行目'), true);
|
|
||||||
await flushMicrotasks();
|
|
||||||
await flushMicrotasks();
|
|
||||||
assert.deepEqual(events, ['settled']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('notePlainSubtitleEmitted suppresses the controller repeat of a payload already shown', async () => {
|
|
||||||
const emitted: SubtitleData[] = [];
|
|
||||||
const controller = createSubtitleProcessingController({
|
|
||||||
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
|
||||||
emitSubtitle: (payload) => emitted.push(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Autoplay priming paints the plain line itself, then asks for tokenization.
|
|
||||||
controller.notePlainSubtitleEmitted('字幕');
|
|
||||||
controller.refreshCurrentSubtitle('字幕');
|
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
assert.deepEqual(emitted, [{ text: '字幕', tokens: [] }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('refreshCurrentSubtitle reports no emit for empty text when nothing is running', async () => {
|
|
||||||
const emitted: SubtitleData[] = [];
|
|
||||||
const controller = createSubtitleProcessingController({
|
|
||||||
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
|
||||||
emitSubtitle: (payload) => emitted.push(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(controller.refreshCurrentSubtitle(''), false);
|
|
||||||
await flushMicrotasks();
|
|
||||||
assert.deepEqual(emitted, []);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
import type { SubtitleData } from '../../types';
|
import type { SubtitleData } from '../../types';
|
||||||
|
import { normalizePlainSubtitleText } from './ass-text';
|
||||||
|
|
||||||
export interface SubtitleProcessingControllerDeps {
|
export interface SubtitleProcessingControllerDeps {
|
||||||
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
|
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
|
||||||
emitSubtitle: (payload: SubtitleData) => void;
|
emitSubtitle: (payload: SubtitleData) => void;
|
||||||
/**
|
|
||||||
* Fires when the controller runs out of work: every scheduled line has been
|
|
||||||
* processed, whether it ended in an emit, a suppressed duplicate, or a
|
|
||||||
* tokenizer failure. Callers that hold a resource for the duration of
|
|
||||||
* processing (prefetch pausing) release it here rather than on an emit,
|
|
||||||
* which is not guaranteed to happen.
|
|
||||||
*/
|
|
||||||
onProcessingSettled?: () => void;
|
|
||||||
logDebug?: (message: string) => void;
|
logDebug?: (message: string) => void;
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
cacheLimit?: number;
|
cacheLimit?: number;
|
||||||
@@ -25,30 +18,23 @@ export interface SubtitleProcessingControllerDeps {
|
|||||||
export const DEFAULT_SUBTITLE_TOKENIZATION_CACHE_LIMIT = 2500;
|
export const DEFAULT_SUBTITLE_TOKENIZATION_CACHE_LIMIT = 2500;
|
||||||
|
|
||||||
export interface SubtitleProcessingController {
|
export interface SubtitleProcessingController {
|
||||||
/**
|
onSubtitleChange: (text: string) => void;
|
||||||
* Returns whether processing is now scheduled or already in flight for this
|
refreshCurrentSubtitle: (textOverride?: string) => void;
|
||||||
* event. A false return means the controller is idle and will do nothing, so
|
|
||||||
* onProcessingSettled will not fire; callers that pause work for the duration
|
|
||||||
* of processing (such as subtitle prefetching) must release it themselves.
|
|
||||||
*/
|
|
||||||
onSubtitleChange: (text: string) => boolean;
|
|
||||||
/** Same contract as onSubtitleChange: whether processing is pending. */
|
|
||||||
refreshCurrentSubtitle: (textOverride?: string) => boolean;
|
|
||||||
/**
|
|
||||||
* Records that this exact text has already been shown plain by someone else
|
|
||||||
* (autoplay priming paints its first frame before scheduling tokenization),
|
|
||||||
* so the controller does not repeat that payload on its way to the tokenized
|
|
||||||
* one.
|
|
||||||
*/
|
|
||||||
notePlainSubtitleEmitted: (text: string) => void;
|
|
||||||
invalidateTokenizationCache: () => void;
|
invalidateTokenizationCache: () => void;
|
||||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
||||||
consumeCachedSubtitle: (text: string) => SubtitleData | null;
|
consumeCachedSubtitle: (text: string) => SubtitleData | null;
|
||||||
hasCachedSubtitle: (text: string) => boolean;
|
hasCachedSubtitle: (text: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefetched cues and live mpv text are both already decoded from ASS, so the key only
|
||||||
|
* has to settle whitespace for one authored line to resolve to one entry.
|
||||||
|
*
|
||||||
|
* An empty key is not a line: it is whatever normalization reduced to nothing. Callers
|
||||||
|
* must skip the cache for it rather than let every such input share one entry.
|
||||||
|
*/
|
||||||
export function normalizeSubtitleCacheKey(text: string): string {
|
export function normalizeSubtitleCacheKey(text: string): string {
|
||||||
return text.replace(/\r\n/g, '\n').replace(/\\N/g, '\n').replace(/\\n/g, '\n').trim();
|
return normalizePlainSubtitleText(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSubtitleProcessingController(
|
export function createSubtitleProcessingController(
|
||||||
@@ -72,6 +58,9 @@ export function createSubtitleProcessingController(
|
|||||||
|
|
||||||
const getCachedTokenization = (text: string): SubtitleData | null => {
|
const getCachedTokenization = (text: string): SubtitleData | null => {
|
||||||
const cacheKey = normalizeSubtitleCacheKey(text);
|
const cacheKey = normalizeSubtitleCacheKey(text);
|
||||||
|
if (!cacheKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const cached = tokenizationCache.get(cacheKey);
|
const cached = tokenizationCache.get(cacheKey);
|
||||||
if (!cached) {
|
if (!cached) {
|
||||||
return null;
|
return null;
|
||||||
@@ -83,7 +72,11 @@ export function createSubtitleProcessingController(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setCachedTokenization = (text: string, payload: SubtitleData): void => {
|
const setCachedTokenization = (text: string, payload: SubtitleData): void => {
|
||||||
tokenizationCache.set(normalizeSubtitleCacheKey(text), payload);
|
const cacheKey = normalizeSubtitleCacheKey(text);
|
||||||
|
if (!cacheKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokenizationCache.set(cacheKey, payload);
|
||||||
while (tokenizationCache.size > SUBTITLE_TOKENIZATION_CACHE_LIMIT) {
|
while (tokenizationCache.size > SUBTITLE_TOKENIZATION_CACHE_LIMIT) {
|
||||||
const firstKey = tokenizationCache.keys().next().value;
|
const firstKey = tokenizationCache.keys().next().value;
|
||||||
if (firstKey !== undefined) {
|
if (firstKey !== undefined) {
|
||||||
@@ -186,20 +179,14 @@ export function createSubtitleProcessingController(
|
|||||||
(latestText.trim() && cacheGeneration !== lastEmittedGeneration)
|
(latestText.trim() && cacheGeneration !== lastEmittedGeneration)
|
||||||
) {
|
) {
|
||||||
processLatest();
|
processLatest();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// Nothing left to do: signal completion even when this run emitted
|
|
||||||
// nothing (suppressed duplicate, tokenizer failure), or callers waiting
|
|
||||||
// on the controller would wait forever.
|
|
||||||
deps.onProcessingSettled?.();
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onSubtitleChange: (text: string) => {
|
onSubtitleChange: (text: string) => {
|
||||||
if (text === latestText) {
|
if (text === latestText) {
|
||||||
// A run already in flight for this text will still emit for it.
|
return;
|
||||||
return processing;
|
|
||||||
}
|
}
|
||||||
latestText = text;
|
latestText = text;
|
||||||
if (
|
if (
|
||||||
@@ -211,28 +198,21 @@ export function createSubtitleProcessingController(
|
|||||||
lastPlainEmittedText = text;
|
lastPlainEmittedText = text;
|
||||||
}
|
}
|
||||||
processLatest();
|
processLatest();
|
||||||
return true;
|
|
||||||
},
|
},
|
||||||
refreshCurrentSubtitle: (textOverride?: string) => {
|
refreshCurrentSubtitle: (textOverride?: string) => {
|
||||||
if (typeof textOverride === 'string') {
|
if (typeof textOverride === 'string') {
|
||||||
latestText = textOverride;
|
latestText = textOverride;
|
||||||
}
|
}
|
||||||
if (!latestText.trim()) {
|
if (!latestText.trim()) {
|
||||||
// A run in flight will pick this up and emit the empty subtitle, so
|
return;
|
||||||
// the caller is still waiting on an emit.
|
|
||||||
return processing;
|
|
||||||
}
|
}
|
||||||
if (processing) {
|
if (
|
||||||
return true;
|
processing ||
|
||||||
}
|
(latestText === lastEmittedText && cacheGeneration === lastEmittedGeneration)
|
||||||
if (latestText === lastEmittedText && cacheGeneration === lastEmittedGeneration) {
|
) {
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
processLatest();
|
processLatest();
|
||||||
return true;
|
|
||||||
},
|
|
||||||
notePlainSubtitleEmitted: (text: string) => {
|
|
||||||
lastPlainEmittedText = text;
|
|
||||||
},
|
},
|
||||||
invalidateTokenizationCache: () => {
|
invalidateTokenizationCache: () => {
|
||||||
tokenizationCache.clear();
|
tokenizationCache.clear();
|
||||||
@@ -254,7 +234,8 @@ export function createSubtitleProcessingController(
|
|||||||
return cached;
|
return cached;
|
||||||
},
|
},
|
||||||
hasCachedSubtitle: (text: string) => {
|
hasCachedSubtitle: (text: string) => {
|
||||||
return tokenizationCache.has(normalizeSubtitleCacheKey(text));
|
const cacheKey = normalizeSubtitleCacheKey(text);
|
||||||
|
return cacheKey.length > 0 && tokenizationCache.has(cacheKey);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1651,9 +1651,11 @@ test('tokenizeSubtitle clears JLPT level from standalone Yomitan particle token'
|
|||||||
assert.equal(result.tokens?.[0]?.jlptLevel, undefined);
|
assert.equal(result.tokens?.[0]?.jlptLevel, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tokenizeSubtitle returns null tokens for empty normalized text', async () => {
|
test('tokenizeSubtitle returns the normalized text when it comes out empty', async () => {
|
||||||
|
// Handing back the original would push whatever normalization dropped into app state
|
||||||
|
// as if it were subtitle text.
|
||||||
const result = await tokenizeSubtitle(' \\n ', makeDeps());
|
const result = await tokenizeSubtitle(' \\n ', makeDeps());
|
||||||
assert.deepEqual(result, { text: ' \\n ', tokens: null });
|
assert.deepEqual(result, { text: '', tokens: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tokenizeSubtitle normalizes newlines before Yomitan parse request', async () => {
|
test('tokenizeSubtitle normalizes newlines before Yomitan parse request', async () => {
|
||||||
@@ -2934,12 +2936,44 @@ test('tokenizeSubtitle preserves Yomitan compound token when MeCab components ar
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (script.includes('parseText')) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
surface: '取り組んで',
|
source: 'scanning-parser',
|
||||||
|
index: 0,
|
||||||
|
content: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: '取り組んで',
|
||||||
reading: 'とりくんで',
|
reading: 'とりくんで',
|
||||||
headword: '取り組む',
|
headwords: [[{ term: '取り組む' }]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: 'もらいます',
|
||||||
|
reading: 'もらいます',
|
||||||
|
headwords: [[{ term: 'もらう' }]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
surface: '取り',
|
||||||
|
reading: 'とり',
|
||||||
|
headword: '取る',
|
||||||
startPos: 0,
|
startPos: 0,
|
||||||
|
endPos: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
surface: '組んで',
|
||||||
|
reading: 'くんで',
|
||||||
|
headword: '組む',
|
||||||
|
startPos: 2,
|
||||||
endPos: 5,
|
endPos: 5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
} from './tokenizer/yomitan-parser-runtime';
|
} from './tokenizer/yomitan-parser-runtime';
|
||||||
import type { YomitanTermFrequency } from './tokenizer/yomitan-parser-runtime';
|
import type { YomitanTermFrequency } from './tokenizer/yomitan-parser-runtime';
|
||||||
import { isKanaChar } from './tokenizer/token-classification';
|
import { isKanaChar } from './tokenizer/token-classification';
|
||||||
|
import { normalizePlainSubtitleText } from './ass-text';
|
||||||
|
|
||||||
const logger = createLogger('main:tokenizer');
|
const logger = createLogger('main:tokenizer');
|
||||||
|
|
||||||
@@ -70,7 +71,6 @@ export interface TokenizerServiceDeps {
|
|||||||
getNameMatchImagesEnabled?: () => boolean;
|
getNameMatchImagesEnabled?: () => boolean;
|
||||||
getCharacterNameImage?: (term: string) => CharacterNameImage | null;
|
getCharacterNameImage?: (term: string) => CharacterNameImage | null;
|
||||||
getCurrentCharacterDictionaryMediaId?: () => number | null;
|
getCurrentCharacterDictionaryMediaId?: () => number | null;
|
||||||
getCharacterNameCandidates?: () => { key: string; forms: string[] } | null;
|
|
||||||
getFrequencyDictionaryEnabled?: () => boolean;
|
getFrequencyDictionaryEnabled?: () => boolean;
|
||||||
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
|
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
|
||||||
getFrequencyRank?: FrequencyDictionaryLookup;
|
getFrequencyRank?: FrequencyDictionaryLookup;
|
||||||
@@ -107,7 +107,6 @@ export interface TokenizerDepsRuntimeOptions {
|
|||||||
getNameMatchImagesEnabled?: () => boolean;
|
getNameMatchImagesEnabled?: () => boolean;
|
||||||
getCharacterNameImage?: (term: string) => CharacterNameImage | null;
|
getCharacterNameImage?: (term: string) => CharacterNameImage | null;
|
||||||
getCurrentCharacterDictionaryMediaId?: () => number | null;
|
getCurrentCharacterDictionaryMediaId?: () => number | null;
|
||||||
getCharacterNameCandidates?: () => { key: string; forms: string[] } | null;
|
|
||||||
getFrequencyDictionaryEnabled?: () => boolean;
|
getFrequencyDictionaryEnabled?: () => boolean;
|
||||||
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
|
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
|
||||||
getFrequencyRank?: FrequencyDictionaryLookup;
|
getFrequencyRank?: FrequencyDictionaryLookup;
|
||||||
@@ -268,7 +267,6 @@ export function createTokenizerDepsRuntime(
|
|||||||
getNameMatchImagesEnabled: options.getNameMatchImagesEnabled,
|
getNameMatchImagesEnabled: options.getNameMatchImagesEnabled,
|
||||||
getCharacterNameImage: options.getCharacterNameImage,
|
getCharacterNameImage: options.getCharacterNameImage,
|
||||||
getCurrentCharacterDictionaryMediaId: options.getCurrentCharacterDictionaryMediaId,
|
getCurrentCharacterDictionaryMediaId: options.getCurrentCharacterDictionaryMediaId,
|
||||||
getCharacterNameCandidates: options.getCharacterNameCandidates,
|
|
||||||
getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled,
|
getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled,
|
||||||
getFrequencyDictionaryMatchMode: options.getFrequencyDictionaryMatchMode ?? (() => 'headword'),
|
getFrequencyDictionaryMatchMode: options.getFrequencyDictionaryMatchMode ?? (() => 'headword'),
|
||||||
getFrequencyRank: options.getFrequencyRank,
|
getFrequencyRank: options.getFrequencyRank,
|
||||||
@@ -719,30 +717,15 @@ function getAnnotationOptions(deps: TokenizerServiceDeps): TokenizerAnnotationOp
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-line stage durations for the pipeline debug log; every field is filled in
|
|
||||||
// by the stage that awaits the corresponding work.
|
|
||||||
interface TokenizationStageTimings {
|
|
||||||
scanMs?: number;
|
|
||||||
mecabMs?: number;
|
|
||||||
frequencyMs?: number;
|
|
||||||
annotateMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function parseWithYomitanInternalParser(
|
async function parseWithYomitanInternalParser(
|
||||||
text: string,
|
text: string,
|
||||||
deps: TokenizerServiceDeps,
|
deps: TokenizerServiceDeps,
|
||||||
options: TokenizerAnnotationOptions,
|
options: TokenizerAnnotationOptions,
|
||||||
stageTimings?: TokenizationStageTimings,
|
|
||||||
): Promise<MergedToken[] | null> {
|
): Promise<MergedToken[] | null> {
|
||||||
const scanStartedAtMs = Date.now();
|
|
||||||
const selectedTokens = await requestYomitanScanTokens(text, deps, logger, {
|
const selectedTokens = await requestYomitanScanTokens(text, deps, logger, {
|
||||||
includeNameMatchMetadata: options.nameMatchEnabled,
|
includeNameMatchMetadata: options.nameMatchEnabled,
|
||||||
currentCharacterDictionaryMediaId: deps.getCurrentCharacterDictionaryMediaId?.() ?? null,
|
currentCharacterDictionaryMediaId: deps.getCurrentCharacterDictionaryMediaId?.() ?? null,
|
||||||
nameCandidates: deps.getCharacterNameCandidates?.() ?? null,
|
|
||||||
});
|
});
|
||||||
if (stageTimings) {
|
|
||||||
stageTimings.scanMs = Date.now() - scanStartedAtMs;
|
|
||||||
}
|
|
||||||
if (!selectedTokens || selectedTokens.length === 0) {
|
if (!selectedTokens || selectedTokens.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -775,7 +758,6 @@ async function parseWithYomitanInternalParser(
|
|||||||
|
|
||||||
const frequencyRankPromise: Promise<YomitanFrequencyIndex> = options.frequencyEnabled
|
const frequencyRankPromise: Promise<YomitanFrequencyIndex> = options.frequencyEnabled
|
||||||
? (async () => {
|
? (async () => {
|
||||||
const frequencyStartedAtMs = Date.now();
|
|
||||||
const frequencyMatchMode = options.frequencyMatchMode;
|
const frequencyMatchMode = options.frequencyMatchMode;
|
||||||
const termReadingList = buildYomitanFrequencyTermReadingList(
|
const termReadingList = buildYomitanFrequencyTermReadingList(
|
||||||
normalizedSelectedTokens,
|
normalizedSelectedTokens,
|
||||||
@@ -786,17 +768,12 @@ async function parseWithYomitanInternalParser(
|
|||||||
deps,
|
deps,
|
||||||
logger,
|
logger,
|
||||||
);
|
);
|
||||||
const frequencyIndex = buildYomitanFrequencyIndex(yomitanFrequencies);
|
return buildYomitanFrequencyIndex(yomitanFrequencies);
|
||||||
if (stageTimings) {
|
|
||||||
stageTimings.frequencyMs = Date.now() - frequencyStartedAtMs;
|
|
||||||
}
|
|
||||||
return frequencyIndex;
|
|
||||||
})()
|
})()
|
||||||
: Promise.resolve({ byPair: new Map(), byTerm: new Map() });
|
: Promise.resolve({ byPair: new Map(), byTerm: new Map() });
|
||||||
|
|
||||||
const mecabEnrichmentPromise: Promise<MergedToken[]> = needsMecabPosEnrichment(options)
|
const mecabEnrichmentPromise: Promise<MergedToken[]> = needsMecabPosEnrichment(options)
|
||||||
? (async () => {
|
? (async () => {
|
||||||
const mecabStartedAtMs = Date.now();
|
|
||||||
try {
|
try {
|
||||||
const mecabTokens = await deps.tokenizeWithMecab(text);
|
const mecabTokens = await deps.tokenizeWithMecab(text);
|
||||||
const enrichTokensWithMecab = deps.enrichTokensWithMecab ?? enrichTokensWithMecabAsync;
|
const enrichTokensWithMecab = deps.enrichTokensWithMecab ?? enrichTokensWithMecabAsync;
|
||||||
@@ -810,10 +787,6 @@ async function parseWithYomitanInternalParser(
|
|||||||
`textLength=${text.length}`,
|
`textLength=${text.length}`,
|
||||||
);
|
);
|
||||||
return normalizedSelectedTokens;
|
return normalizedSelectedTokens;
|
||||||
} finally {
|
|
||||||
if (stageTimings) {
|
|
||||||
stageTimings.mecabMs = Date.now() - mecabStartedAtMs;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
: Promise.resolve(normalizedSelectedTokens);
|
: Promise.resolve(normalizedSelectedTokens);
|
||||||
@@ -886,14 +859,14 @@ export async function tokenizeSubtitle(
|
|||||||
text: string,
|
text: string,
|
||||||
deps: TokenizerServiceDeps,
|
deps: TokenizerServiceDeps,
|
||||||
): Promise<SubtitleData> {
|
): Promise<SubtitleData> {
|
||||||
const displayText = text
|
const displayText = normalizePlainSubtitleText(text);
|
||||||
.replace(/\r\n/g, '\n')
|
|
||||||
.replace(/\\N/g, '\n')
|
|
||||||
.replace(/\\n/g, '\n')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
|
// ASS decoding already happened upstream (cue parser for files, mpv for live text), so
|
||||||
|
// all this drops is whitespace -- but a whitespace-only line still normalizes to empty.
|
||||||
|
// Return the normalized form anyway: handing back the original would put a blank line
|
||||||
|
// into application state as if it were subtitle text.
|
||||||
if (!displayText) {
|
if (!displayText) {
|
||||||
return { text, tokens: null };
|
return { text: displayText, tokens: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenizeText = displayText
|
const tokenizeText = displayText
|
||||||
@@ -904,35 +877,15 @@ export async function tokenizeSubtitle(
|
|||||||
const annotationOptions = getAnnotationOptions(deps);
|
const annotationOptions = getAnnotationOptions(deps);
|
||||||
annotationOptions.sourceText = tokenizeText;
|
annotationOptions.sourceText = tokenizeText;
|
||||||
|
|
||||||
const stageTimings: TokenizationStageTimings = {};
|
const yomitanTokens = await parseWithYomitanInternalParser(tokenizeText, deps, annotationOptions);
|
||||||
const startedAtMs = Date.now();
|
|
||||||
const logStageTimings = (tokenCount: number): void => {
|
|
||||||
logger.debug(
|
|
||||||
`Subtitle tokenization stages; textLength=${tokenizeText.length}, tokenCount=${tokenCount}, ` +
|
|
||||||
`scanMs=${stageTimings.scanMs ?? '-'}, mecabMs=${stageTimings.mecabMs ?? '-'}, ` +
|
|
||||||
`frequencyMs=${stageTimings.frequencyMs ?? '-'}, annotateMs=${stageTimings.annotateMs ?? '-'}, ` +
|
|
||||||
`totalMs=${Date.now() - startedAtMs}`,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const yomitanTokens = await parseWithYomitanInternalParser(
|
|
||||||
tokenizeText,
|
|
||||||
deps,
|
|
||||||
annotationOptions,
|
|
||||||
stageTimings,
|
|
||||||
);
|
|
||||||
if (yomitanTokens && yomitanTokens.length > 0) {
|
if (yomitanTokens && yomitanTokens.length > 0) {
|
||||||
const annotateStartedAtMs = Date.now();
|
|
||||||
const annotatedTokens = await applyAnnotationStage(yomitanTokens, deps, annotationOptions);
|
const annotatedTokens = await applyAnnotationStage(yomitanTokens, deps, annotationOptions);
|
||||||
stageTimings.annotateMs = Date.now() - annotateStartedAtMs;
|
|
||||||
const renderedTokens = applyCharacterNameImages(annotatedTokens, deps, annotationOptions);
|
const renderedTokens = applyCharacterNameImages(annotatedTokens, deps, annotationOptions);
|
||||||
logStageTimings(renderedTokens.length);
|
|
||||||
return {
|
return {
|
||||||
text: displayText,
|
text: displayText,
|
||||||
tokens: renderedTokens.length > 0 ? renderedTokens : null,
|
tokens: renderedTokens.length > 0 ? renderedTokens : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
logStageTimings(0);
|
|
||||||
return { text: displayText, tokens: null };
|
return { text: displayText, tokens: null };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -366,11 +366,8 @@ export function createReplayMessageStore(messages: GoldenRecordedMessage[]): Rep
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// One persistent context per fixture, matching the real parser window: the
|
async function runInjectedScriptInVm(script: string, store: ReplayMessageStore): Promise<unknown> {
|
||||||
// scan runtime installs itself once into globalThis and later per-line call
|
return await vm.runInNewContext(script, {
|
||||||
// scripts reuse it.
|
|
||||||
function createInjectedScriptVm(store: ReplayMessageStore): (script: string) => Promise<unknown> {
|
|
||||||
const context = vm.createContext({
|
|
||||||
chrome: {
|
chrome: {
|
||||||
runtime: {
|
runtime: {
|
||||||
lastError: null,
|
lastError: null,
|
||||||
@@ -396,7 +393,6 @@ function createInjectedScriptVm(store: ReplayMessageStore): (script: string) =>
|
|||||||
Set,
|
Set,
|
||||||
String,
|
String,
|
||||||
});
|
});
|
||||||
return async (script: string) => await vm.runInContext(script, context);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createReplayTokenizerDeps(fixture: GoldenFixture): TokenizerServiceDeps {
|
export function createReplayTokenizerDeps(fixture: GoldenFixture): TokenizerServiceDeps {
|
||||||
@@ -404,14 +400,13 @@ export function createReplayTokenizerDeps(fixture: GoldenFixture): TokenizerServ
|
|||||||
const scriptResults = new Map(
|
const scriptResults = new Map(
|
||||||
fixture.recording.scripts.map((entry) => [entry.sha256, entry] as const),
|
fixture.recording.scripts.map((entry) => [entry.sha256, entry] as const),
|
||||||
);
|
);
|
||||||
const runInjectedScriptInVm = createInjectedScriptVm(store);
|
|
||||||
|
|
||||||
const parserWindow = {
|
const parserWindow = {
|
||||||
isDestroyed: () => false,
|
isDestroyed: () => false,
|
||||||
webContents: {
|
webContents: {
|
||||||
executeJavaScript: async (script: string) => {
|
executeJavaScript: async (script: string) => {
|
||||||
try {
|
try {
|
||||||
return await runInjectedScriptInVm(script);
|
return await runInjectedScriptInVm(script, store);
|
||||||
} catch (vmError) {
|
} catch (vmError) {
|
||||||
const recorded = scriptResults.get(hashInjectedScript(script));
|
const recorded = scriptResults.get(hashInjectedScript(script));
|
||||||
if (recorded) {
|
if (recorded) {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
isKanaChar,
|
isKanaChar,
|
||||||
isKanaOnlyText,
|
isKanaOnlyText,
|
||||||
isTokenPos2Excluded,
|
isTokenPos2Excluded,
|
||||||
normalizeKana,
|
|
||||||
} from './token-classification';
|
} from './token-classification';
|
||||||
|
|
||||||
const POS1_EXCLUSIONS = new Set(['助詞']);
|
const POS1_EXCLUSIONS = new Set(['助詞']);
|
||||||
@@ -30,26 +29,6 @@ function makeNoun(surface: string): MergedToken {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('kana normalization folds halfwidth kana, composing the voiced pairs', () => {
|
|
||||||
// カ + ゙ is two code points for one character: without composing them, a
|
|
||||||
// halfwidth word counts as longer than the reading that spells it, which
|
|
||||||
// disqualifies the reading from known-word matching.
|
|
||||||
assert.equal(normalizeKana('ガク'), normalizeKana('ガク'));
|
|
||||||
assert.equal(normalizeKana('パン'), normalizeKana('パン'));
|
|
||||||
assert.equal(normalizeKana('ミナト'), 'みなと');
|
|
||||||
assert.ok(isKanaOnlyText('ガク'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('kana normalization leaves characters other than halfwidth kana alone', () => {
|
|
||||||
// The composition is scoped to the halfwidth runs: applied to the whole
|
|
||||||
// string, NFKC would also rewrite these into something the dictionary, the
|
|
||||||
// known-word list, and the frequency data were never keyed on.
|
|
||||||
assert.equal(normalizeKana('①ガ'), '①が');
|
|
||||||
assert.equal(normalizeKana('Aガ'), 'Aが');
|
|
||||||
assert.equal(normalizeKana('㍑ガ'), '㍑が');
|
|
||||||
assert.equal(normalizeKana('fiガ'), 'fiが');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('kana classification excludes the katakana-hiragana double hyphen', () => {
|
test('kana classification excludes the katakana-hiragana double hyphen', () => {
|
||||||
assert.equal(isKanaChar('゠'), false);
|
assert.equal(isKanaChar('゠'), false);
|
||||||
assert.equal(isKanaOnlyText('゠'), false);
|
assert.equal(isKanaOnlyText('゠'), false);
|
||||||
|
|||||||
@@ -4,20 +4,8 @@ const KATAKANA_TO_HIRAGANA_OFFSET = 0x60;
|
|||||||
const KATAKANA_CODEPOINT_START = 0x30a1;
|
const KATAKANA_CODEPOINT_START = 0x30a1;
|
||||||
const KATAKANA_CODEPOINT_END = 0x30f6;
|
const KATAKANA_CODEPOINT_END = 0x30f6;
|
||||||
|
|
||||||
// No `u` flag: the range is entirely BMP so it changes nothing here, and
|
|
||||||
// Bun's unicode-mode matcher mis-handles this class next to certain ligatures.
|
|
||||||
const HALFWIDTH_KANA_RUN = /[\uff66-\uff9f]+/g;
|
|
||||||
|
|
||||||
// NFKC over the halfwidth kana only, never the whole string: it composes the
|
|
||||||
// voiced pairs (カ + ゙) into single characters so ガク compares equal to ガク
|
|
||||||
// instead of counting one character longer than the word it spells, but run
|
|
||||||
// over everything it would also rewrite unrelated text (① → 1, ㍑ → リットル).
|
|
||||||
function composeHalfwidthKana(text: string): string {
|
|
||||||
return text.replace(HALFWIDTH_KANA_RUN, (run) => run.normalize('NFKC'));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeKana(text: string): string {
|
export function normalizeKana(text: string): string {
|
||||||
const raw = composeHalfwidthKana(text).trim();
|
const raw = text.trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,14 +3,6 @@ import * as fs from 'fs';
|
|||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { selectYomitanParseTokens } from './parser-selection-stage';
|
import { selectYomitanParseTokens } from './parser-selection-stage';
|
||||||
import {
|
|
||||||
buildYomitanScanCallScript,
|
|
||||||
buildYomitanScanNameCandidatesScript,
|
|
||||||
CHARACTER_DICTIONARY_TITLE_PREFIX,
|
|
||||||
YOMITAN_SCAN_RUNTIME_INSTALL_SCRIPT,
|
|
||||||
YOMITAN_SCAN_RUNTIME_MISSING_SENTINEL,
|
|
||||||
type YomitanFrequencyMode,
|
|
||||||
} from './yomitan-scan-runtime-script';
|
|
||||||
|
|
||||||
interface LoggerLike {
|
interface LoggerLike {
|
||||||
error: (message: string, ...args: unknown[]) => void;
|
error: (message: string, ...args: unknown[]) => void;
|
||||||
@@ -30,6 +22,8 @@ interface YomitanParserRuntimeDeps {
|
|||||||
createYomitanExtensionWindow?: (pageName: string) => Promise<BrowserWindow | null>;
|
createYomitanExtensionWindow?: (pageName: string) => Promise<BrowserWindow | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type YomitanFrequencyMode = 'occurrence-based' | 'rank-based';
|
||||||
|
|
||||||
export interface YomitanDictionaryInfo {
|
export interface YomitanDictionaryInfo {
|
||||||
title: string;
|
title: string;
|
||||||
revision?: string | number;
|
revision?: string | number;
|
||||||
@@ -80,19 +74,13 @@ export interface YomitanAddNoteResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_YOMITAN_SCAN_LENGTH = 40;
|
const DEFAULT_YOMITAN_SCAN_LENGTH = 40;
|
||||||
|
const CHARACTER_DICTIONARY_TITLE_PREFIX = 'SubMiner Character Dictionary';
|
||||||
const yomitanProfileMetadataByWindow = new WeakMap<BrowserWindow, YomitanProfileMetadata>();
|
const yomitanProfileMetadataByWindow = new WeakMap<BrowserWindow, YomitanProfileMetadata>();
|
||||||
const yomitanProfileDiagnosticsLoggedByWindow = new WeakSet<BrowserWindow>();
|
const yomitanProfileDiagnosticsLoggedByWindow = new WeakSet<BrowserWindow>();
|
||||||
const yomitanFrequencyCacheByWindow = new WeakMap<
|
const yomitanFrequencyCacheByWindow = new WeakMap<
|
||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
Map<string, YomitanTermFrequency[]>
|
Map<string, YomitanTermFrequency[]>
|
||||||
>();
|
>();
|
||||||
// Epoch passed with every scan request; the in-window termsFind cache clears
|
|
||||||
// itself when the epoch changes (dictionary imports, settings changes).
|
|
||||||
const yomitanScanCacheEpochByWindow = new WeakMap<BrowserWindow, number>();
|
|
||||||
|
|
||||||
function getYomitanScanCacheEpoch(window: BrowserWindow): number {
|
|
||||||
return yomitanScanCacheEpochByWindow.get(window) ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isObject(value: unknown): value is Record<string, unknown> {
|
function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
return Boolean(value && typeof value === 'object');
|
return Boolean(value && typeof value === 'object');
|
||||||
@@ -111,7 +99,6 @@ function isScanTokenArray(value: unknown): value is YomitanScanToken[] {
|
|||||||
typeof entry.startPos === 'number' &&
|
typeof entry.startPos === 'number' &&
|
||||||
typeof entry.endPos === 'number' &&
|
typeof entry.endPos === 'number' &&
|
||||||
(entry.isNameMatch === undefined || typeof entry.isNameMatch === 'boolean') &&
|
(entry.isNameMatch === undefined || typeof entry.isNameMatch === 'boolean') &&
|
||||||
(entry.isUnparsedRun === undefined || typeof entry.isUnparsedRun === 'boolean') &&
|
|
||||||
(entry.frequencyRank === undefined || typeof entry.frequencyRank === 'number') &&
|
(entry.frequencyRank === undefined || typeof entry.frequencyRank === 'number') &&
|
||||||
(entry.wordClasses === undefined ||
|
(entry.wordClasses === undefined ||
|
||||||
(Array.isArray(entry.wordClasses) &&
|
(Array.isArray(entry.wordClasses) &&
|
||||||
@@ -120,9 +107,13 @@ function isScanTokenArray(value: unknown): value is YomitanScanToken[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scanTokenSpanKey(token: YomitanScanToken): string {
|
||||||
|
return `${token.startPos}:${token.endPos}:${token.surface}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Maps a parse-selected token to the scanner-token shape carried out of the
|
// Maps a parse-selected token to the scanner-token shape carried out of the
|
||||||
// parser runtime, used by the parseText fallback path when the in-window
|
// parser runtime. Shared by both selectYomitanParseTokens fallback paths so the
|
||||||
// scanner is unavailable.
|
// projected fields stay in sync as the shape changes.
|
||||||
function toYomitanScanToken(token: {
|
function toYomitanScanToken(token: {
|
||||||
surface: string;
|
surface: string;
|
||||||
reading: string;
|
reading: string;
|
||||||
@@ -141,6 +132,66 @@ function toYomitanScanToken(token: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseText segmentation is authoritative (it emits filler chunks for text the
|
||||||
|
// termsFind scanner skips), but only the termsFind scanner carries annotation
|
||||||
|
// metadata (isNameMatch, frequencyRank, headwordReading, wordClasses). Graft
|
||||||
|
// scanner tokens onto the parseText segmentation per matching span so one
|
||||||
|
// unmatched chunk degrades only itself instead of dropping the whole line's
|
||||||
|
// metadata.
|
||||||
|
//
|
||||||
|
// Exception: character-name tokens. The greedy name scan can re-segment text
|
||||||
|
// around a name (e.g. とヨータ → と + ヨータ instead of とヨー + タ), so
|
||||||
|
// parseText segmentation cannot be authoritative there. Each name span is
|
||||||
|
// expanded until it aligns with token boundaries in both segmentations, then
|
||||||
|
// the parse tokens inside are replaced with the scanner tokens.
|
||||||
|
function mergeScannerTokensIntoParseTokens(
|
||||||
|
parseScanTokens: YomitanScanToken[],
|
||||||
|
scannerTokens: YomitanScanToken[],
|
||||||
|
): YomitanScanToken[] {
|
||||||
|
const scannerTokensBySpan = new Map<string, YomitanScanToken>();
|
||||||
|
for (const token of scannerTokens) {
|
||||||
|
scannerTokensBySpan.set(scanTokenSpanKey(token), token);
|
||||||
|
}
|
||||||
|
const graftedTokens = parseScanTokens.map(
|
||||||
|
(token) => scannerTokensBySpan.get(scanTokenSpanKey(token)) ?? token,
|
||||||
|
);
|
||||||
|
|
||||||
|
const nameTokens = scannerTokens.filter((token) => token.isNameMatch === true);
|
||||||
|
if (nameTokens.length === 0) {
|
||||||
|
return graftedTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
const regions = nameTokens.map((token) => ({ start: token.startPos, end: token.endPos }));
|
||||||
|
const allTokens = [...parseScanTokens, ...scannerTokens];
|
||||||
|
let expanded = true;
|
||||||
|
while (expanded) {
|
||||||
|
expanded = false;
|
||||||
|
for (const region of regions) {
|
||||||
|
for (const token of allTokens) {
|
||||||
|
const overlaps = token.startPos < region.end && token.endPos > region.start;
|
||||||
|
const extendsBeyond = token.startPos < region.start || token.endPos > region.end;
|
||||||
|
if (overlaps && extendsBeyond) {
|
||||||
|
region.start = Math.min(region.start, token.startPos);
|
||||||
|
region.end = Math.max(region.end, token.endPos);
|
||||||
|
expanded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInsideNameRegion = (token: YomitanScanToken): boolean =>
|
||||||
|
regions.some((region) => token.startPos >= region.start && token.endPos <= region.end);
|
||||||
|
|
||||||
|
const merged = graftedTokens.filter((token) => !isInsideNameRegion(token));
|
||||||
|
for (const token of scannerTokens) {
|
||||||
|
if (isInsideNameRegion(token)) {
|
||||||
|
merged.push(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged.sort((a, b) => a.startPos - b.startPos || a.endPos - b.endPos);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
function makeTermReadingCacheKey(term: string, reading: string | null): string {
|
function makeTermReadingCacheKey(term: string, reading: string | null): string {
|
||||||
return `${term}\u0000${reading ?? ''}`;
|
return `${term}\u0000${reading ?? ''}`;
|
||||||
}
|
}
|
||||||
@@ -157,7 +208,6 @@ function getWindowFrequencyCache(window: BrowserWindow): Map<string, YomitanTerm
|
|||||||
function clearWindowCaches(window: BrowserWindow): void {
|
function clearWindowCaches(window: BrowserWindow): void {
|
||||||
yomitanProfileMetadataByWindow.delete(window);
|
yomitanProfileMetadataByWindow.delete(window);
|
||||||
yomitanFrequencyCacheByWindow.delete(window);
|
yomitanFrequencyCacheByWindow.delete(window);
|
||||||
yomitanScanCacheEpochByWindow.set(window, getYomitanScanCacheEpoch(window) + 1);
|
|
||||||
}
|
}
|
||||||
export function clearYomitanParserCachesForWindow(window: BrowserWindow): void {
|
export function clearYomitanParserCachesForWindow(window: BrowserWindow): void {
|
||||||
clearWindowCaches(window);
|
clearWindowCaches(window);
|
||||||
@@ -654,10 +704,6 @@ async function ensureYomitanParserWindow(
|
|||||||
if (readyPromise) {
|
if (readyPromise) {
|
||||||
await readyPromise;
|
await readyPromise;
|
||||||
}
|
}
|
||||||
// Eagerly install the scan runtime so the first subtitle line does not
|
|
||||||
// pay the install round trip; failures fall back to the per-request
|
|
||||||
// install-and-retry path.
|
|
||||||
await installYomitanScanRuntime(parserWindow).catch(() => {});
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -831,42 +877,668 @@ async function serveDictionaryZipOnce<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installYomitanScanRuntime(parserWindow: BrowserWindow): Promise<void> {
|
const YOMITAN_SCANNING_HELPERS = String.raw`
|
||||||
await parserWindow.webContents.executeJavaScript(YOMITAN_SCAN_RUNTIME_INSTALL_SCRIPT, true);
|
const HIRAGANA_CONVERSION_RANGE = [0x3041, 0x3096];
|
||||||
// A fresh runtime has no candidate list; force the next scan to reinstall it.
|
const KATAKANA_CONVERSION_RANGE = [0x30a1, 0x30f6];
|
||||||
yomitanScanNameCandidateKeyByWindow.delete(parserWindow);
|
const KANA_PROLONGED_SOUND_MARK_CODE_POINT = 0x30fc;
|
||||||
}
|
const KATAKANA_SMALL_KA_CODE_POINT = 0x30f5;
|
||||||
|
const KATAKANA_SMALL_KE_CODE_POINT = 0x30f6;
|
||||||
// Key of the character-name candidate list currently installed in each parser
|
const KANA_RANGES = [[0x3040, 0x309f], [0x30a0, 0x30ff]];
|
||||||
// window, so an unchanged list costs nothing per line.
|
const JAPANESE_RANGES = [[0x3040, 0x30ff], [0x3400, 0x9fff]];
|
||||||
const yomitanScanNameCandidateKeyByWindow = new WeakMap<BrowserWindow, string>();
|
function isCodePointInRange(codePoint, range) { return codePoint >= range[0] && codePoint <= range[1]; }
|
||||||
|
function isCodePointInRanges(codePoint, ranges) { return ranges.some((range) => isCodePointInRange(codePoint, range)); }
|
||||||
async function ensureYomitanScanNameCandidates(
|
function isCodePointKana(codePoint) { return isCodePointInRanges(codePoint, KANA_RANGES); }
|
||||||
parserWindow: BrowserWindow,
|
function isCodePointJapanese(codePoint) { return isCodePointInRanges(codePoint, JAPANESE_RANGES); }
|
||||||
nameCandidates: { key: string; forms: string[] } | null,
|
function createFuriganaSegment(text, reading) { return {text, reading}; }
|
||||||
logger: LoggerLike,
|
function getSegmentReadingContribution(segment) {
|
||||||
): Promise<void> {
|
if (typeof segment.reading === "string" && segment.reading.length > 0) { return segment.reading; }
|
||||||
const installedKey = yomitanScanNameCandidateKeyByWindow.get(parserWindow);
|
const segmentText = typeof segment.text === "string" ? segment.text : "";
|
||||||
const nextKey = nameCandidates?.key ?? '';
|
const isKanaOnly = segmentText.length > 0 && [...segmentText].every((char) => isCodePointKana(char.codePointAt(0)));
|
||||||
if (installedKey === nextKey) {
|
return isKanaOnly ? segmentText : "";
|
||||||
|
}
|
||||||
|
function getProlongedHiragana(previousCharacter) {
|
||||||
|
switch (previousCharacter) {
|
||||||
|
case "あ": case "か": case "が": case "さ": case "ざ": case "た": case "だ": case "な": case "は": case "ば": case "ぱ": case "ま": case "や": case "ら": case "わ": case "ぁ": case "ゃ": case "ゎ": return "あ";
|
||||||
|
case "い": case "き": case "ぎ": case "し": case "じ": case "ち": case "ぢ": case "に": case "ひ": case "び": case "ぴ": case "み": case "り": case "ぃ": return "い";
|
||||||
|
case "う": case "く": case "ぐ": case "す": case "ず": case "つ": case "づ": case "ぬ": case "ふ": case "ぶ": case "ぷ": case "む": case "ゆ": case "る": case "ぅ": case "ゅ": return "う";
|
||||||
|
case "え": case "け": case "げ": case "せ": case "ぜ": case "て": case "で": case "ね": case "へ": case "べ": case "ぺ": case "め": case "れ": case "ぇ": return "え";
|
||||||
|
case "お": case "こ": case "ご": case "そ": case "ぞ": case "と": case "ど": case "の": case "ほ": case "ぼ": case "ぽ": case "も": case "よ": case "ろ": case "を": case "ぉ": case "ょ": return "う";
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function getFuriganaKanaSegments(text, reading) {
|
||||||
|
const newSegments = [];
|
||||||
|
let start = 0;
|
||||||
|
let state = (reading[0] === text[0]);
|
||||||
|
for (let i = 1; i < text.length; ++i) {
|
||||||
|
const newState = (reading[i] === text[i]);
|
||||||
|
if (state === newState) { continue; }
|
||||||
|
newSegments.push(createFuriganaSegment(text.substring(start, i), state ? '' : reading.substring(start, i)));
|
||||||
|
state = newState;
|
||||||
|
start = i;
|
||||||
|
}
|
||||||
|
newSegments.push(createFuriganaSegment(text.substring(start), state ? '' : reading.substring(start)));
|
||||||
|
return newSegments;
|
||||||
|
}
|
||||||
|
function convertKatakanaToHiragana(text, keepProlongedSoundMarks = false) {
|
||||||
|
let result = '';
|
||||||
|
const offset = (HIRAGANA_CONVERSION_RANGE[0] - KATAKANA_CONVERSION_RANGE[0]);
|
||||||
|
for (let char of text) {
|
||||||
|
const codePoint = char.codePointAt(0);
|
||||||
|
switch (codePoint) {
|
||||||
|
case KATAKANA_SMALL_KA_CODE_POINT:
|
||||||
|
case KATAKANA_SMALL_KE_CODE_POINT:
|
||||||
|
break;
|
||||||
|
case KANA_PROLONGED_SOUND_MARK_CODE_POINT:
|
||||||
|
if (!keepProlongedSoundMarks && result.length > 0) {
|
||||||
|
const char2 = getProlongedHiragana(result[result.length - 1]);
|
||||||
|
if (char2 !== null) { char = char2; }
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (isCodePointInRange(codePoint, KATAKANA_CONVERSION_RANGE)) {
|
||||||
|
char = String.fromCodePoint(codePoint + offset);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
result += char;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function segmentizeFurigana(reading, readingNormalized, groups, groupsStart) {
|
||||||
|
const groupCount = groups.length - groupsStart;
|
||||||
|
if (groupCount <= 0) { return reading.length === 0 ? [] : null; }
|
||||||
|
const group = groups[groupsStart];
|
||||||
|
const {isKana, text} = group;
|
||||||
|
if (isKana) {
|
||||||
|
if (group.textNormalized !== null && readingNormalized.startsWith(group.textNormalized)) {
|
||||||
|
const segments = segmentizeFurigana(reading.substring(text.length), readingNormalized.substring(text.length), groups, groupsStart + 1);
|
||||||
|
if (segments !== null) {
|
||||||
|
if (reading.startsWith(text)) { segments.unshift(createFuriganaSegment(text, '')); }
|
||||||
|
else { segments.unshift(...getFuriganaKanaSegments(text, reading)); }
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let result = null;
|
||||||
|
for (let i = reading.length; i >= text.length; --i) {
|
||||||
|
const segments = segmentizeFurigana(reading.substring(i), readingNormalized.substring(i), groups, groupsStart + 1);
|
||||||
|
if (segments !== null) {
|
||||||
|
if (result !== null) { return null; }
|
||||||
|
segments.unshift(createFuriganaSegment(text, reading.substring(0, i)));
|
||||||
|
result = segments;
|
||||||
|
}
|
||||||
|
if (groupCount === 1) { break; }
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function distributeFurigana(term, reading) {
|
||||||
|
if (reading === term) { return [createFuriganaSegment(term, '')]; }
|
||||||
|
const groups = [];
|
||||||
|
let groupPre = null;
|
||||||
|
let isKanaPre = null;
|
||||||
|
for (const c of term) {
|
||||||
|
const isKana = isCodePointKana(c.codePointAt(0));
|
||||||
|
if (isKana === isKanaPre) { groupPre.text += c; }
|
||||||
|
else {
|
||||||
|
groupPre = {isKana, text: c, textNormalized: null};
|
||||||
|
groups.push(groupPre);
|
||||||
|
isKanaPre = isKana;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const group of groups) {
|
||||||
|
if (group.isKana) { group.textNormalized = convertKatakanaToHiragana(group.text); }
|
||||||
|
}
|
||||||
|
const segments = segmentizeFurigana(reading, convertKatakanaToHiragana(reading), groups, 0);
|
||||||
|
return segments !== null ? segments : [createFuriganaSegment(term, reading)];
|
||||||
|
}
|
||||||
|
function getStemLength(text1, text2) {
|
||||||
|
const minLength = Math.min(text1.length, text2.length);
|
||||||
|
if (minLength === 0) { return 0; }
|
||||||
|
let i = 0;
|
||||||
|
while (true) {
|
||||||
|
const char1 = text1.codePointAt(i);
|
||||||
|
const char2 = text2.codePointAt(i);
|
||||||
|
if (char1 !== char2) { break; }
|
||||||
|
const charLength = String.fromCodePoint(char1).length;
|
||||||
|
i += charLength;
|
||||||
|
if (i >= minLength) {
|
||||||
|
if (i > minLength) { i -= charLength; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
function distributeFuriganaInflected(term, reading, source) {
|
||||||
|
const termNormalized = convertKatakanaToHiragana(term);
|
||||||
|
const readingNormalized = convertKatakanaToHiragana(reading);
|
||||||
|
const sourceNormalized = convertKatakanaToHiragana(source);
|
||||||
|
let mainText = term;
|
||||||
|
let stemLength = getStemLength(termNormalized, sourceNormalized);
|
||||||
|
const readingStemLength = getStemLength(readingNormalized, sourceNormalized);
|
||||||
|
if (readingStemLength > 0 && readingStemLength >= stemLength) {
|
||||||
|
mainText = reading;
|
||||||
|
stemLength = readingStemLength;
|
||||||
|
reading = source.substring(0, stemLength) + reading.substring(stemLength);
|
||||||
|
}
|
||||||
|
const segments = [];
|
||||||
|
if (stemLength > 0) {
|
||||||
|
mainText = source.substring(0, stemLength) + mainText.substring(stemLength);
|
||||||
|
const segments2 = distributeFurigana(mainText, reading);
|
||||||
|
let consumed = 0;
|
||||||
|
for (const segment of segments2) {
|
||||||
|
const start = consumed;
|
||||||
|
consumed += segment.text.length;
|
||||||
|
if (consumed < stemLength) { segments.push(segment); }
|
||||||
|
else if (consumed === stemLength) { segments.push(segment); break; }
|
||||||
|
else {
|
||||||
|
if (start < stemLength) { segments.push(createFuriganaSegment(mainText.substring(start, stemLength), '')); }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stemLength < source.length) {
|
||||||
|
const remainder = source.substring(stemLength);
|
||||||
|
const last = segments[segments.length - 1];
|
||||||
|
if (last && last.reading.length === 0) { last.text += remainder; }
|
||||||
|
else { segments.push(createFuriganaSegment(remainder, '')); }
|
||||||
|
}
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
function parsePositiveFrequencyNumber(value) {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||||
|
return Math.max(1, Math.floor(value));
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const numericMatch = value.trim().match(/[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?/)?.[0];
|
||||||
|
if (!numericMatch) { return null; }
|
||||||
|
const parsed = Number.parseFloat(numericMatch);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) { return null; }
|
||||||
|
return Math.max(1, Math.floor(parsed));
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
const parsed = parsePositiveFrequencyNumber(item);
|
||||||
|
if (parsed !== null) { return parsed; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function parseDisplayFrequencyNumber(value) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const leadingDigits = value.trim().match(/^\d+/)?.[0];
|
||||||
|
if (!leadingDigits) { return null; }
|
||||||
|
const parsed = Number.parseInt(leadingDigits, 10);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
return parsePositiveFrequencyNumber(value);
|
||||||
|
}
|
||||||
|
function getFrequencyDictionaryName(frequency) {
|
||||||
|
const candidates = [
|
||||||
|
frequency?.dictionary,
|
||||||
|
frequency?.dictionaryName,
|
||||||
|
frequency?.name,
|
||||||
|
frequency?.title,
|
||||||
|
frequency?.dictionaryTitle,
|
||||||
|
frequency?.dictionaryAlias
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
||||||
|
return candidate.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function getBestFrequencyRank(dictionaryEntry, headwordIndex, dictionaryPriorityByName, dictionaryFrequencyModeByName) {
|
||||||
|
let best = null;
|
||||||
|
const headwordCount = Array.isArray(dictionaryEntry?.headwords) ? dictionaryEntry.headwords.length : 0;
|
||||||
|
for (const frequency of dictionaryEntry?.frequencies || []) {
|
||||||
|
if (!frequency || typeof frequency !== 'object') { continue; }
|
||||||
|
const frequencyHeadwordIndex = frequency.headwordIndex;
|
||||||
|
if (typeof frequencyHeadwordIndex === 'number') {
|
||||||
|
if (frequencyHeadwordIndex !== headwordIndex) { continue; }
|
||||||
|
} else if (headwordCount > 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dictionary = getFrequencyDictionaryName(frequency);
|
||||||
|
if (!dictionary) { continue; }
|
||||||
|
if (dictionaryFrequencyModeByName[dictionary] === 'occurrence-based') { continue; }
|
||||||
|
const rank =
|
||||||
|
parseDisplayFrequencyNumber(frequency.displayValue) ??
|
||||||
|
parsePositiveFrequencyNumber(frequency.frequency);
|
||||||
|
if (rank === null) { continue; }
|
||||||
|
const priorityRaw = dictionaryPriorityByName[dictionary];
|
||||||
|
const fallbackPriority =
|
||||||
|
typeof frequency.dictionaryIndex === 'number' && Number.isFinite(frequency.dictionaryIndex)
|
||||||
|
? Math.max(0, Math.floor(frequency.dictionaryIndex))
|
||||||
|
: Number.MAX_SAFE_INTEGER;
|
||||||
|
const priority =
|
||||||
|
typeof priorityRaw === 'number' && Number.isFinite(priorityRaw)
|
||||||
|
? Math.max(0, Math.floor(priorityRaw))
|
||||||
|
: fallbackPriority;
|
||||||
|
if (best === null || priority < best.priority || (priority === best.priority && rank < best.rank)) {
|
||||||
|
best = { priority, rank };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best?.rank ?? null;
|
||||||
|
}
|
||||||
|
function hasExactSource(headword, token, requirePrimary) {
|
||||||
|
for (const src of headword.sources || []) {
|
||||||
|
if (src.originalText !== token) { continue; }
|
||||||
|
if (requirePrimary && !src.isPrimary) { continue; }
|
||||||
|
if (src.matchType !== 'exact') { continue; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function collectExactHeadwordMatches(dictionaryEntries, token, requirePrimary) {
|
||||||
|
const matches = [];
|
||||||
|
for (const dictionaryEntry of dictionaryEntries || []) {
|
||||||
|
const headwords = Array.isArray(dictionaryEntry?.headwords) ? dictionaryEntry.headwords : [];
|
||||||
|
for (let headwordIndex = 0; headwordIndex < headwords.length; headwordIndex += 1) {
|
||||||
|
const headword = headwords[headwordIndex];
|
||||||
|
if (!hasExactSource(headword, token, requirePrimary)) { continue; }
|
||||||
|
matches.push({ dictionaryEntry, headword, headwordIndex });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
function sameHeadword(match, preferredMatch) {
|
||||||
|
if (!match || !preferredMatch) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (match.headword?.term !== preferredMatch.headword?.term) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const matchReading = typeof match.headword?.reading === 'string' ? match.headword.reading : '';
|
||||||
|
const preferredReading =
|
||||||
|
typeof preferredMatch.headword?.reading === 'string' ? preferredMatch.headword.reading : '';
|
||||||
|
if (!matchReading || !preferredReading) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return matchReading === preferredReading;
|
||||||
|
}
|
||||||
|
function getBestFrequencyRankForMatches(matches, dictionaryPriorityByName, dictionaryFrequencyModeByName) {
|
||||||
|
let best = null;
|
||||||
|
for (const match of matches) {
|
||||||
|
const rank = getBestFrequencyRank(
|
||||||
|
match.dictionaryEntry,
|
||||||
|
match.headwordIndex,
|
||||||
|
dictionaryPriorityByName,
|
||||||
|
dictionaryFrequencyModeByName
|
||||||
|
);
|
||||||
|
if (rank === null) { continue; }
|
||||||
|
if (best === null || rank < best) {
|
||||||
|
best = rank;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
function normalizeWordClasses(headword) {
|
||||||
|
if (!Array.isArray(headword?.wordClasses)) { return undefined; }
|
||||||
|
const classes = headword.wordClasses.filter((wordClass) => typeof wordClass === "string" && wordClass.trim().length > 0);
|
||||||
|
return classes.length > 0 ? classes : undefined;
|
||||||
|
}
|
||||||
|
function appendDictionaryNames(target, value) {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const candidates = [
|
||||||
try {
|
value.dictionary,
|
||||||
await parserWindow.webContents.executeJavaScript(
|
value.dictionaryName,
|
||||||
buildYomitanScanNameCandidatesScript(nameCandidates),
|
value.name,
|
||||||
true,
|
value.title,
|
||||||
);
|
value.dictionaryTitle,
|
||||||
yomitanScanNameCandidateKeyByWindow.set(parserWindow, nextKey);
|
value.dictionaryAlias
|
||||||
} catch (err) {
|
];
|
||||||
// The scan falls back to checking every position when the list is absent,
|
for (const candidate of candidates) {
|
||||||
// so a failed install costs speed, never a missed name.
|
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
||||||
logger.warn?.(
|
target.push(candidate.trim());
|
||||||
'Failed to install Yomitan character-name scan candidates:',
|
|
||||||
(err as Error).message,
|
|
||||||
);
|
|
||||||
yomitanScanNameCandidateKeyByWindow.delete(parserWindow);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function getDictionaryEntryNames(entry) {
|
||||||
|
const names = [];
|
||||||
|
appendDictionaryNames(names, entry);
|
||||||
|
for (const definition of entry?.definitions || []) {
|
||||||
|
appendDictionaryNames(names, definition);
|
||||||
|
}
|
||||||
|
for (const frequency of entry?.frequencies || []) {
|
||||||
|
appendDictionaryNames(names, frequency);
|
||||||
|
}
|
||||||
|
for (const pronunciation of entry?.pronunciations || []) {
|
||||||
|
appendDictionaryNames(names, pronunciation);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
function isNameDictionaryEntry(entry) {
|
||||||
|
if (!includeNameMatchMetadata || !entry || typeof entry !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return getDictionaryEntryNames(entry).some((name) => name.startsWith(${JSON.stringify(CHARACTER_DICTIONARY_TITLE_PREFIX)}));
|
||||||
|
}
|
||||||
|
function parseSubMinerMediaIdFromString(value) {
|
||||||
|
const imageMatch = value.match(/\bimg\/m(\d+)-/i);
|
||||||
|
if (imageMatch) {
|
||||||
|
const parsed = Number.parseInt(imageMatch[1], 10);
|
||||||
|
if (Number.isSafeInteger(parsed) && parsed > 0) { return parsed; }
|
||||||
|
}
|
||||||
|
const titleMatch = value.match(/${CHARACTER_DICTIONARY_TITLE_PREFIX}[^\d]*(?:AniList\s*)?(\d+)/i);
|
||||||
|
if (titleMatch) {
|
||||||
|
const parsed = Number.parseInt(titleMatch[1], 10);
|
||||||
|
if (Number.isSafeInteger(parsed) && parsed > 0) { return parsed; }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function parseSubMinerMediaIdCandidate(value) {
|
||||||
|
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && /^\d+$/.test(value.trim())) {
|
||||||
|
const parsed = Number.parseInt(value.trim(), 10);
|
||||||
|
if (Number.isSafeInteger(parsed) && parsed > 0) { return parsed; }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function collectSubMinerMediaIds(value, target) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = parseSubMinerMediaIdFromString(value);
|
||||||
|
if (parsed !== null) { target.add(parsed); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) { collectSubMinerMediaIds(item, target); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mediaIdCandidates = [
|
||||||
|
value.subminerMediaId,
|
||||||
|
value.subMinerMediaId,
|
||||||
|
value.characterDictionaryMediaId,
|
||||||
|
value.data?.subminerMediaId,
|
||||||
|
value.data?.subMinerMediaId,
|
||||||
|
value.data?.characterDictionaryMediaId
|
||||||
|
];
|
||||||
|
for (const candidate of mediaIdCandidates) {
|
||||||
|
const parsed = parseSubMinerMediaIdCandidate(candidate);
|
||||||
|
if (parsed !== null) { target.add(parsed); }
|
||||||
|
}
|
||||||
|
for (const child of Object.values(value)) {
|
||||||
|
collectSubMinerMediaIds(child, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function getSubMinerMediaIds(entry) {
|
||||||
|
const mediaIds = new Set();
|
||||||
|
collectSubMinerMediaIds(entry, mediaIds);
|
||||||
|
return mediaIds;
|
||||||
|
}
|
||||||
|
function isCurrentMediaNameDictionaryEntry(entry) {
|
||||||
|
if (!isNameDictionaryEntry(entry)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (currentCharacterDictionaryMediaId === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const mediaIds = getSubMinerMediaIds(entry);
|
||||||
|
return mediaIds.size === 0 || mediaIds.has(currentCharacterDictionaryMediaId);
|
||||||
|
}
|
||||||
|
function findLongestNameMatch(dictionaryEntries, textWindow) {
|
||||||
|
let best = null;
|
||||||
|
for (const dictionaryEntry of dictionaryEntries || []) {
|
||||||
|
if (!isCurrentMediaNameDictionaryEntry(dictionaryEntry)) { continue; }
|
||||||
|
const headwords = Array.isArray(dictionaryEntry?.headwords) ? dictionaryEntry.headwords : [];
|
||||||
|
for (let headwordIndex = 0; headwordIndex < headwords.length; headwordIndex += 1) {
|
||||||
|
const headword = headwords[headwordIndex];
|
||||||
|
for (const src of headword?.sources || []) {
|
||||||
|
if (src.matchType !== 'exact' || src.isPrimary !== true) { continue; }
|
||||||
|
const originalText = typeof src.originalText === 'string' ? src.originalText : '';
|
||||||
|
if (!originalText || !textWindow.startsWith(originalText)) { continue; }
|
||||||
|
if (best === null || originalText.length > best.sourceLength) {
|
||||||
|
best = { dictionaryEntry, headword, headwordIndex, sourceLength: originalText.length };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
function findLongestGenericMatchLength(dictionaryEntries, textWindow) {
|
||||||
|
let best = 0;
|
||||||
|
for (const dictionaryEntry of dictionaryEntries || []) {
|
||||||
|
if (isNameDictionaryEntry(dictionaryEntry)) { continue; }
|
||||||
|
const headwords = Array.isArray(dictionaryEntry?.headwords) ? dictionaryEntry.headwords : [];
|
||||||
|
for (const headword of headwords) {
|
||||||
|
for (const src of headword?.sources || []) {
|
||||||
|
if (src.matchType !== 'exact' || src.isPrimary !== true) { continue; }
|
||||||
|
const originalText = typeof src.originalText === 'string' ? src.originalText : '';
|
||||||
|
if (!originalText || !textWindow.startsWith(originalText)) { continue; }
|
||||||
|
if (originalText.length > best) { best = originalText.length; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
function getPreferredHeadword(dictionaryEntries, token, dictionaryPriorityByName, dictionaryFrequencyModeByName) {
|
||||||
|
const currentMediaDictionaryEntries =
|
||||||
|
currentCharacterDictionaryMediaId === null
|
||||||
|
? (dictionaryEntries || [])
|
||||||
|
: (dictionaryEntries || []).filter((entry) => {
|
||||||
|
if (!isNameDictionaryEntry(entry)) { return true; }
|
||||||
|
return isCurrentMediaNameDictionaryEntry(entry);
|
||||||
|
});
|
||||||
|
const exactPrimaryMatches = collectExactHeadwordMatches(currentMediaDictionaryEntries, token, true);
|
||||||
|
let matchedNameDictionary = false;
|
||||||
|
if (includeNameMatchMetadata) {
|
||||||
|
for (const dictionaryEntry of currentMediaDictionaryEntries || []) {
|
||||||
|
if (!isCurrentMediaNameDictionaryEntry(dictionaryEntry)) { continue; }
|
||||||
|
for (const match of exactPrimaryMatches) {
|
||||||
|
if (match.dictionaryEntry !== dictionaryEntry) { continue; }
|
||||||
|
matchedNameDictionary = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (matchedNameDictionary) { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const preferredMatch = exactPrimaryMatches[0];
|
||||||
|
if (preferredMatch) {
|
||||||
|
const exactFrequencyMatches = collectExactHeadwordMatches(currentMediaDictionaryEntries, token, false)
|
||||||
|
.filter((match) => sameHeadword(match, preferredMatch));
|
||||||
|
return {
|
||||||
|
term: preferredMatch.headword.term,
|
||||||
|
reading: preferredMatch.headword.reading,
|
||||||
|
wordClasses: normalizeWordClasses(preferredMatch.headword),
|
||||||
|
isNameMatch:
|
||||||
|
matchedNameDictionary || isCurrentMediaNameDictionaryEntry(preferredMatch.dictionaryEntry),
|
||||||
|
frequencyRank: getBestFrequencyRankForMatches(
|
||||||
|
exactFrequencyMatches.length > 0 ? exactFrequencyMatches : exactPrimaryMatches,
|
||||||
|
dictionaryPriorityByName,
|
||||||
|
dictionaryFrequencyModeByName
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function buildYomitanScanningScript(
|
||||||
|
text: string,
|
||||||
|
profileIndex: number,
|
||||||
|
scanLength: number,
|
||||||
|
includeNameMatchMetadata: boolean,
|
||||||
|
greedyNameScanEnabled: boolean,
|
||||||
|
currentCharacterDictionaryMediaId: number | null,
|
||||||
|
dictionaryPriorityByName: Record<string, number>,
|
||||||
|
dictionaryFrequencyModeByName: Partial<Record<string, YomitanFrequencyMode>>,
|
||||||
|
): string {
|
||||||
|
return `
|
||||||
|
(async () => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
${YOMITAN_SCANNING_HELPERS}
|
||||||
|
const includeNameMatchMetadata = ${includeNameMatchMetadata ? 'true' : 'false'};
|
||||||
|
const greedyNameScanEnabled = ${greedyNameScanEnabled ? 'true' : 'false'};
|
||||||
|
const currentCharacterDictionaryMediaId = ${
|
||||||
|
currentCharacterDictionaryMediaId !== null
|
||||||
|
? String(currentCharacterDictionaryMediaId)
|
||||||
|
: 'null'
|
||||||
|
};
|
||||||
|
const dictionaryPriorityByName = ${JSON.stringify(dictionaryPriorityByName)};
|
||||||
|
const dictionaryFrequencyModeByName = ${JSON.stringify(dictionaryFrequencyModeByName)};
|
||||||
|
const text = ${JSON.stringify(text)};
|
||||||
|
const details = {matchType: "exact", deinflect: true};
|
||||||
|
const tokens = [];
|
||||||
|
const termsFindCache = new Map();
|
||||||
|
async function termsFindAt(position, windowLength) {
|
||||||
|
const cacheKey = position + ":" + windowLength;
|
||||||
|
const cached = termsFindCache.get(cacheKey);
|
||||||
|
if (cached) { return cached; }
|
||||||
|
const substring = text.substring(position, position + windowLength);
|
||||||
|
const result = await invoke("termsFind", { text: substring, details, optionsContext: { index: ${profileIndex} } });
|
||||||
|
termsFindCache.set(cacheKey, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
// 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)) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let i = 0;
|
||||||
|
let nameIndex = 0;
|
||||||
|
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) {
|
||||||
|
tokens.push(nextNameToken);
|
||||||
|
i = nextNameToken.endPos;
|
||||||
|
nameIndex += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Cap the window at the next reserved name span so a generic match
|
||||||
|
// cannot consume into it.
|
||||||
|
const windowLength = nextNameToken ? Math.min(${scanLength}, nextNameToken.startPos - i) : ${scanLength};
|
||||||
|
let attempt = await findTokenAt(i, windowLength);
|
||||||
|
// 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.
|
||||||
|
let retryLength = Math.min(attempt.matchedLength, windowLength) - 1;
|
||||||
|
while (!attempt.token && retryLength >= 1) {
|
||||||
|
const retry = await findTokenAt(i, retryLength);
|
||||||
|
if (retry.token) {
|
||||||
|
attempt = retry;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
retryLength = Math.min(retryLength - 1, retry.matchedLength - 1);
|
||||||
|
}
|
||||||
|
if (attempt.token) {
|
||||||
|
tokens.push(attempt.token);
|
||||||
|
i += attempt.matchedLength;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i += String.fromCodePoint(text.codePointAt(i)).length;
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
})();
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requestYomitanParseResults(
|
export async function requestYomitanParseResults(
|
||||||
@@ -963,20 +1635,6 @@ export async function requestYomitanParseResults(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseText fallback for when the in-window scanner cannot run (script eval
|
|
||||||
// failure, unexpected payload). The scanner walk is the primary tokenizer and
|
|
||||||
// emits its own filler runs, so this extra full parse only happens on errors.
|
|
||||||
async function requestYomitanParseFallbackTokens(
|
|
||||||
text: string,
|
|
||||||
deps: YomitanParserRuntimeDeps,
|
|
||||||
logger: LoggerLike,
|
|
||||||
): Promise<YomitanScanToken[] | null> {
|
|
||||||
const parseResults = await requestYomitanParseResults(text, deps, logger);
|
|
||||||
const selectedTokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
|
|
||||||
const parseScanTokens = selectedTokens?.map(toYomitanScanToken) ?? null;
|
|
||||||
return parseScanTokens && parseScanTokens.length > 0 ? parseScanTokens : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requestYomitanScanTokens(
|
export async function requestYomitanScanTokens(
|
||||||
text: string,
|
text: string,
|
||||||
deps: YomitanParserRuntimeDeps,
|
deps: YomitanParserRuntimeDeps,
|
||||||
@@ -984,7 +1642,6 @@ export async function requestYomitanScanTokens(
|
|||||||
options?: {
|
options?: {
|
||||||
includeNameMatchMetadata?: boolean;
|
includeNameMatchMetadata?: boolean;
|
||||||
currentCharacterDictionaryMediaId?: number | null;
|
currentCharacterDictionaryMediaId?: number | null;
|
||||||
nameCandidates?: { key: string; forms: string[] } | null;
|
|
||||||
},
|
},
|
||||||
): Promise<YomitanScanToken[] | null> {
|
): Promise<YomitanScanToken[] | null> {
|
||||||
const yomitanExt = deps.getYomitanExt();
|
const yomitanExt = deps.getYomitanExt();
|
||||||
@@ -998,6 +1655,10 @@ export async function requestYomitanScanTokens(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parseResults = await requestYomitanParseResults(text, deps, logger);
|
||||||
|
const selectedParseTokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
|
||||||
|
const parseScanTokens = selectedParseTokens?.map(toYomitanScanToken) ?? null;
|
||||||
|
|
||||||
const metadata = await requestYomitanProfileMetadata(parserWindow, logger);
|
const metadata = await requestYomitanProfileMetadata(parserWindow, logger);
|
||||||
const profileIndex = metadata?.profileIndex ?? 0;
|
const profileIndex = metadata?.profileIndex ?? 0;
|
||||||
const scanLength = metadata?.scanLength ?? DEFAULT_YOMITAN_SCAN_LENGTH;
|
const scanLength = metadata?.scanLength ?? DEFAULT_YOMITAN_SCAN_LENGTH;
|
||||||
@@ -1008,63 +1669,44 @@ export async function requestYomitanScanTokens(
|
|||||||
name.startsWith(CHARACTER_DICTIONARY_TITLE_PREFIX),
|
name.startsWith(CHARACTER_DICTIONARY_TITLE_PREFIX),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Candidate name forms let the in-page pre-pass skip positions where no
|
try {
|
||||||
// character name can start. Installed only when it changes (per media), so
|
const rawResult = await parserWindow.webContents.executeJavaScript(
|
||||||
// the per-line call stays a single tiny script.
|
buildYomitanScanningScript(
|
||||||
const nameCandidates = greedyNameScanEnabled ? (options?.nameCandidates ?? null) : null;
|
|
||||||
await ensureYomitanScanNameCandidates(parserWindow, nameCandidates, logger);
|
|
||||||
|
|
||||||
const callScript = buildYomitanScanCallScript({
|
|
||||||
text,
|
text,
|
||||||
profileIndex,
|
profileIndex,
|
||||||
scanLength,
|
scanLength,
|
||||||
includeNameMatchMetadata,
|
includeNameMatchMetadata,
|
||||||
greedyNameScanEnabled,
|
greedyNameScanEnabled,
|
||||||
currentCharacterDictionaryMediaId:
|
|
||||||
typeof options?.currentCharacterDictionaryMediaId === 'number' &&
|
typeof options?.currentCharacterDictionaryMediaId === 'number' &&
|
||||||
Number.isFinite(options.currentCharacterDictionaryMediaId) &&
|
Number.isFinite(options.currentCharacterDictionaryMediaId) &&
|
||||||
options.currentCharacterDictionaryMediaId > 0
|
options.currentCharacterDictionaryMediaId > 0
|
||||||
? Math.floor(options.currentCharacterDictionaryMediaId)
|
? Math.floor(options.currentCharacterDictionaryMediaId)
|
||||||
: null,
|
: null,
|
||||||
dictionaryPriorityByName: metadata?.dictionaryPriorityByName ?? {},
|
metadata?.dictionaryPriorityByName ?? {},
|
||||||
dictionaryFrequencyModeByName: metadata?.dictionaryFrequencyModeByName ?? {},
|
metadata?.dictionaryFrequencyModeByName ?? {},
|
||||||
cacheEpoch: getYomitanScanCacheEpoch(parserWindow),
|
),
|
||||||
nameCandidateKey: nameCandidates?.key ?? null,
|
true,
|
||||||
});
|
);
|
||||||
|
|
||||||
try {
|
|
||||||
let rawResult = await parserWindow.webContents.executeJavaScript(callScript, true);
|
|
||||||
if (rawResult === YOMITAN_SCAN_RUNTIME_MISSING_SENTINEL) {
|
|
||||||
// First request for this window, or the page reloaded and dropped the
|
|
||||||
// installed runtime: install and retry once. The candidate list lives in
|
|
||||||
// the same page state, so it has to be reinstalled alongside it.
|
|
||||||
await installYomitanScanRuntime(parserWindow);
|
|
||||||
await ensureYomitanScanNameCandidates(parserWindow, nameCandidates, logger);
|
|
||||||
rawResult = await parserWindow.webContents.executeJavaScript(callScript, true);
|
|
||||||
}
|
|
||||||
// The scanner reports a line where a position ran out of shrinking-window
|
|
||||||
// retries: it stopped short of windows an uncapped ladder would have tried,
|
|
||||||
// so a real term may be sitting in an unparsed run. One parseText for the
|
|
||||||
// line is the bounded way to get the exhaustive answer back (this is the
|
|
||||||
// parse the scanner replaced, and it only runs for these rare lines).
|
|
||||||
if (isObject(rawResult) && rawResult.retryBudgetExhausted === true) {
|
|
||||||
logger.info?.('Yomitan scanner exhausted its retry budget; parsing the line as a fallback.');
|
|
||||||
const fallbackTokens = await requestYomitanParseFallbackTokens(text, deps, logger);
|
|
||||||
if (fallbackTokens) {
|
|
||||||
return fallbackTokens;
|
|
||||||
}
|
|
||||||
rawResult = rawResult.tokens;
|
|
||||||
}
|
|
||||||
if (isScanTokenArray(rawResult)) {
|
if (isScanTokenArray(rawResult)) {
|
||||||
// Filler-only results carry no dictionary match; keep the historical
|
if (parseScanTokens && parseScanTokens.length > 0) {
|
||||||
// contract of returning null so callers fall back to raw text.
|
return mergeScannerTokensIntoParseTokens(parseScanTokens, rawResult);
|
||||||
return rawResult.some((token) => token.isUnparsedRun !== true) ? rawResult : null;
|
|
||||||
}
|
}
|
||||||
logger.error('Yomitan scanner returned an unexpected payload; using parseText fallback.');
|
return rawResult;
|
||||||
return await requestYomitanParseFallbackTokens(text, deps, logger);
|
}
|
||||||
|
if (Array.isArray(rawResult)) {
|
||||||
|
const selectedTokens = selectYomitanParseTokens(rawResult, () => false, 'headword');
|
||||||
|
return selectedTokens?.map(toYomitanScanToken) ?? null;
|
||||||
|
}
|
||||||
|
if (parseScanTokens && parseScanTokens.length > 0) {
|
||||||
|
return parseScanTokens;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (parseScanTokens && parseScanTokens.length > 0) {
|
||||||
|
return parseScanTokens;
|
||||||
|
}
|
||||||
logger.error('Yomitan scanner request failed:', (err as Error).message);
|
logger.error('Yomitan scanner request failed:', (err as Error).message);
|
||||||
return await requestYomitanParseFallbackTokens(text, deps, logger);
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,521 +0,0 @@
|
|||||||
// 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 = 7;
|
|
||||||
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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Classification of a dictionary entry (which dictionaries it came from,
|
|
||||||
// which media ids it mentions) depends only on the entry object, so it is
|
|
||||||
// memoized for as long as that object lives. Entries are shared with the
|
|
||||||
// termsFind cache above, which is what makes this worth keeping: the same
|
|
||||||
// objects come back for every repeated lookup, on every line.
|
|
||||||
const dictionaryEntryNamesCache = new WeakMap();
|
|
||||||
const subMinerMediaIdsCache = new WeakMap();
|
|
||||||
const EMPTY_MEDIA_ID_SET = new Set();
|
|
||||||
// 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)});
|
|
||||||
})();
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
@@ -1,548 +0,0 @@
|
|||||||
// Helper bundle for the in-page Yomitan scan runtime: kana/furigana handling,
|
|
||||||
// headword preference, and frequency-rank resolution. Injected as text into the
|
|
||||||
// parser window by yomitan-scan-runtime-script.ts, so it is data here, not code
|
|
||||||
// this process runs.
|
|
||||||
import { HAN_CODE_POINT_RANGES } from '../../text/han-code-points';
|
|
||||||
|
|
||||||
export const CHARACTER_DICTIONARY_TITLE_PREFIX = 'SubMiner Character Dictionary';
|
|
||||||
|
|
||||||
export const YOMITAN_SCANNING_HELPERS = String.raw`
|
|
||||||
const HIRAGANA_CONVERSION_RANGE = [0x3041, 0x3096];
|
|
||||||
const KATAKANA_CONVERSION_RANGE = [0x30a1, 0x30f6];
|
|
||||||
const KANA_PROLONGED_SOUND_MARK_CODE_POINT = 0x30fc;
|
|
||||||
const KATAKANA_SMALL_KA_CODE_POINT = 0x30f5;
|
|
||||||
const KATAKANA_SMALL_KE_CODE_POINT = 0x30f6;
|
|
||||||
const KANA_RANGES = [[0x3040, 0x309f], [0x30a0, 0x30ff], [0xff66, 0xff9f]];
|
|
||||||
const HALFWIDTH_KATAKANA_RANGE = [0xff66, 0xff9d];
|
|
||||||
const HALFWIDTH_KANA_PROLONGED_SOUND_MARK_CODE_POINT = 0xff70;
|
|
||||||
// Folded one code point to one, so every index into a normalized string
|
|
||||||
// still lines up with the original text — the name-candidate prefilter
|
|
||||||
// and the furigana stem matching both index back into it. The standalone
|
|
||||||
// voiced marks (゙ ゚) have no one-character equivalent and stay as they are.
|
|
||||||
const HALFWIDTH_KATAKANA_TO_HIRAGANA = "をぁぃぅぇぉゃゅょっーあいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわん";
|
|
||||||
function convertHalfwidthKanaCodePointToHiragana(codePoint) {
|
|
||||||
if (codePoint < HALFWIDTH_KATAKANA_RANGE[0] || codePoint > HALFWIDTH_KATAKANA_RANGE[1]) { return null; }
|
|
||||||
return HALFWIDTH_KATAKANA_TO_HIRAGANA[codePoint - HALFWIDTH_KATAKANA_RANGE[0]] || null;
|
|
||||||
}
|
|
||||||
// Halfwidth katakana is kana here but not to the rest of the pipeline
|
|
||||||
// (known-word matching and frequency lookups only fold fullwidth), so a
|
|
||||||
// reading taken from halfwidth text is written the way the fullwidth
|
|
||||||
// katakana path already writes it. NFKC rather than the per-code-point
|
|
||||||
// table: this is the one place where nothing indexes back into the
|
|
||||||
// result, so a voiced pair (カ + ゙) can compose into the single ガ it
|
|
||||||
// means instead of leaving a stray combining mark in the reading. Scoped
|
|
||||||
// to the halfwidth runs, because NFKC over everything else rewrites
|
|
||||||
// characters that have nothing to do with kana (① → 1, ㍑ → リットル).
|
|
||||||
function convertHalfwidthKanaToKatakana(text) {
|
|
||||||
return text.replace(/[ヲ-゚]+/g, (run) => run.normalize("NFKC"));
|
|
||||||
}
|
|
||||||
// Han ranges come from the shared table so the scan walk and the character
|
|
||||||
// dictionary agree on what a kanji is (supplementary planes included).
|
|
||||||
// Halfwidth katakana counts as Japanese text: a name written that way has
|
|
||||||
// to reach the greedy pre-pass, which has its own handling for it.
|
|
||||||
const JAPANESE_RANGES = [[0x3040, 0x30ff], [0xff66, 0xff9f], ...${JSON.stringify(HAN_CODE_POINT_RANGES)}];
|
|
||||||
function isCodePointInRange(codePoint, range) { return codePoint >= range[0] && codePoint <= range[1]; }
|
|
||||||
function isCodePointInRanges(codePoint, ranges) { return ranges.some((range) => isCodePointInRange(codePoint, range)); }
|
|
||||||
function isCodePointKana(codePoint) { return isCodePointInRanges(codePoint, KANA_RANGES); }
|
|
||||||
function isCodePointJapanese(codePoint) { return isCodePointInRanges(codePoint, JAPANESE_RANGES); }
|
|
||||||
function createFuriganaSegment(text, reading) { return {text, reading}; }
|
|
||||||
function getSegmentReadingContribution(segment) {
|
|
||||||
if (typeof segment.reading === "string" && segment.reading.length > 0) { return segment.reading; }
|
|
||||||
const segmentText = typeof segment.text === "string" ? segment.text : "";
|
|
||||||
const isKanaOnly = segmentText.length > 0 && [...segmentText].every((char) => isCodePointKana(char.codePointAt(0)));
|
|
||||||
return isKanaOnly ? convertHalfwidthKanaToKatakana(segmentText) : "";
|
|
||||||
}
|
|
||||||
function getProlongedHiragana(previousCharacter) {
|
|
||||||
switch (previousCharacter) {
|
|
||||||
case "あ": case "か": case "が": case "さ": case "ざ": case "た": case "だ": case "な": case "は": case "ば": case "ぱ": case "ま": case "や": case "ら": case "わ": case "ぁ": case "ゃ": case "ゎ": return "あ";
|
|
||||||
case "い": case "き": case "ぎ": case "し": case "じ": case "ち": case "ぢ": case "に": case "ひ": case "び": case "ぴ": case "み": case "り": case "ぃ": return "い";
|
|
||||||
case "う": case "く": case "ぐ": case "す": case "ず": case "つ": case "づ": case "ぬ": case "ふ": case "ぶ": case "ぷ": case "む": case "ゆ": case "る": case "ぅ": case "ゅ": return "う";
|
|
||||||
case "え": case "け": case "げ": case "せ": case "ぜ": case "て": case "で": case "ね": case "へ": case "べ": case "ぺ": case "め": case "れ": case "ぇ": return "え";
|
|
||||||
case "お": case "こ": case "ご": case "そ": case "ぞ": case "と": case "ど": case "の": case "ほ": case "ぼ": case "ぽ": case "も": case "よ": case "ろ": case "を": case "ぉ": case "ょ": return "う";
|
|
||||||
default: return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function getFuriganaKanaSegments(text, reading) {
|
|
||||||
const newSegments = [];
|
|
||||||
let start = 0;
|
|
||||||
let state = (reading[0] === text[0]);
|
|
||||||
for (let i = 1; i < text.length; ++i) {
|
|
||||||
const newState = (reading[i] === text[i]);
|
|
||||||
if (state === newState) { continue; }
|
|
||||||
newSegments.push(createFuriganaSegment(text.substring(start, i), state ? '' : reading.substring(start, i)));
|
|
||||||
state = newState;
|
|
||||||
start = i;
|
|
||||||
}
|
|
||||||
newSegments.push(createFuriganaSegment(text.substring(start), state ? '' : reading.substring(start)));
|
|
||||||
return newSegments;
|
|
||||||
}
|
|
||||||
function convertKatakanaToHiragana(text, keepProlongedSoundMarks = false) {
|
|
||||||
let result = '';
|
|
||||||
const offset = (HIRAGANA_CONVERSION_RANGE[0] - KATAKANA_CONVERSION_RANGE[0]);
|
|
||||||
for (let char of text) {
|
|
||||||
const codePoint = char.codePointAt(0);
|
|
||||||
switch (codePoint) {
|
|
||||||
case KATAKANA_SMALL_KA_CODE_POINT:
|
|
||||||
case KATAKANA_SMALL_KE_CODE_POINT:
|
|
||||||
break;
|
|
||||||
case KANA_PROLONGED_SOUND_MARK_CODE_POINT:
|
|
||||||
case HALFWIDTH_KANA_PROLONGED_SOUND_MARK_CODE_POINT:
|
|
||||||
char = "ー";
|
|
||||||
if (!keepProlongedSoundMarks && result.length > 0) {
|
|
||||||
const char2 = getProlongedHiragana(result[result.length - 1]);
|
|
||||||
if (char2 !== null) { char = char2; }
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
if (isCodePointInRange(codePoint, KATAKANA_CONVERSION_RANGE)) {
|
|
||||||
char = String.fromCodePoint(codePoint + offset);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Halfwidth katakana folds too, or a name written that way would
|
|
||||||
// match neither a candidate form nor its own reading.
|
|
||||||
const halfwidthHiragana = convertHalfwidthKanaCodePointToHiragana(codePoint);
|
|
||||||
if (halfwidthHiragana !== null) { char = halfwidthHiragana; }
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
result += char;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
function segmentizeFurigana(reading, readingNormalized, groups, groupsStart) {
|
|
||||||
const groupCount = groups.length - groupsStart;
|
|
||||||
if (groupCount <= 0) { return reading.length === 0 ? [] : null; }
|
|
||||||
const group = groups[groupsStart];
|
|
||||||
const {isKana, text} = group;
|
|
||||||
if (isKana) {
|
|
||||||
if (group.textNormalized !== null && readingNormalized.startsWith(group.textNormalized)) {
|
|
||||||
const segments = segmentizeFurigana(reading.substring(text.length), readingNormalized.substring(text.length), groups, groupsStart + 1);
|
|
||||||
if (segments !== null) {
|
|
||||||
if (reading.startsWith(text)) { segments.unshift(createFuriganaSegment(text, '')); }
|
|
||||||
else { segments.unshift(...getFuriganaKanaSegments(text, reading)); }
|
|
||||||
return segments;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
let result = null;
|
|
||||||
for (let i = reading.length; i >= text.length; --i) {
|
|
||||||
const segments = segmentizeFurigana(reading.substring(i), readingNormalized.substring(i), groups, groupsStart + 1);
|
|
||||||
if (segments !== null) {
|
|
||||||
if (result !== null) { return null; }
|
|
||||||
segments.unshift(createFuriganaSegment(text, reading.substring(0, i)));
|
|
||||||
result = segments;
|
|
||||||
}
|
|
||||||
if (groupCount === 1) { break; }
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
function distributeFurigana(term, reading) {
|
|
||||||
if (reading === term) { return [createFuriganaSegment(term, '')]; }
|
|
||||||
const groups = [];
|
|
||||||
let groupPre = null;
|
|
||||||
let isKanaPre = null;
|
|
||||||
for (const c of term) {
|
|
||||||
const isKana = isCodePointKana(c.codePointAt(0));
|
|
||||||
if (isKana === isKanaPre) { groupPre.text += c; }
|
|
||||||
else {
|
|
||||||
groupPre = {isKana, text: c, textNormalized: null};
|
|
||||||
groups.push(groupPre);
|
|
||||||
isKanaPre = isKana;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const group of groups) {
|
|
||||||
if (group.isKana) { group.textNormalized = convertKatakanaToHiragana(group.text); }
|
|
||||||
}
|
|
||||||
const segments = segmentizeFurigana(reading, convertKatakanaToHiragana(reading), groups, 0);
|
|
||||||
return segments !== null ? segments : [createFuriganaSegment(term, reading)];
|
|
||||||
}
|
|
||||||
function getStemLength(text1, text2) {
|
|
||||||
const minLength = Math.min(text1.length, text2.length);
|
|
||||||
if (minLength === 0) { return 0; }
|
|
||||||
let i = 0;
|
|
||||||
while (true) {
|
|
||||||
const char1 = text1.codePointAt(i);
|
|
||||||
const char2 = text2.codePointAt(i);
|
|
||||||
if (char1 !== char2) { break; }
|
|
||||||
const charLength = String.fromCodePoint(char1).length;
|
|
||||||
i += charLength;
|
|
||||||
if (i >= minLength) {
|
|
||||||
if (i > minLength) { i -= charLength; }
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
function distributeFuriganaInflected(term, reading, source) {
|
|
||||||
const termNormalized = convertKatakanaToHiragana(term);
|
|
||||||
const readingNormalized = convertKatakanaToHiragana(reading);
|
|
||||||
const sourceNormalized = convertKatakanaToHiragana(source);
|
|
||||||
let mainText = term;
|
|
||||||
let stemLength = getStemLength(termNormalized, sourceNormalized);
|
|
||||||
const readingStemLength = getStemLength(readingNormalized, sourceNormalized);
|
|
||||||
if (readingStemLength > 0 && readingStemLength >= stemLength) {
|
|
||||||
mainText = reading;
|
|
||||||
stemLength = readingStemLength;
|
|
||||||
reading = source.substring(0, stemLength) + reading.substring(stemLength);
|
|
||||||
}
|
|
||||||
const segments = [];
|
|
||||||
if (stemLength > 0) {
|
|
||||||
mainText = source.substring(0, stemLength) + mainText.substring(stemLength);
|
|
||||||
const segments2 = distributeFurigana(mainText, reading);
|
|
||||||
let consumed = 0;
|
|
||||||
for (const segment of segments2) {
|
|
||||||
const start = consumed;
|
|
||||||
consumed += segment.text.length;
|
|
||||||
if (consumed < stemLength) { segments.push(segment); }
|
|
||||||
else if (consumed === stemLength) { segments.push(segment); break; }
|
|
||||||
else {
|
|
||||||
if (start < stemLength) { segments.push(createFuriganaSegment(mainText.substring(start, stemLength), '')); }
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (stemLength < source.length) {
|
|
||||||
const remainder = source.substring(stemLength);
|
|
||||||
const last = segments[segments.length - 1];
|
|
||||||
if (last && last.reading.length === 0) { last.text += remainder; }
|
|
||||||
else { segments.push(createFuriganaSegment(remainder, '')); }
|
|
||||||
}
|
|
||||||
return segments;
|
|
||||||
}
|
|
||||||
function parsePositiveFrequencyNumber(value) {
|
|
||||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
|
||||||
return Math.max(1, Math.floor(value));
|
|
||||||
}
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const numericMatch = value.trim().match(/[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?/)?.[0];
|
|
||||||
if (!numericMatch) { return null; }
|
|
||||||
const parsed = Number.parseFloat(numericMatch);
|
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) { return null; }
|
|
||||||
return Math.max(1, Math.floor(parsed));
|
|
||||||
}
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
for (const item of value) {
|
|
||||||
const parsed = parsePositiveFrequencyNumber(item);
|
|
||||||
if (parsed !== null) { return parsed; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
function parseDisplayFrequencyNumber(value) {
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const leadingDigits = value.trim().match(/^\d+/)?.[0];
|
|
||||||
if (!leadingDigits) { return null; }
|
|
||||||
const parsed = Number.parseInt(leadingDigits, 10);
|
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
||||||
}
|
|
||||||
return parsePositiveFrequencyNumber(value);
|
|
||||||
}
|
|
||||||
function getFrequencyDictionaryName(frequency) {
|
|
||||||
const candidates = [
|
|
||||||
frequency?.dictionary,
|
|
||||||
frequency?.dictionaryName,
|
|
||||||
frequency?.name,
|
|
||||||
frequency?.title,
|
|
||||||
frequency?.dictionaryTitle,
|
|
||||||
frequency?.dictionaryAlias
|
|
||||||
];
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
|
||||||
return candidate.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
function getBestFrequencyRank(dictionaryEntry, headwordIndex, dictionaryPriorityByName, dictionaryFrequencyModeByName) {
|
|
||||||
let best = null;
|
|
||||||
const headwordCount = Array.isArray(dictionaryEntry?.headwords) ? dictionaryEntry.headwords.length : 0;
|
|
||||||
for (const frequency of dictionaryEntry?.frequencies || []) {
|
|
||||||
if (!frequency || typeof frequency !== 'object') { continue; }
|
|
||||||
const frequencyHeadwordIndex = frequency.headwordIndex;
|
|
||||||
if (typeof frequencyHeadwordIndex === 'number') {
|
|
||||||
if (frequencyHeadwordIndex !== headwordIndex) { continue; }
|
|
||||||
} else if (headwordCount > 1) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const dictionary = getFrequencyDictionaryName(frequency);
|
|
||||||
if (!dictionary) { continue; }
|
|
||||||
if (dictionaryFrequencyModeByName[dictionary] === 'occurrence-based') { continue; }
|
|
||||||
const rank =
|
|
||||||
parseDisplayFrequencyNumber(frequency.displayValue) ??
|
|
||||||
parsePositiveFrequencyNumber(frequency.frequency);
|
|
||||||
if (rank === null) { continue; }
|
|
||||||
const priorityRaw = dictionaryPriorityByName[dictionary];
|
|
||||||
const fallbackPriority =
|
|
||||||
typeof frequency.dictionaryIndex === 'number' && Number.isFinite(frequency.dictionaryIndex)
|
|
||||||
? Math.max(0, Math.floor(frequency.dictionaryIndex))
|
|
||||||
: Number.MAX_SAFE_INTEGER;
|
|
||||||
const priority =
|
|
||||||
typeof priorityRaw === 'number' && Number.isFinite(priorityRaw)
|
|
||||||
? Math.max(0, Math.floor(priorityRaw))
|
|
||||||
: fallbackPriority;
|
|
||||||
if (best === null || priority < best.priority || (priority === best.priority && rank < best.rank)) {
|
|
||||||
best = { priority, rank };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best?.rank ?? null;
|
|
||||||
}
|
|
||||||
function hasExactSource(headword, token, requirePrimary) {
|
|
||||||
for (const src of headword.sources || []) {
|
|
||||||
if (src.originalText !== token) { continue; }
|
|
||||||
if (requirePrimary && !src.isPrimary) { continue; }
|
|
||||||
if (src.matchType !== 'exact') { continue; }
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
function collectExactHeadwordMatches(dictionaryEntries, token, requirePrimary) {
|
|
||||||
const matches = [];
|
|
||||||
for (const dictionaryEntry of dictionaryEntries || []) {
|
|
||||||
const headwords = Array.isArray(dictionaryEntry?.headwords) ? dictionaryEntry.headwords : [];
|
|
||||||
for (let headwordIndex = 0; headwordIndex < headwords.length; headwordIndex += 1) {
|
|
||||||
const headword = headwords[headwordIndex];
|
|
||||||
if (!hasExactSource(headword, token, requirePrimary)) { continue; }
|
|
||||||
matches.push({ dictionaryEntry, headword, headwordIndex });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
function sameHeadword(match, preferredMatch) {
|
|
||||||
if (!match || !preferredMatch) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (match.headword?.term !== preferredMatch.headword?.term) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const matchReading = typeof match.headword?.reading === 'string' ? match.headword.reading : '';
|
|
||||||
const preferredReading =
|
|
||||||
typeof preferredMatch.headword?.reading === 'string' ? preferredMatch.headword.reading : '';
|
|
||||||
if (!matchReading || !preferredReading) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return matchReading === preferredReading;
|
|
||||||
}
|
|
||||||
function getBestFrequencyRankForMatches(matches, dictionaryPriorityByName, dictionaryFrequencyModeByName) {
|
|
||||||
let best = null;
|
|
||||||
for (const match of matches) {
|
|
||||||
const rank = getBestFrequencyRank(
|
|
||||||
match.dictionaryEntry,
|
|
||||||
match.headwordIndex,
|
|
||||||
dictionaryPriorityByName,
|
|
||||||
dictionaryFrequencyModeByName
|
|
||||||
);
|
|
||||||
if (rank === null) { continue; }
|
|
||||||
if (best === null || rank < best) {
|
|
||||||
best = rank;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
function normalizeWordClasses(headword) {
|
|
||||||
if (!Array.isArray(headword?.wordClasses)) { return undefined; }
|
|
||||||
const classes = headword.wordClasses.filter((wordClass) => typeof wordClass === "string" && wordClass.trim().length > 0);
|
|
||||||
return classes.length > 0 ? classes : undefined;
|
|
||||||
}
|
|
||||||
function appendDictionaryNames(target, value) {
|
|
||||||
if (!value || typeof value !== 'object') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const candidates = [
|
|
||||||
value.dictionary,
|
|
||||||
value.dictionaryName,
|
|
||||||
value.name,
|
|
||||||
value.title,
|
|
||||||
value.dictionaryTitle,
|
|
||||||
value.dictionaryAlias
|
|
||||||
];
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
|
||||||
target.push(candidate.trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Memoized on the entry object: termsFind results are cached across
|
|
||||||
// lines, so the same entries come back for every repeated lookup, and
|
|
||||||
// each one is classified several times per scan (name pre-pass,
|
|
||||||
// headword preference, every retry window).
|
|
||||||
function getDictionaryEntryNames(entry) {
|
|
||||||
if (!entry || typeof entry !== 'object') { return []; }
|
|
||||||
const cached = dictionaryEntryNamesCache.get(entry);
|
|
||||||
if (cached !== undefined) { return cached; }
|
|
||||||
const names = [];
|
|
||||||
appendDictionaryNames(names, entry);
|
|
||||||
for (const definition of entry?.definitions || []) {
|
|
||||||
appendDictionaryNames(names, definition);
|
|
||||||
}
|
|
||||||
for (const frequency of entry?.frequencies || []) {
|
|
||||||
appendDictionaryNames(names, frequency);
|
|
||||||
}
|
|
||||||
for (const pronunciation of entry?.pronunciations || []) {
|
|
||||||
appendDictionaryNames(names, pronunciation);
|
|
||||||
}
|
|
||||||
dictionaryEntryNamesCache.set(entry, names);
|
|
||||||
return names;
|
|
||||||
}
|
|
||||||
// Cached per scan rather than per runtime: the answer depends on
|
|
||||||
// includeNameMatchMetadata, which is a per-call parameter.
|
|
||||||
const nameDictionaryEntryCache = new WeakMap();
|
|
||||||
function isNameDictionaryEntry(entry) {
|
|
||||||
if (!includeNameMatchMetadata || !entry || typeof entry !== 'object') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const cached = nameDictionaryEntryCache.get(entry);
|
|
||||||
if (cached !== undefined) { return cached; }
|
|
||||||
const isName = getDictionaryEntryNames(entry).some((name) => name.startsWith(${JSON.stringify(CHARACTER_DICTIONARY_TITLE_PREFIX)}));
|
|
||||||
nameDictionaryEntryCache.set(entry, isName);
|
|
||||||
return isName;
|
|
||||||
}
|
|
||||||
function parseSubMinerMediaIdFromString(value) {
|
|
||||||
const imageMatch = value.match(/\bimg\/m(\d+)-/i);
|
|
||||||
if (imageMatch) {
|
|
||||||
const parsed = Number.parseInt(imageMatch[1], 10);
|
|
||||||
if (Number.isSafeInteger(parsed) && parsed > 0) { return parsed; }
|
|
||||||
}
|
|
||||||
const titleMatch = value.match(/${CHARACTER_DICTIONARY_TITLE_PREFIX}[^\d]*(?:AniList\s*)?(\d+)/i);
|
|
||||||
if (titleMatch) {
|
|
||||||
const parsed = Number.parseInt(titleMatch[1], 10);
|
|
||||||
if (Number.isSafeInteger(parsed) && parsed > 0) { return parsed; }
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
function parseSubMinerMediaIdCandidate(value) {
|
|
||||||
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (typeof value === 'string' && /^\d+$/.test(value.trim())) {
|
|
||||||
const parsed = Number.parseInt(value.trim(), 10);
|
|
||||||
if (Number.isSafeInteger(parsed) && parsed > 0) { return parsed; }
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
function collectSubMinerMediaIds(value, target) {
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const parsed = parseSubMinerMediaIdFromString(value);
|
|
||||||
if (parsed !== null) { target.add(parsed); }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!value || typeof value !== 'object') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
for (const item of value) { collectSubMinerMediaIds(item, target); }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const mediaIdCandidates = [
|
|
||||||
value.subminerMediaId,
|
|
||||||
value.subMinerMediaId,
|
|
||||||
value.characterDictionaryMediaId,
|
|
||||||
value.data?.subminerMediaId,
|
|
||||||
value.data?.subMinerMediaId,
|
|
||||||
value.data?.characterDictionaryMediaId
|
|
||||||
];
|
|
||||||
for (const candidate of mediaIdCandidates) {
|
|
||||||
const parsed = parseSubMinerMediaIdCandidate(candidate);
|
|
||||||
if (parsed !== null) { target.add(parsed); }
|
|
||||||
}
|
|
||||||
for (const child of Object.values(value)) {
|
|
||||||
collectSubMinerMediaIds(child, target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Walking an entry collects media ids from every nested value, so this
|
|
||||||
// is the most expensive classification step; memoized on the entry for
|
|
||||||
// the same reason as the dictionary names above.
|
|
||||||
function getSubMinerMediaIds(entry) {
|
|
||||||
if (!entry || typeof entry !== 'object') { return EMPTY_MEDIA_ID_SET; }
|
|
||||||
const cached = subMinerMediaIdsCache.get(entry);
|
|
||||||
if (cached !== undefined) { return cached; }
|
|
||||||
const mediaIds = new Set();
|
|
||||||
collectSubMinerMediaIds(entry, mediaIds);
|
|
||||||
subMinerMediaIdsCache.set(entry, mediaIds);
|
|
||||||
return mediaIds;
|
|
||||||
}
|
|
||||||
function isCurrentMediaNameDictionaryEntry(entry) {
|
|
||||||
if (!isNameDictionaryEntry(entry)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (currentCharacterDictionaryMediaId === null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const mediaIds = getSubMinerMediaIds(entry);
|
|
||||||
return mediaIds.size === 0 || mediaIds.has(currentCharacterDictionaryMediaId);
|
|
||||||
}
|
|
||||||
function findLongestNameMatch(dictionaryEntries, textWindow) {
|
|
||||||
let best = null;
|
|
||||||
for (const dictionaryEntry of dictionaryEntries || []) {
|
|
||||||
if (!isCurrentMediaNameDictionaryEntry(dictionaryEntry)) { continue; }
|
|
||||||
const headwords = Array.isArray(dictionaryEntry?.headwords) ? dictionaryEntry.headwords : [];
|
|
||||||
for (let headwordIndex = 0; headwordIndex < headwords.length; headwordIndex += 1) {
|
|
||||||
const headword = headwords[headwordIndex];
|
|
||||||
for (const src of headword?.sources || []) {
|
|
||||||
if (src.matchType !== 'exact' || src.isPrimary !== true) { continue; }
|
|
||||||
const originalText = typeof src.originalText === 'string' ? src.originalText : '';
|
|
||||||
if (!originalText || !textWindow.startsWith(originalText)) { continue; }
|
|
||||||
if (best === null || originalText.length > best.sourceLength) {
|
|
||||||
best = { dictionaryEntry, headword, headwordIndex, sourceLength: originalText.length };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
function findLongestGenericMatchLength(dictionaryEntries, textWindow) {
|
|
||||||
let best = 0;
|
|
||||||
for (const dictionaryEntry of dictionaryEntries || []) {
|
|
||||||
if (isNameDictionaryEntry(dictionaryEntry)) { continue; }
|
|
||||||
const headwords = Array.isArray(dictionaryEntry?.headwords) ? dictionaryEntry.headwords : [];
|
|
||||||
for (const headword of headwords) {
|
|
||||||
for (const src of headword?.sources || []) {
|
|
||||||
if (src.matchType !== 'exact' || src.isPrimary !== true) { continue; }
|
|
||||||
const originalText = typeof src.originalText === 'string' ? src.originalText : '';
|
|
||||||
if (!originalText || !textWindow.startsWith(originalText)) { continue; }
|
|
||||||
if (originalText.length > best) { best = originalText.length; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
function getPreferredHeadword(dictionaryEntries, token, dictionaryPriorityByName, dictionaryFrequencyModeByName) {
|
|
||||||
const currentMediaDictionaryEntries =
|
|
||||||
currentCharacterDictionaryMediaId === null
|
|
||||||
? (dictionaryEntries || [])
|
|
||||||
: (dictionaryEntries || []).filter((entry) => {
|
|
||||||
if (!isNameDictionaryEntry(entry)) { return true; }
|
|
||||||
return isCurrentMediaNameDictionaryEntry(entry);
|
|
||||||
});
|
|
||||||
const exactPrimaryMatches = collectExactHeadwordMatches(currentMediaDictionaryEntries, token, true);
|
|
||||||
let matchedNameDictionary = false;
|
|
||||||
if (includeNameMatchMetadata) {
|
|
||||||
for (const dictionaryEntry of currentMediaDictionaryEntries || []) {
|
|
||||||
if (!isCurrentMediaNameDictionaryEntry(dictionaryEntry)) { continue; }
|
|
||||||
for (const match of exactPrimaryMatches) {
|
|
||||||
if (match.dictionaryEntry !== dictionaryEntry) { continue; }
|
|
||||||
matchedNameDictionary = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (matchedNameDictionary) { break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const preferredMatch = exactPrimaryMatches[0];
|
|
||||||
if (preferredMatch) {
|
|
||||||
const exactFrequencyMatches = collectExactHeadwordMatches(currentMediaDictionaryEntries, token, false)
|
|
||||||
.filter((match) => sameHeadword(match, preferredMatch));
|
|
||||||
return {
|
|
||||||
term: preferredMatch.headword.term,
|
|
||||||
reading: preferredMatch.headword.reading,
|
|
||||||
wordClasses: normalizeWordClasses(preferredMatch.headword),
|
|
||||||
isNameMatch:
|
|
||||||
matchedNameDictionary || isCurrentMediaNameDictionaryEntry(preferredMatch.dictionaryEntry),
|
|
||||||
frequencyRank: getBestFrequencyRankForMatches(
|
|
||||||
exactFrequencyMatches.length > 0 ? exactFrequencyMatches : exactPrimaryMatches,
|
|
||||||
dictionaryPriorityByName,
|
|
||||||
dictionaryFrequencyModeByName
|
|
||||||
)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
import { HAN_CODE_POINT_RANGES, HAN_REGEXP_CLASS_BODY, isHanCodePoint } from './han-code-points';
|
|
||||||
|
|
||||||
test('every range boundary is inside the table', () => {
|
|
||||||
for (const [start, end] of HAN_CODE_POINT_RANGES) {
|
|
||||||
for (const codePoint of [start, end]) {
|
|
||||||
assert.ok(isHanCodePoint(codePoint), `expected U+${codePoint.toString(16)} to be Han`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extension J (Unicode 17) and the Compatibility blocks are the ones a
|
|
||||||
// BMP-only table used to miss.
|
|
||||||
assert.ok(isHanCodePoint(0x323b0));
|
|
||||||
assert.ok(isHanCodePoint(0x33479));
|
|
||||||
assert.ok(isHanCodePoint(0xf900));
|
|
||||||
assert.ok(isHanCodePoint(0x2f800));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no unified ideograph the runtime knows about falls outside the table', () => {
|
|
||||||
// One direction only: a runtime with older Unicode data simply checks fewer
|
|
||||||
// code points, where asserting the reverse would fail on Extension J.
|
|
||||||
const unifiedIdeograph = /\p{Unified_Ideograph}/u;
|
|
||||||
|
|
||||||
for (let codePoint = 0x3000; codePoint <= 0x40000; codePoint += 1) {
|
|
||||||
if (unifiedIdeograph.test(String.fromCodePoint(codePoint))) {
|
|
||||||
assert.ok(
|
|
||||||
isHanCodePoint(codePoint),
|
|
||||||
`expected unified ideograph U+${codePoint.toString(16)} to be in the table`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('code points just outside the table are rejected', () => {
|
|
||||||
for (const codePoint of [0x33ff, 0x4dc0, 0xa000, 0x1f000, 0x3347a]) {
|
|
||||||
assert.equal(
|
|
||||||
isHanCodePoint(codePoint),
|
|
||||||
false,
|
|
||||||
`expected U+${codePoint.toString(16)} not to be Han`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the regexp class body matches the same code points as the predicate', () => {
|
|
||||||
const classRegExp = new RegExp(`^[${HAN_REGEXP_CLASS_BODY}]$`, 'u');
|
|
||||||
|
|
||||||
for (const codePoint of [0x3400, 0x4e00, 0x9fff, 0xf900, 0x20000, 0x323b0, 0x33479]) {
|
|
||||||
assert.match(String.fromCodePoint(codePoint), classRegExp);
|
|
||||||
}
|
|
||||||
for (const codePoint of [0x3040, 0x30ff, 0x33fa, 0x3347a]) {
|
|
||||||
assert.doesNotMatch(String.fromCodePoint(codePoint), classRegExp);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
// Single source of truth for "this code point is a Han character", shared by
|
|
||||||
// the main-process character dictionary and the in-page Yomitan scan runtime.
|
|
||||||
// The two used to carry separate range lists, and they drifted: a name written
|
|
||||||
// with a supplementary-plane kanji could enter the generated dictionary while
|
|
||||||
// the scanner's greedy name pre-pass refused to probe the position.
|
|
||||||
//
|
|
||||||
// Ranges rather than \p{Script=Han}: the scan walk tests one code point per
|
|
||||||
// character of every subtitle line, where an integer compare beats building a
|
|
||||||
// string for a regex, and the script is injected as text into a page where a
|
|
||||||
// shared helper cannot be imported.
|
|
||||||
export const HAN_CODE_POINT_RANGES: ReadonlyArray<readonly [number, number]> = [
|
|
||||||
[0x3400, 0x4dbf], // Extension A
|
|
||||||
[0x4e00, 0x9fff], // CJK Unified Ideographs
|
|
||||||
[0xf900, 0xfaff], // Compatibility Ideographs
|
|
||||||
[0x20000, 0x2a6df], // Extension B
|
|
||||||
[0x2a700, 0x2ebef], // Extensions C-F
|
|
||||||
[0x2ebf0, 0x2ee5f], // Extension I
|
|
||||||
[0x2f800, 0x2fa1f], // Compatibility Ideographs Supplement
|
|
||||||
[0x30000, 0x3134f], // Extension G
|
|
||||||
[0x31350, 0x323af], // Extension H
|
|
||||||
[0x323b0, 0x33479], // Extension J (Unicode 17)
|
|
||||||
];
|
|
||||||
|
|
||||||
export function isHanCodePoint(codePoint: number): boolean {
|
|
||||||
return HAN_CODE_POINT_RANGES.some(([start, end]) => codePoint >= start && codePoint <= end);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The same ranges as a regular expression character class body (needs the `u` flag). */
|
|
||||||
export const HAN_REGEXP_CLASS_BODY = HAN_CODE_POINT_RANGES.map(
|
|
||||||
([start, end]) => `\\u{${start.toString(16)}}-\\u{${end.toString(16)}}`,
|
|
||||||
).join('');
|
|
||||||
+9
-44
@@ -487,7 +487,6 @@ import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility
|
|||||||
import { createDiscordPresenceRuntime } from './main/runtime/discord-presence-runtime';
|
import { createDiscordPresenceRuntime } from './main/runtime/discord-presence-runtime';
|
||||||
import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime';
|
import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime';
|
||||||
import { createCharacterDictionaryImageLookup } from './main/character-dictionary-runtime/image-lookup';
|
import { createCharacterDictionaryImageLookup } from './main/character-dictionary-runtime/image-lookup';
|
||||||
import { createCharacterNameCandidateLookup } from './main/character-dictionary-runtime/name-candidates';
|
|
||||||
import {
|
import {
|
||||||
createCharacterDictionaryAutoSyncRuntimeService,
|
createCharacterDictionaryAutoSyncRuntimeService,
|
||||||
getCharacterDictionaryManagerSnapshot,
|
getCharacterDictionaryManagerSnapshot,
|
||||||
@@ -1817,7 +1816,7 @@ function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
|
|||||||
endTime: appState.mpvClient?.currentSubEnd ?? null,
|
endTime: appState.mpvClient?.currentSubEnd ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function emitSubtitlePayload(payload: SubtitleData, options?: { resumePrefetch?: boolean }): void {
|
function emitSubtitlePayload(payload: SubtitleData): void {
|
||||||
const timedPayload = withCurrentSubtitleTiming(payload);
|
const timedPayload = withCurrentSubtitleTiming(payload);
|
||||||
const currentSubtitleData = appState.currentSubtitleData;
|
const currentSubtitleData = appState.currentSubtitleData;
|
||||||
const isAnnotationUpgrade = isSubtitleAnnotationUpgrade(currentSubtitleData, timedPayload);
|
const isAnnotationUpgrade = isSubtitleAnnotationUpgrade(currentSubtitleData, timedPayload);
|
||||||
@@ -1834,13 +1833,7 @@ function emitSubtitlePayload(payload: SubtitleData, options?: { resumePrefetch?:
|
|||||||
}
|
}
|
||||||
annotationSubtitleWsService.broadcast(timedPayload, frequencyOptions);
|
annotationSubtitleWsService.broadcast(timedPayload, frequencyOptions);
|
||||||
autoplayReadyGate.maybeSignalPluginAutoplayReady(timedPayload, { forceWhilePaused: true });
|
autoplayReadyGate.maybeSignalPluginAutoplayReady(timedPayload, { forceWhilePaused: true });
|
||||||
// resumePrefetch: false marks an emit that is not the end of the work for
|
|
||||||
// this line; prefetch stays paused until the subtitle processing controller
|
|
||||||
// settles so it does not compete with the on-screen line for the single
|
|
||||||
// Yomitan parser window.
|
|
||||||
if (options?.resumePrefetch !== false) {
|
|
||||||
subtitlePrefetchService?.resume();
|
subtitlePrefetchService?.resume();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function getCurrentAutoplaySubtitlePayload(): SubtitleData | null {
|
function getCurrentAutoplaySubtitlePayload(): SubtitleData | null {
|
||||||
const payload = appState.currentSubtitleData;
|
const payload = appState.currentSubtitleData;
|
||||||
@@ -1896,17 +1889,7 @@ const buildSubtitleProcessingControllerMainDepsHandler =
|
|||||||
createBuildSubtitleProcessingControllerMainDepsHandler({
|
createBuildSubtitleProcessingControllerMainDepsHandler({
|
||||||
tokenizeSubtitle: async (text: string) =>
|
tokenizeSubtitle: async (text: string) =>
|
||||||
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : { text, tokens: null },
|
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : { text, tokens: null },
|
||||||
// Controller emits never release the prefetch pause: the first emit for an
|
emitSubtitle: (payload) => emitSubtitlePayload(payload),
|
||||||
// uncached line is the provisional plain payload, sent before tokenization
|
|
||||||
// starts, so resuming on it would put prefetch back in contention with the
|
|
||||||
// on-screen line for the single parser window.
|
|
||||||
emitSubtitle: (payload) => emitSubtitlePayload(payload, { resumePrefetch: false }),
|
|
||||||
// The pause is released once the controller has no work left, which covers
|
|
||||||
// the runs that end without an emit (suppressed duplicate, failed
|
|
||||||
// tokenization) as well as the ones that deliver a payload.
|
|
||||||
onProcessingSettled: () => {
|
|
||||||
subtitlePrefetchService?.resume();
|
|
||||||
},
|
|
||||||
logDebug: (message) => {
|
logDebug: (message) => {
|
||||||
logger.debug(`[subtitle-processing] ${message}`);
|
logger.debug(`[subtitle-processing] ${message}`);
|
||||||
},
|
},
|
||||||
@@ -1943,7 +1926,7 @@ const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({
|
|||||||
appState.activeParsedSubtitleMediaPath = mediaPath;
|
appState.activeParsedSubtitleMediaPath = mediaPath;
|
||||||
},
|
},
|
||||||
subtitleProcessingController,
|
subtitleProcessingController,
|
||||||
emitSubtitlePayload: (payload, options) => emitSubtitlePayload(payload, options),
|
emitSubtitlePayload: (payload) => emitSubtitlePayload(payload),
|
||||||
getSubtitlePrefetchService: () => subtitlePrefetchService,
|
getSubtitlePrefetchService: () => subtitlePrefetchService,
|
||||||
getLastObservedTimePos: () => lastObservedTimePos,
|
getLastObservedTimePos: () => lastObservedTimePos,
|
||||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||||
@@ -2549,10 +2532,6 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
hasParserWindow: () => Boolean(appState.yomitanParserWindow),
|
hasParserWindow: () => Boolean(appState.yomitanParserWindow),
|
||||||
invalidateCharacterDictionaryLookups: () => {
|
|
||||||
characterDictionaryImageLookup.invalidate();
|
|
||||||
characterNameCandidateLookup.invalidate();
|
|
||||||
},
|
|
||||||
clearParserCaches: () => {
|
clearParserCaches: () => {
|
||||||
if (appState.yomitanParserWindow) {
|
if (appState.yomitanParserWindow) {
|
||||||
clearYomitanParserCachesForWindow(appState.yomitanParserWindow);
|
clearYomitanParserCachesForWindow(appState.yomitanParserWindow);
|
||||||
@@ -2578,13 +2557,6 @@ const characterDictionaryImageLookup = createCharacterDictionaryImageLookup({
|
|||||||
getCurrentMediaId: () => characterDictionaryAutoSyncRuntime.getCurrentMediaId(),
|
getCurrentMediaId: () => characterDictionaryAutoSyncRuntime.getCurrentMediaId(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Lets the Yomitan scan runtime skip name lookups at positions where no
|
|
||||||
// character name can start; absent candidates just mean the exhaustive scan.
|
|
||||||
const characterNameCandidateLookup = createCharacterNameCandidateLookup({
|
|
||||||
userDataPath: USER_DATA_PATH,
|
|
||||||
getCurrentMediaId: () => characterDictionaryAutoSyncRuntime.getCurrentMediaId(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
||||||
createBuildOverlayVisibilityRuntimeMainDepsHandler({
|
createBuildOverlayVisibilityRuntimeMainDepsHandler({
|
||||||
getMainWindow: () => overlayManager.getMainWindow(),
|
getMainWindow: () => overlayManager.getMainWindow(),
|
||||||
@@ -3999,10 +3971,7 @@ const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => {
|
|||||||
}
|
}
|
||||||
subtitleProcessingController.invalidateTokenizationCache();
|
subtitleProcessingController.invalidateTokenizationCache();
|
||||||
subtitlePrefetchService?.onSeek(lastObservedTimePos);
|
subtitlePrefetchService?.onSeek(lastObservedTimePos);
|
||||||
if (!subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText)) {
|
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
||||||
// Idle controller: no settle is coming to release the pause above.
|
|
||||||
subtitlePrefetchService?.resume();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let hasAttemptedImmersionTrackerStartup = false;
|
let hasAttemptedImmersionTrackerStartup = false;
|
||||||
const ensureImmersionTrackerStarted = (): void => {
|
const ensureImmersionTrackerStarted = (): void => {
|
||||||
@@ -4403,15 +4372,9 @@ const {
|
|||||||
emitSubtitlePayload(payload);
|
emitSubtitlePayload(payload);
|
||||||
},
|
},
|
||||||
onSubtitleChange: (text) => {
|
onSubtitleChange: (text) => {
|
||||||
// Pause only; restarting the prefetch run here would discard in-flight
|
|
||||||
// tokenization work on every line. Real seeks restart via onTimePosUpdate.
|
|
||||||
subtitlePrefetchService?.pause();
|
subtitlePrefetchService?.pause();
|
||||||
if (!subtitleProcessingController.onSubtitleChange(text)) {
|
subtitlePrefetchService?.onSeek(lastObservedTimePos);
|
||||||
// Repeat of the current text: the controller is idle, so no settle is
|
subtitleProcessingController.onSubtitleChange(text);
|
||||||
// coming to release the pause. Resume now instead of idling prefetch
|
|
||||||
// for the rest of the cue.
|
|
||||||
subtitlePrefetchService?.resume();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
refreshDiscordPresence: () => {
|
refreshDiscordPresence: () => {
|
||||||
discordPresenceRuntime.publishDiscordPresence();
|
discordPresenceRuntime.publishDiscordPresence();
|
||||||
@@ -4655,7 +4618,6 @@ const {
|
|||||||
getCharacterNameImage: (term) => characterDictionaryImageLookup.get(term),
|
getCharacterNameImage: (term) => characterDictionaryImageLookup.get(term),
|
||||||
getCurrentCharacterDictionaryMediaId: () =>
|
getCurrentCharacterDictionaryMediaId: () =>
|
||||||
characterDictionaryAutoSyncRuntime.getCurrentMediaId(),
|
characterDictionaryAutoSyncRuntime.getCurrentMediaId(),
|
||||||
getCharacterNameCandidates: () => characterNameCandidateLookup.get(),
|
|
||||||
getFrequencyDictionaryEnabled: () =>
|
getFrequencyDictionaryEnabled: () =>
|
||||||
getRuntimeBooleanOption(
|
getRuntimeBooleanOption(
|
||||||
'subtitle.annotation.frequency',
|
'subtitle.annotation.frequency',
|
||||||
@@ -5710,6 +5672,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
if (result.ok && result.rebuildRequired) {
|
if (result.ok && result.rebuildRequired) {
|
||||||
try {
|
try {
|
||||||
await characterDictionaryAutoSyncRuntime.runSyncNow();
|
await characterDictionaryAutoSyncRuntime.runSyncNow();
|
||||||
|
characterDictionaryImageLookup.invalidate();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Failed to rebuild character dictionary after manager override:', error);
|
logger.warn('Failed to rebuild character dictionary after manager override:', error);
|
||||||
}
|
}
|
||||||
@@ -5740,6 +5703,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
if (result.ok && result.rebuildRequired) {
|
if (result.ok && result.rebuildRequired) {
|
||||||
try {
|
try {
|
||||||
await characterDictionaryAutoSyncRuntime.runSyncNow();
|
await characterDictionaryAutoSyncRuntime.runSyncNow();
|
||||||
|
characterDictionaryImageLookup.invalidate();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Failed to rebuild character dictionary after manager removal:', error);
|
logger.warn('Failed to rebuild character dictionary after manager removal:', error);
|
||||||
}
|
}
|
||||||
@@ -5756,6 +5720,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
if (result.ok && result.rebuildRequired) {
|
if (result.ok && result.rebuildRequired) {
|
||||||
try {
|
try {
|
||||||
await characterDictionaryAutoSyncRuntime.runSyncNow();
|
await characterDictionaryAutoSyncRuntime.runSyncNow();
|
||||||
|
characterDictionaryImageLookup.invalidate();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Failed to rebuild character dictionary after manager reorder:', error);
|
logger.warn('Failed to rebuild character dictionary after manager reorder:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
export const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||||
export const ANILIST_REQUEST_DELAY_MS = 2000;
|
export const ANILIST_REQUEST_DELAY_MS = 2000;
|
||||||
export const CHARACTER_IMAGE_DOWNLOAD_DELAY_MS = 250;
|
export const CHARACTER_IMAGE_DOWNLOAD_DELAY_MS = 250;
|
||||||
export const CHARACTER_DICTIONARY_FORMAT_VERSION = 20;
|
export const CHARACTER_DICTIONARY_FORMAT_VERSION = 19;
|
||||||
export const CHARACTER_DICTIONARY_MERGED_TITLE = 'SubMiner Character Dictionary';
|
export const CHARACTER_DICTIONARY_MERGED_TITLE = 'SubMiner Character Dictionary';
|
||||||
|
|
||||||
export const HONORIFIC_SUFFIXES = [
|
export const HONORIFIC_SUFFIXES = [
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as os from 'os';
|
|
||||||
import * as path from 'path';
|
|
||||||
import test from 'node:test';
|
|
||||||
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
|
|
||||||
import { createCharacterNameCandidateLookup } from './name-candidates';
|
|
||||||
|
|
||||||
function writeSnapshot(outputDir: string, mediaId: number, entries: Array<[string, string]>): void {
|
|
||||||
const snapshotsDir = path.join(outputDir, 'snapshots');
|
|
||||||
fs.mkdirSync(snapshotsDir, { recursive: true });
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(snapshotsDir, `anilist-${mediaId}.json`),
|
|
||||||
JSON.stringify({
|
|
||||||
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
|
|
||||||
mediaId,
|
|
||||||
mediaTitle: `title-${mediaId}`,
|
|
||||||
entryCount: entries.length,
|
|
||||||
updatedAt: 1,
|
|
||||||
termEntries: entries.map(([term, reading]) => [
|
|
||||||
term,
|
|
||||||
reading,
|
|
||||||
'name main',
|
|
||||||
'',
|
|
||||||
100,
|
|
||||||
[],
|
|
||||||
0,
|
|
||||||
'',
|
|
||||||
]),
|
|
||||||
images: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function withTempDir<T>(run: (dir: string) => T): T {
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-name-candidates-'));
|
|
||||||
try {
|
|
||||||
return run(dir);
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test('collects terms and readings for the current media', () => {
|
|
||||||
withTempDir((dir) => {
|
|
||||||
writeSnapshot(dir, 1, [
|
|
||||||
['ミナト', 'みなと'],
|
|
||||||
['湊', 'みなと'],
|
|
||||||
]);
|
|
||||||
writeSnapshot(dir, 2, [['カズマ', 'かずま']]);
|
|
||||||
|
|
||||||
const lookup = createCharacterNameCandidateLookup({
|
|
||||||
outputDir: dir,
|
|
||||||
getCurrentMediaId: () => 1,
|
|
||||||
});
|
|
||||||
const candidates = lookup.get();
|
|
||||||
|
|
||||||
assert.ok(candidates);
|
|
||||||
assert.deepEqual([...candidates.forms].sort(), ['みなと', 'ミナト', '湊'].sort());
|
|
||||||
// Deduplicated: both entries share the みなと reading.
|
|
||||||
assert.equal(candidates.forms.length, 3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns null without a media scope so the scanner stays exhaustive', () => {
|
|
||||||
withTempDir((dir) => {
|
|
||||||
writeSnapshot(dir, 1, [['ミナト', 'みなと']]);
|
|
||||||
|
|
||||||
const lookup = createCharacterNameCandidateLookup({
|
|
||||||
outputDir: dir,
|
|
||||||
getCurrentMediaId: () => null,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(lookup.get(), null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns null for a media with no cached snapshot', () => {
|
|
||||||
withTempDir((dir) => {
|
|
||||||
writeSnapshot(dir, 1, [['ミナト', 'みなと']]);
|
|
||||||
|
|
||||||
const lookup = createCharacterNameCandidateLookup({
|
|
||||||
outputDir: dir,
|
|
||||||
getCurrentMediaId: () => 999,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(lookup.get(), null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('key changes when the snapshot content changes', () => {
|
|
||||||
withTempDir((dir) => {
|
|
||||||
writeSnapshot(dir, 1, [['ミナト', 'みなと']]);
|
|
||||||
const lookup = createCharacterNameCandidateLookup({
|
|
||||||
outputDir: dir,
|
|
||||||
getCurrentMediaId: () => 1,
|
|
||||||
});
|
|
||||||
const first = lookup.get();
|
|
||||||
|
|
||||||
writeSnapshot(dir, 1, [
|
|
||||||
['ミナト', 'みなと'],
|
|
||||||
['アクア', 'あくあ'],
|
|
||||||
]);
|
|
||||||
lookup.invalidate();
|
|
||||||
const second = lookup.get();
|
|
||||||
|
|
||||||
assert.ok(first && second);
|
|
||||||
assert.notEqual(first.key, second.key);
|
|
||||||
assert.equal(second.forms.length, 4);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// The lookup runs once per subtitle line, so it must not stat the snapshot
|
|
||||||
// directory every call. Asserted behaviorally: an unannounced on-disk change is
|
|
||||||
// invisible until the recheck interval elapses, which can only be true if the
|
|
||||||
// filesystem is not consulted per lookup.
|
|
||||||
test('does not re-read the snapshot directory on every lookup', () => {
|
|
||||||
withTempDir((dir) => {
|
|
||||||
writeSnapshot(dir, 1, [['ミナト', 'みなと']]);
|
|
||||||
let nowMs = 1_000_000;
|
|
||||||
const lookup = createCharacterNameCandidateLookup({
|
|
||||||
outputDir: dir,
|
|
||||||
getCurrentMediaId: () => 1,
|
|
||||||
now: () => nowMs,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(lookup.get()?.forms.length, 2);
|
|
||||||
|
|
||||||
writeSnapshot(dir, 1, [
|
|
||||||
['ミナト', 'みなと'],
|
|
||||||
['アクア', 'あくあ'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
nowMs += 1000;
|
|
||||||
assert.equal(lookup.get()?.forms.length, 2, 'expected the cached list within the interval');
|
|
||||||
|
|
||||||
nowMs += 10_000;
|
|
||||||
assert.equal(lookup.get()?.forms.length, 4, 'expected a refresh past the interval');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('invalidate picks up a snapshot change immediately', () => {
|
|
||||||
withTempDir((dir) => {
|
|
||||||
writeSnapshot(dir, 1, [['ミナト', 'みなと']]);
|
|
||||||
let nowMs = 1_000_000;
|
|
||||||
const lookup = createCharacterNameCandidateLookup({
|
|
||||||
outputDir: dir,
|
|
||||||
getCurrentMediaId: () => 1,
|
|
||||||
now: () => nowMs,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(lookup.get()?.forms.length, 2);
|
|
||||||
|
|
||||||
writeSnapshot(dir, 1, [
|
|
||||||
['ミナト', 'みなと'],
|
|
||||||
['アクア', 'あくあ'],
|
|
||||||
]);
|
|
||||||
nowMs += 1;
|
|
||||||
lookup.invalidate();
|
|
||||||
|
|
||||||
assert.equal(lookup.get()?.forms.length, 4);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { readCachedSnapshots } from './cache';
|
|
||||||
import type { CharacterDictionarySnapshot } from './types';
|
|
||||||
|
|
||||||
// Candidate name forms for the greedy name pre-pass in the Yomitan scan
|
|
||||||
// runtime. The scanner otherwise has to ask the backend at every Japanese
|
|
||||||
// position, because a character name can start mid-token; knowing which forms
|
|
||||||
// exist lets it look up only where a name can actually begin.
|
|
||||||
//
|
|
||||||
// A form is any string Yomitan could match a character entry by: the term and
|
|
||||||
// its reading. Both come from the dictionary SubMiner generated, so the pair is
|
|
||||||
// the complete matchable set for an entry. Callers treat a missing list as
|
|
||||||
// "scan every position", so a stale or absent snapshot costs speed, never a
|
|
||||||
// missed name.
|
|
||||||
|
|
||||||
function getSnapshotsDir(outputDir: string): string {
|
|
||||||
return path.join(outputDir, 'snapshots');
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectSnapshotNameForms(snapshot: CharacterDictionarySnapshot): string[] {
|
|
||||||
const forms = new Set<string>();
|
|
||||||
for (const entry of snapshot.termEntries) {
|
|
||||||
const term = typeof entry[0] === 'string' ? entry[0].trim() : '';
|
|
||||||
if (term) {
|
|
||||||
forms.add(term);
|
|
||||||
}
|
|
||||||
const reading = typeof entry[1] === 'string' ? entry[1].trim() : '';
|
|
||||||
if (reading) {
|
|
||||||
forms.add(reading);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...forms];
|
|
||||||
}
|
|
||||||
|
|
||||||
// The signature grows with the size of the dictionary library, and it rides
|
|
||||||
// along in every per-line scan call, so it is folded into a fixed-width digest
|
|
||||||
// first. Collisions only matter against the immediately previous signature (the
|
|
||||||
// runtime compares keys for equality), and FNV-1a over the file list is far
|
|
||||||
// beyond what that needs.
|
|
||||||
function digestSnapshotDirectorySignature(signature: string): string {
|
|
||||||
let hash = 0x811c9dc5;
|
|
||||||
for (let index = 0; index < signature.length; index += 1) {
|
|
||||||
hash ^= signature.charCodeAt(index);
|
|
||||||
hash = Math.imul(hash, 0x01000193);
|
|
||||||
}
|
|
||||||
return (hash >>> 0).toString(36);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSnapshotDirectorySignature(outputDir: string): string {
|
|
||||||
let entries: fs.Dirent[] = [];
|
|
||||||
try {
|
|
||||||
entries = fs.readdirSync(getSnapshotsDir(outputDir), { withFileTypes: true });
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts: string[] = [];
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.isFile() || !/^anilist-\d+\.json$/.test(entry.name)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const stat = fs.statSync(path.join(getSnapshotsDir(outputDir), entry.name));
|
|
||||||
parts.push(`${entry.name}:${stat.mtimeMs}:${stat.size}`);
|
|
||||||
} catch {
|
|
||||||
// Ignore files that disappear during a refresh; the next lookup rebuilds.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parts.sort().join('|');
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CharacterNameCandidateSet {
|
|
||||||
/** Identifies this exact form list, so the scan runtime can cache it. */
|
|
||||||
key: string;
|
|
||||||
forms: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// This lookup is consulted once per subtitle line, so it must not stat the
|
|
||||||
// snapshot directory every time. Dictionary writes are rare and always call
|
|
||||||
// invalidate(), which forces the next lookup to re-read; the interval only
|
|
||||||
// bounds staleness from changes made behind our back.
|
|
||||||
const SNAPSHOT_SIGNATURE_RECHECK_INTERVAL_MS = 5000;
|
|
||||||
|
|
||||||
export function createCharacterNameCandidateLookup(deps: {
|
|
||||||
userDataPath?: string;
|
|
||||||
outputDir?: string;
|
|
||||||
getCurrentMediaId?: () => number | null | undefined;
|
|
||||||
now?: () => number;
|
|
||||||
}): {
|
|
||||||
get: (mediaId?: number | null) => CharacterNameCandidateSet | null;
|
|
||||||
invalidate: () => void;
|
|
||||||
} {
|
|
||||||
const outputDir =
|
|
||||||
deps.outputDir ??
|
|
||||||
(deps.userDataPath ? path.join(deps.userDataPath, 'character-dictionaries') : '');
|
|
||||||
const now = deps.now ?? (() => Date.now());
|
|
||||||
let signature: string | null = null;
|
|
||||||
let lastSignatureCheckAtMs = 0;
|
|
||||||
let formsByMediaId = new Map<number, string[]>();
|
|
||||||
|
|
||||||
function refreshIfNeeded(): void {
|
|
||||||
if (!outputDir) {
|
|
||||||
formsByMediaId = new Map<number, string[]>();
|
|
||||||
signature = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nowMs = now();
|
|
||||||
if (
|
|
||||||
signature !== null &&
|
|
||||||
nowMs - lastSignatureCheckAtMs < SNAPSHOT_SIGNATURE_RECHECK_INTERVAL_MS
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastSignatureCheckAtMs = nowMs;
|
|
||||||
const nextSignature = getSnapshotDirectorySignature(outputDir);
|
|
||||||
if (nextSignature === signature) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
signature = nextSignature;
|
|
||||||
formsByMediaId = new Map<number, string[]>();
|
|
||||||
for (const snapshot of readCachedSnapshots(outputDir)) {
|
|
||||||
const forms = collectSnapshotNameForms(snapshot);
|
|
||||||
if (forms.length > 0) {
|
|
||||||
formsByMediaId.set(snapshot.mediaId, forms);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
get(mediaId?: number | null): CharacterNameCandidateSet | null {
|
|
||||||
refreshIfNeeded();
|
|
||||||
const rawMediaId = mediaId ?? deps.getCurrentMediaId?.() ?? null;
|
|
||||||
const normalizedMediaId =
|
|
||||||
typeof rawMediaId === 'number' && Number.isFinite(rawMediaId) && rawMediaId > 0
|
|
||||||
? Math.floor(rawMediaId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Without a media scope the pre-pass would need every character of every
|
|
||||||
// cached title, which is both slow to match and pointless: report no
|
|
||||||
// candidates so the scanner keeps its exhaustive behavior.
|
|
||||||
if (normalizedMediaId === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const forms = formsByMediaId.get(normalizedMediaId);
|
|
||||||
if (!forms || forms.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
key: `${digestSnapshotDirectorySignature(signature ?? '')}:${normalizedMediaId}`,
|
|
||||||
forms,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
invalidate(): void {
|
|
||||||
signature = null;
|
|
||||||
lastSignatureCheckAtMs = 0;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { isHanCodePoint } from '../../core/text/han-code-points';
|
|
||||||
import { HONORIFIC_SUFFIXES } from './constants';
|
import { HONORIFIC_SUFFIXES } from './constants';
|
||||||
import type { JapaneseNameParts, NameReadings, ResolvedNameSplits } from './types';
|
import type { JapaneseNameParts, NameReadings, ResolvedNameSplits } from './types';
|
||||||
|
|
||||||
@@ -27,12 +26,10 @@ export function buildReading(term: string): string {
|
|||||||
return katakanaToHiragana(compact);
|
return katakanaToHiragana(compact);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Code points, not code units: a supplementary-plane kanji (𠮷, U+20BB7) is a
|
|
||||||
// surrogate pair, and reading only the high surrogate would classify a real
|
|
||||||
// single-character name as non-kanji and drop it.
|
|
||||||
export function containsKanji(value: string): boolean {
|
export function containsKanji(value: string): boolean {
|
||||||
for (const char of value) {
|
for (const char of value) {
|
||||||
if (isHanCodePoint(char.codePointAt(0) ?? 0)) {
|
const code = char.charCodeAt(0);
|
||||||
|
if ((code >= 0x4e00 && code <= 0x9fff) || (code >= 0x3400 && code <= 0x4dbf)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,134 +36,3 @@ test('buildNameTerms adds surname honorifics from Japanese localized aliases', (
|
|||||||
assert.ok(terms.includes('馬渕さん'));
|
assert.ok(terms.includes('馬渕さん'));
|
||||||
assert.ok(!terms.includes('송치'));
|
assert.ok(!terms.includes('송치'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildNameTerms drops the disambiguator letter of a mob character name', () => {
|
|
||||||
const terms = buildNameTerms(
|
|
||||||
characterRecord({
|
|
||||||
firstNameHint: '',
|
|
||||||
lastNameHint: '',
|
|
||||||
fullName: 'Joshi A',
|
|
||||||
nativeName: '女子A',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ア would match every あ〜 in the subtitles; the letter is a disambiguator
|
|
||||||
// (Girl A / Girl B), not a name.
|
|
||||||
assert.ok(!terms.includes('ア'));
|
|
||||||
assert.ok(!terms.includes('アさん'));
|
|
||||||
assert.ok(terms.includes('女子A'));
|
|
||||||
assert.ok(terms.includes('ジョシア'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildNameTerms keeps a character whose whole name is one kana', () => {
|
|
||||||
const terms = buildNameTerms(
|
|
||||||
characterRecord({
|
|
||||||
firstNameHint: '',
|
|
||||||
lastNameHint: '',
|
|
||||||
fullName: 'A',
|
|
||||||
nativeName: 'あ',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// The mob-label rule only judges parts a name was split into; a name the
|
|
||||||
// source gives us whole is the character's actual name.
|
|
||||||
assert.ok(terms.includes('あ'));
|
|
||||||
assert.ok(terms.includes('あさん'));
|
|
||||||
// Romanized forms are never lookup targets (the subtitles are Japanese), and
|
|
||||||
// the single-kana alias "A" transliterates to is dropped as a collision.
|
|
||||||
assert.ok(!terms.includes('A'));
|
|
||||||
assert.ok(!terms.includes('ア'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildNameTerms keeps a one-character name written in another script', () => {
|
|
||||||
const terms = buildNameTerms(
|
|
||||||
characterRecord({
|
|
||||||
firstNameHint: '',
|
|
||||||
lastNameHint: '',
|
|
||||||
fullName: 'Byeol',
|
|
||||||
nativeName: '별',
|
|
||||||
alternativeNames: ['Я'],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(terms.includes('별'));
|
|
||||||
assert.ok(terms.includes('별さん'));
|
|
||||||
assert.ok(terms.includes('Я'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildNameTerms yields nothing for a character whose only name is a bare letter', () => {
|
|
||||||
// Documented policy rather than an oversight: a romanized name is never a
|
|
||||||
// term on its own (the subtitles are Japanese), and the single kana a bare
|
|
||||||
// letter transliterates to would match every あ〜 in the line.
|
|
||||||
assert.deepEqual(
|
|
||||||
buildNameTerms(
|
|
||||||
characterRecord({
|
|
||||||
firstNameHint: '',
|
|
||||||
lastNameHint: '',
|
|
||||||
fullName: 'A',
|
|
||||||
nativeName: '',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildNameTerms keeps one-character split parts that are not mob labels', () => {
|
|
||||||
const hangul = buildNameTerms(
|
|
||||||
characterRecord({
|
|
||||||
firstNameHint: '',
|
|
||||||
lastNameHint: '',
|
|
||||||
fullName: 'Byeol Kim',
|
|
||||||
nativeName: '별 김',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(hangul.includes('별'));
|
|
||||||
assert.ok(hangul.includes('김'));
|
|
||||||
|
|
||||||
const middleDot = buildNameTerms(
|
|
||||||
characterRecord({
|
|
||||||
firstNameHint: '',
|
|
||||||
lastNameHint: '',
|
|
||||||
fullName: 'A Be',
|
|
||||||
nativeName: 'ア・ベ',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(middleDot.includes('ア'));
|
|
||||||
assert.ok(middleDot.includes('ベ'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildNameTerms keeps a single-kanji name part', () => {
|
|
||||||
// The name is an alias, not the native name, so the parts come from the
|
|
||||||
// space split rather than from the native-name split.
|
|
||||||
const terms = buildNameTerms(
|
|
||||||
characterRecord({
|
|
||||||
firstNameHint: 'Sora',
|
|
||||||
lastNameHint: 'Yamada',
|
|
||||||
fullName: 'Sora Yamada',
|
|
||||||
nativeName: '',
|
|
||||||
alternativeNames: ['山田 空'],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(terms.includes('山田'));
|
|
||||||
assert.ok(terms.includes('空'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildNameTerms keeps a single supplementary-plane kanji name part', () => {
|
|
||||||
// 𠮷 (U+20BB7) is a surrogate pair: a code-unit kanji check reads only the
|
|
||||||
// high surrogate and drops the part as if it were a mob disambiguator.
|
|
||||||
const terms = buildNameTerms(
|
|
||||||
characterRecord({
|
|
||||||
firstNameHint: 'Tsukasa',
|
|
||||||
lastNameHint: 'Yoshi',
|
|
||||||
fullName: 'Tsukasa Yoshi',
|
|
||||||
nativeName: '',
|
|
||||||
alternativeNames: ['𠮷 司'],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(terms.includes('𠮷'));
|
|
||||||
assert.ok(terms.includes('司'));
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { HAN_REGEXP_CLASS_BODY } from '../../core/text/han-code-points';
|
|
||||||
import { HONORIFIC_SUFFIXES } from './constants';
|
import { HONORIFIC_SUFFIXES } from './constants';
|
||||||
import {
|
import {
|
||||||
addRomanizedKanaAliases,
|
addRomanizedKanaAliases,
|
||||||
@@ -43,29 +42,11 @@ export function expandRawNameVariants(rawName: string): string[] {
|
|||||||
return [...variants];
|
return [...variants];
|
||||||
}
|
}
|
||||||
|
|
||||||
// The label AniList appends to unnamed mob characters: one letter or digit,
|
|
||||||
// halfwidth or fullwidth (女子A / "Joshi A" / 女子1). Nothing else qualifies —
|
|
||||||
// a one-character part in any script is a real name part (별 김, ア・ベ, 山田 空).
|
|
||||||
const SINGLE_LABEL_CHARACTER = /^[0-9A-Za-z\uff10-\uff19\uff21-\uff3a\uff41-\uff5a]$/u;
|
|
||||||
|
|
||||||
// Judged on split parts only: a name the source gives us whole in a script the
|
|
||||||
// subtitles can contain is kept whatever it looks like, because a character
|
|
||||||
// really can be called あ or 별. (A romanized name is a separate matter: it is
|
|
||||||
// never a term on its own, only a source of kana aliases. See below.)
|
|
||||||
function isUsableNameSplitPart(part: string): boolean {
|
|
||||||
return !SINGLE_LABEL_CHARACTER.test(part);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kana, Han (shared ranges), and the marks that only ever appear inside a
|
|
||||||
// Japanese name: iteration marks and the small ka/ke used in place names.
|
|
||||||
const JAPANESE_NAME_CHARACTERS = new RegExp(
|
|
||||||
`^[\\u3040-\\u30ff${HAN_REGEXP_CLASS_BODY}\u3005\u3006\u30f5\u30f6\u30fc]+$`,
|
|
||||||
'u',
|
|
||||||
);
|
|
||||||
|
|
||||||
export function isJapaneseNameSplitCandidate(name: string): boolean {
|
export function isJapaneseNameSplitCandidate(name: string): boolean {
|
||||||
const compact = name.replace(/[\s\u3000・・·•]/g, '');
|
const compact = name.replace(/[\s\u3000・・·•]/g, '');
|
||||||
return containsKanji(compact) && JAPANESE_NAME_CHARACTERS.test(compact);
|
return (
|
||||||
|
containsKanji(compact) && /^[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff々〆ヵヶー]+$/.test(compact)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addJapaneseNameParts(
|
function addJapaneseNameParts(
|
||||||
@@ -116,11 +97,8 @@ export function buildNameTerms(
|
|||||||
|
|
||||||
const split = name.split(/[\s\u3000]+/).filter((part) => part.trim().length > 0);
|
const split = name.split(/[\s\u3000]+/).filter((part) => part.trim().length > 0);
|
||||||
if (split.length === 2) {
|
if (split.length === 2) {
|
||||||
for (const part of split) {
|
target.add(split[0]!);
|
||||||
if (isUsableNameSplitPart(part)) {
|
target.add(split[1]!);
|
||||||
target.add(part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const splitByMiddleDot = name
|
const splitByMiddleDot = name
|
||||||
@@ -129,11 +107,9 @@ export function buildNameTerms(
|
|||||||
.filter((part) => part.length > 0);
|
.filter((part) => part.length > 0);
|
||||||
if (splitByMiddleDot.length >= 2) {
|
if (splitByMiddleDot.length >= 2) {
|
||||||
for (const part of splitByMiddleDot) {
|
for (const part of splitByMiddleDot) {
|
||||||
if (isUsableNameSplitPart(part)) {
|
|
||||||
target.add(part);
|
target.add(part);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (target === base) {
|
if (target === base) {
|
||||||
addJapaneseNameParts(character, name, base, resolvedSplits);
|
addJapaneseNameParts(character, name, base, resolvedSplits);
|
||||||
@@ -141,15 +117,7 @@ export function buildNameTerms(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Romanized names never become terms themselves — the subtitles are Japanese,
|
|
||||||
// so "Joshi A" would never appear in one — they only contribute the kana a
|
|
||||||
// Japanese writer would spell them with.
|
|
||||||
for (const alias of addRomanizedKanaAliases(romanizedBase)) {
|
for (const alias of addRomanizedKanaAliases(romanizedBase)) {
|
||||||
// Except when the whole name is one letter: it transliterates to a single
|
|
||||||
// kana (A → ア) that matches every あ〜 in the subtitles. A character whose
|
|
||||||
// only recorded name is a bare letter therefore yields no terms at all,
|
|
||||||
// which is the intended outcome: those are unnamed mob characters.
|
|
||||||
if ([...alias].length === 1) continue;
|
|
||||||
base.add(alias);
|
base.add(alias);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ test('update overlay notification action triggers install flow', () => {
|
|||||||
assert.match(runtimeSource, /fallbackClient\.openNoteInBrowser\(noteId\)/);
|
assert.match(runtimeSource, /fallbackClient\.openNoteInBrowser\(noteId\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('subtitle change pauses prefetch without restarting its run before tokenizing current line', () => {
|
test('subtitle change re-prioritizes prefetch around live playback before tokenizing current line', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const actionBlock = source.match(
|
const actionBlock = source.match(
|
||||||
/onSubtitleChange:\s*\(text\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n refreshDiscordPresence:/,
|
/onSubtitleChange:\s*\(text\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n refreshDiscordPresence:/,
|
||||||
@@ -231,19 +231,15 @@ test('subtitle change pauses prefetch without restarting its run before tokenizi
|
|||||||
|
|
||||||
assert.ok(actionBlock);
|
assert.ok(actionBlock);
|
||||||
assert.match(actionBlock, /subtitlePrefetchService\?\.pause\(\);/);
|
assert.match(actionBlock, /subtitlePrefetchService\?\.pause\(\);/);
|
||||||
// Restarting the run per line (onSeek) discards in-flight prefetch work;
|
assert.match(actionBlock, /subtitlePrefetchService\?\.onSeek\(lastObservedTimePos\);/);
|
||||||
// only real seeks restart via onTimePosUpdate.
|
assert.match(actionBlock, /subtitleProcessingController\.onSubtitleChange\(text\);/);
|
||||||
assert.doesNotMatch(actionBlock, /subtitlePrefetchService\?\.onSeek\(/);
|
|
||||||
assert.match(actionBlock, /subtitleProcessingController\.onSubtitleChange\(text\)/);
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
actionBlock.indexOf('subtitlePrefetchService?.pause();') <
|
actionBlock.indexOf('subtitlePrefetchService?.pause();') <
|
||||||
actionBlock.indexOf('subtitleProcessingController.onSubtitleChange(text)'),
|
actionBlock.indexOf('subtitlePrefetchService?.onSeek(lastObservedTimePos);'),
|
||||||
);
|
);
|
||||||
// A repeated subtitle emits nothing, so the pause has to be released here or
|
assert.ok(
|
||||||
// prefetching idles until the next distinct line.
|
actionBlock.indexOf('subtitlePrefetchService?.onSeek(lastObservedTimePos);') <
|
||||||
assert.match(
|
actionBlock.indexOf('subtitleProcessingController.onSubtitleChange(text);'),
|
||||||
actionBlock,
|
|
||||||
/if \(!subtitleProcessingController\.onSubtitleChange\(text\)\) \{[\s\S]*?subtitlePrefetchService\?\.resume\(\);/,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -493,35 +489,16 @@ test('known-word updates invalidate prefetched tokenizations before refreshing c
|
|||||||
assert.match(actionBlock, /subtitlePrefetchService\?\.onSeek\(lastObservedTimePos\);/);
|
assert.match(actionBlock, /subtitlePrefetchService\?\.onSeek\(lastObservedTimePos\);/);
|
||||||
assert.match(
|
assert.match(
|
||||||
actionBlock,
|
actionBlock,
|
||||||
/if \(!subtitleProcessingController\.refreshCurrentSubtitle\(appState\.currentSubText\)\) \{[\s\S]*?subtitlePrefetchService\?\.resume\(\);/,
|
/subtitleProcessingController\.refreshCurrentSubtitle\(appState\.currentSubText\);/,
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
actionBlock.indexOf('subtitleProcessingController.invalidateTokenizationCache();') <
|
actionBlock.indexOf('subtitleProcessingController.invalidateTokenizationCache();') <
|
||||||
actionBlock.indexOf(
|
actionBlock.indexOf(
|
||||||
'subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText)',
|
'subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('subtitle processing controller resumes prefetch on settle, not on its emits', () => {
|
|
||||||
const source = readMainSource();
|
|
||||||
const depsBlock = source.match(
|
|
||||||
/createBuildSubtitleProcessingControllerMainDepsHandler\(\{(?<body>[\s\S]*?)\n \}\);/,
|
|
||||||
)?.groups?.body;
|
|
||||||
|
|
||||||
assert.ok(depsBlock);
|
|
||||||
// A controller emit can be the provisional plain payload sent before the
|
|
||||||
// scan runs, so it must not release the prefetch pause.
|
|
||||||
assert.match(
|
|
||||||
depsBlock,
|
|
||||||
/emitSubtitle: \(payload\) => emitSubtitlePayload\(payload, \{ resumePrefetch: false \}\),/,
|
|
||||||
);
|
|
||||||
assert.match(
|
|
||||||
depsBlock,
|
|
||||||
/onProcessingSettled: \(\) => \{\s+subtitlePrefetchService\?\.resume\(\);/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('manual visible overlay changes notify mpv plugin visibility state', () => {
|
test('manual visible overlay changes notify mpv plugin visibility state', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const setBlock = source.match(
|
const setBlock = source.match(
|
||||||
@@ -616,7 +593,7 @@ test('YouTube media cache lifecycle routes through configured status notificatio
|
|||||||
test('subtitle broadcasts share one frequency options snapshot per emitted payload', () => {
|
test('subtitle broadcasts share one frequency options snapshot per emitted payload', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const emitBlock = source.match(
|
const emitBlock = source.match(
|
||||||
/function emitSubtitlePayload\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
|
/function emitSubtitlePayload\(payload: SubtitleData\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
const frequencyOptionsSnapshot = emitBlock?.match(
|
const frequencyOptionsSnapshot = emitBlock?.match(
|
||||||
/const frequencyDictionary = configService\.getConfig\(\)\.subtitleStyle\.frequencyDictionary;(?<body>[\s\S]*?)\n \};/,
|
/const frequencyDictionary = configService\.getConfig\(\)\.subtitleStyle\.frequencyDictionary;(?<body>[\s\S]*?)\n \};/,
|
||||||
@@ -639,7 +616,7 @@ test('subtitle broadcasts share one frequency options snapshot per emitted paylo
|
|||||||
test('annotation upgrades skip the duplicate basic websocket event', () => {
|
test('annotation upgrades skip the duplicate basic websocket event', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const emitBlock = source.match(
|
const emitBlock = source.match(
|
||||||
/function emitSubtitlePayload\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
|
/function emitSubtitlePayload\(payload: SubtitleData\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(emitBlock);
|
assert.ok(emitBlock);
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { createSubtitleProcessingController } from '../../core/services/subtitle-processing-controller';
|
|
||||||
import type { SubtitleData } from '../../types';
|
|
||||||
import {
|
import {
|
||||||
createAutoplaySubtitlePrimingRuntime,
|
createAutoplaySubtitlePrimingRuntime,
|
||||||
setMpvCurrentSecondarySubText,
|
setMpvCurrentSecondarySubText,
|
||||||
@@ -44,9 +42,8 @@ test('scheduleSubtitlePrefetchRefresh logs refresh failures from timer callback'
|
|||||||
setActiveParsedSubtitleMediaPath: () => {},
|
setActiveParsedSubtitleMediaPath: () => {},
|
||||||
subtitleProcessingController: {
|
subtitleProcessingController: {
|
||||||
consumeCachedSubtitle: () => null,
|
consumeCachedSubtitle: () => null,
|
||||||
onSubtitleChange: () => true,
|
onSubtitleChange: () => {},
|
||||||
refreshCurrentSubtitle: () => true,
|
refreshCurrentSubtitle: () => {},
|
||||||
notePlainSubtitleEmitted: () => {},
|
|
||||||
},
|
},
|
||||||
emitSubtitlePayload: () => {},
|
emitSubtitlePayload: () => {},
|
||||||
getSubtitlePrefetchService: () => null,
|
getSubtitlePrefetchService: () => null,
|
||||||
@@ -96,25 +93,13 @@ test('primeCurrentSubtitleForAutoplay refreshes active subtitle cues when mpv su
|
|||||||
setActiveParsedSubtitleMediaPath: () => {},
|
setActiveParsedSubtitleMediaPath: () => {},
|
||||||
subtitleProcessingController: {
|
subtitleProcessingController: {
|
||||||
consumeCachedSubtitle: () => null,
|
consumeCachedSubtitle: () => null,
|
||||||
onSubtitleChange: (text) => {
|
onSubtitleChange: (text) => calls.push(`change:${text}`),
|
||||||
calls.push(`change:${text}`);
|
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text ?? ''}`),
|
||||||
return true;
|
|
||||||
},
|
},
|
||||||
refreshCurrentSubtitle: (text) => {
|
emitSubtitlePayload: (payload) => calls.push(`emit:${payload.text}`),
|
||||||
calls.push(`refresh:${text ?? ''}`);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
notePlainSubtitleEmitted: () => {},
|
|
||||||
},
|
|
||||||
emitSubtitlePayload: (payload, options) =>
|
|
||||||
calls.push(`emit:${payload.text}:resume=${options?.resumePrefetch !== false}`),
|
|
||||||
getSubtitlePrefetchService: () => ({
|
getSubtitlePrefetchService: () => ({
|
||||||
pause: () => {
|
pause: () => calls.push('prefetch:pause'),
|
||||||
calls.push('prefetch:pause');
|
onSeek: (timePos) => calls.push(`prefetch:seek:${timePos}`),
|
||||||
},
|
|
||||||
resume: () => {
|
|
||||||
calls.push('prefetch:resume');
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
getLastObservedTimePos: () => 12,
|
getLastObservedTimePos: () => 12,
|
||||||
getVisibleOverlayVisible: () => true,
|
getVisibleOverlayVisible: () => true,
|
||||||
@@ -135,10 +120,8 @@ test('primeCurrentSubtitleForAutoplay refreshes active subtitle cues when mpv su
|
|||||||
'request:time-pos',
|
'request:time-pos',
|
||||||
'set:起動字幕',
|
'set:起動字幕',
|
||||||
'prefetch:pause',
|
'prefetch:pause',
|
||||||
'emit:起動字幕:resume=false',
|
'emit:起動字幕',
|
||||||
// Uncached priming refreshes rather than announcing a change, so an
|
'change:起動字幕',
|
||||||
// invalidated-but-unchanged line is still re-tokenized.
|
|
||||||
'refresh:起動字幕',
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,25 +151,13 @@ test('primeCurrentSubtitleForAutoplay emits raw first paint on cache miss before
|
|||||||
setActiveParsedSubtitleMediaPath: () => {},
|
setActiveParsedSubtitleMediaPath: () => {},
|
||||||
subtitleProcessingController: {
|
subtitleProcessingController: {
|
||||||
consumeCachedSubtitle: () => null,
|
consumeCachedSubtitle: () => null,
|
||||||
onSubtitleChange: (text) => {
|
onSubtitleChange: (text) => calls.push(`change:${text}`),
|
||||||
calls.push(`change:${text}`);
|
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text ?? ''}`),
|
||||||
return true;
|
|
||||||
},
|
},
|
||||||
refreshCurrentSubtitle: (text) => {
|
emitSubtitlePayload: (payload) => calls.push(`emit:${payload.text}`),
|
||||||
calls.push(`refresh:${text ?? ''}`);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
notePlainSubtitleEmitted: () => {},
|
|
||||||
},
|
|
||||||
emitSubtitlePayload: (payload, options) =>
|
|
||||||
calls.push(`emit:${payload.text}:resume=${options?.resumePrefetch !== false}`),
|
|
||||||
getSubtitlePrefetchService: () => ({
|
getSubtitlePrefetchService: () => ({
|
||||||
pause: () => {
|
pause: () => calls.push('prefetch:pause'),
|
||||||
calls.push('prefetch:pause');
|
onSeek: (timePos) => calls.push(`prefetch:seek:${timePos}`),
|
||||||
},
|
|
||||||
resume: () => {
|
|
||||||
calls.push('prefetch:resume');
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
getLastObservedTimePos: () => 12,
|
getLastObservedTimePos: () => 12,
|
||||||
getVisibleOverlayVisible: () => true,
|
getVisibleOverlayVisible: () => true,
|
||||||
@@ -204,228 +175,7 @@ test('primeCurrentSubtitleForAutoplay emits raw first paint on cache miss before
|
|||||||
'request:sub-text',
|
'request:sub-text',
|
||||||
'set:起動字幕',
|
'set:起動字幕',
|
||||||
'prefetch:pause',
|
'prefetch:pause',
|
||||||
'emit:起動字幕:resume=false',
|
'emit:起動字幕',
|
||||||
// Uncached priming refreshes rather than announcing a change, so an
|
'change:起動字幕',
|
||||||
// invalidated-but-unchanged line is still re-tokenized.
|
|
||||||
'refresh:起動字幕',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Driven by the real processing controller rather than a stub: the failure this
|
|
||||||
// covers is a disagreement between the priming path and the controller's own
|
|
||||||
// staleness rules, which a hand-written stub cannot reproduce.
|
|
||||||
function createPrimingRuntimeWithRealController(options: {
|
|
||||||
text: string;
|
|
||||||
calls: string[];
|
|
||||||
onTokenize: () => void;
|
|
||||||
tokenize?: (text: string) => SubtitleData | null | Promise<SubtitleData | null>;
|
|
||||||
cacheLimit?: number;
|
|
||||||
}) {
|
|
||||||
const { text, calls } = options;
|
|
||||||
let currentSubText = '';
|
|
||||||
let currentSubtitleData: SubtitleData | null = null;
|
|
||||||
const mediaPath = '/media/video.mkv';
|
|
||||||
|
|
||||||
const prefetchService = {
|
|
||||||
pause: () => calls.push('prefetch:pause'),
|
|
||||||
resume: () => calls.push('prefetch:resume'),
|
|
||||||
};
|
|
||||||
// Mirrors main.ts emitSubtitlePayload: an emit resumes prefetching unless it
|
|
||||||
// is explicitly marked as not the end of the work for the line, and every
|
|
||||||
// controller emit is so marked.
|
|
||||||
const emitSubtitlePayload = (
|
|
||||||
payload: SubtitleData,
|
|
||||||
emitOptions?: { resumePrefetch?: boolean },
|
|
||||||
): void => {
|
|
||||||
currentSubtitleData = payload;
|
|
||||||
calls.push(
|
|
||||||
emitOptions?.resumePrefetch === false
|
|
||||||
? `emit-raw:${payload.text}`
|
|
||||||
: `emit-direct:${payload.text}`,
|
|
||||||
);
|
|
||||||
if (emitOptions?.resumePrefetch !== false) {
|
|
||||||
prefetchService.resume();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const subtitleProcessingController = createSubtitleProcessingController({
|
|
||||||
tokenizeSubtitle: async (subtitleText) => {
|
|
||||||
options.onTokenize();
|
|
||||||
return options.tokenize ? options.tokenize(subtitleText) : { text: subtitleText, tokens: [] };
|
|
||||||
},
|
|
||||||
// main.ts routes controller emits through emitSubtitlePayload with
|
|
||||||
// resumePrefetch: false, so they never release the pause on their own.
|
|
||||||
emitSubtitle: (payload) => {
|
|
||||||
currentSubtitleData = payload;
|
|
||||||
calls.push(`emit:${payload.text}:tokens=${payload.tokens === null ? 'none' : 'yes'}`);
|
|
||||||
},
|
|
||||||
onProcessingSettled: () => {
|
|
||||||
prefetchService.resume();
|
|
||||||
},
|
|
||||||
...(options.cacheLimit === undefined ? {} : { cacheLimit: options.cacheLimit }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const runtime = createAutoplaySubtitlePrimingRuntime({
|
|
||||||
getCurrentMediaPath: () => mediaPath,
|
|
||||||
getMpvClient: () => ({
|
|
||||||
connected: true,
|
|
||||||
currentVideoPath: mediaPath,
|
|
||||||
requestProperty: async (name) => (name === 'sub-text' ? text : null),
|
|
||||||
}),
|
|
||||||
setCurrentSubText: (value) => {
|
|
||||||
currentSubText = value;
|
|
||||||
},
|
|
||||||
getCurrentSubText: () => currentSubText,
|
|
||||||
getCurrentSubtitleData: () => currentSubtitleData,
|
|
||||||
getActiveParsedSubtitleCues: () => [],
|
|
||||||
setActiveParsedSubtitleMediaPath: () => {},
|
|
||||||
subtitleProcessingController,
|
|
||||||
emitSubtitlePayload,
|
|
||||||
getSubtitlePrefetchService: () => prefetchService,
|
|
||||||
getLastObservedTimePos: () => 12,
|
|
||||||
getVisibleOverlayVisible: () => true,
|
|
||||||
emitSecondarySubtitle: () => {},
|
|
||||||
initSubtitlePrefetch: async () => {},
|
|
||||||
refreshSubtitlePrefetchFromActiveTrack: async () => {},
|
|
||||||
logDebug: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { runtime, subtitleProcessingController, mediaPath };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('primeCurrentSubtitleForAutoplay re-tokenizes text whose cached annotation was invalidated', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
let tokenizations = 0;
|
|
||||||
const text = '起動字幕';
|
|
||||||
const { runtime, subtitleProcessingController, mediaPath } =
|
|
||||||
createPrimingRuntimeWithRealController({
|
|
||||||
text,
|
|
||||||
calls,
|
|
||||||
onTokenize: () => {
|
|
||||||
tokenizations += 1;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// The line was already tokenized and cached during normal playback.
|
|
||||||
subtitleProcessingController.onSubtitleChange(text);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
const tokenizationsBeforeInvalidation = tokenizations;
|
|
||||||
|
|
||||||
// Mining a card drops every cached tokenization.
|
|
||||||
subtitleProcessingController.invalidateTokenizationCache();
|
|
||||||
calls.length = 0;
|
|
||||||
|
|
||||||
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
// The cache miss must schedule fresh work, or the line stays unannotated for
|
|
||||||
// as long as it is on screen.
|
|
||||||
assert.equal(
|
|
||||||
tokenizations,
|
|
||||||
tokenizationsBeforeInvalidation + 1,
|
|
||||||
'expected the invalidated subtitle to be tokenized again',
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
calls.includes(`emit:${text}:tokens=yes`),
|
|
||||||
`expected an annotated emit, saw ${JSON.stringify(calls)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('primeCurrentSubtitleForAutoplay releases the prefetch pause when nothing is scheduled', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const text = '起動字幕';
|
|
||||||
const { runtime, subtitleProcessingController, mediaPath } =
|
|
||||||
createPrimingRuntimeWithRealController({
|
|
||||||
text,
|
|
||||||
calls,
|
|
||||||
onTokenize: () => {},
|
|
||||||
cacheLimit: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Emitted at the current cache generation, then evicted from the one-entry
|
|
||||||
// cache: priming misses the cache but the controller has nothing to redo, so
|
|
||||||
// no emit is coming and the pause must be released here.
|
|
||||||
subtitleProcessingController.onSubtitleChange(text);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
subtitleProcessingController.preCacheTokenization('別の字幕', {
|
|
||||||
text: '別の字幕',
|
|
||||||
tokens: [],
|
|
||||||
});
|
|
||||||
calls.length = 0;
|
|
||||||
|
|
||||||
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['prefetch:pause', `emit-raw:${text}`, 'prefetch:resume']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('primeCurrentSubtitleForAutoplay releases the prefetch pause when tokenization emits nothing', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const text = '起動字幕';
|
|
||||||
const { runtime, subtitleProcessingController, mediaPath } =
|
|
||||||
createPrimingRuntimeWithRealController({
|
|
||||||
text,
|
|
||||||
calls,
|
|
||||||
onTokenize: () => {},
|
|
||||||
// Transient tokenizer failure: the controller falls back to plain text it
|
|
||||||
// has already shown, so it suppresses the emit entirely.
|
|
||||||
tokenize: () => null,
|
|
||||||
});
|
|
||||||
|
|
||||||
subtitleProcessingController.onSubtitleChange(text);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
subtitleProcessingController.invalidateTokenizationCache();
|
|
||||||
calls.length = 0;
|
|
||||||
|
|
||||||
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
assert.ok(
|
|
||||||
!calls.some((call) => call.startsWith('emit:')),
|
|
||||||
`expected no controller emit, saw ${JSON.stringify(calls)}`,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
calls.filter((call) => call === 'prefetch:resume').length,
|
|
||||||
1,
|
|
||||||
`expected the prefetch pause to be released, saw ${JSON.stringify(calls)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('prefetch stays paused until tokenization of an uncached line completes', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const text = '起動字幕';
|
|
||||||
let finishTokenization = (): void => {};
|
|
||||||
const tokenizationGate = new Promise<void>((resolve) => {
|
|
||||||
finishTokenization = resolve;
|
|
||||||
});
|
|
||||||
const { runtime, mediaPath } = createPrimingRuntimeWithRealController({
|
|
||||||
text,
|
|
||||||
calls,
|
|
||||||
onTokenize: () => {},
|
|
||||||
tokenize: async (subtitleText) => {
|
|
||||||
await tokenizationGate;
|
|
||||||
return { text: subtitleText, tokens: [] };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Driven through the priming path, which is what takes the pause out.
|
|
||||||
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
// Neither the priming emit nor the controller's provisional plain emit may
|
|
||||||
// release the pause: the expensive scan is still ahead of them and would
|
|
||||||
// compete with prefetching for the parser.
|
|
||||||
// One plain payload, not two: priming paints it and tells the controller, so
|
|
||||||
// the controller goes straight for the tokenized one. And it does not resume
|
|
||||||
// prefetch, because the expensive scan is still ahead of it.
|
|
||||||
assert.deepEqual(calls, ['prefetch:pause', `emit-raw:${text}`]);
|
|
||||||
|
|
||||||
finishTokenization();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
assert.deepEqual(calls, [
|
|
||||||
'prefetch:pause',
|
|
||||||
`emit-raw:${text}`,
|
|
||||||
`emit:${text}:tokens=yes`,
|
|
||||||
'prefetch:resume',
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ type AutoplaySubtitlePrimingMpvClient = {
|
|||||||
|
|
||||||
type AutoplaySubtitlePrimingPrefetchService = {
|
type AutoplaySubtitlePrimingPrefetchService = {
|
||||||
pause: () => void;
|
pause: () => void;
|
||||||
resume: () => void;
|
onSeek: (timePos: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AutoplaySubtitlePrimingRuntimeDeps {
|
export interface AutoplaySubtitlePrimingRuntimeDeps {
|
||||||
@@ -30,12 +30,10 @@ export interface AutoplaySubtitlePrimingRuntimeDeps {
|
|||||||
setActiveParsedSubtitleMediaPath: (mediaPath: string | null) => void;
|
setActiveParsedSubtitleMediaPath: (mediaPath: string | null) => void;
|
||||||
subtitleProcessingController: {
|
subtitleProcessingController: {
|
||||||
consumeCachedSubtitle: (text: string) => SubtitleData | null;
|
consumeCachedSubtitle: (text: string) => SubtitleData | null;
|
||||||
// Both report whether processing is pending; see pausePrefetchUntilProcessed.
|
onSubtitleChange: (text: string) => void;
|
||||||
onSubtitleChange: (text: string) => boolean;
|
refreshCurrentSubtitle: (text: string) => void;
|
||||||
refreshCurrentSubtitle: (text: string) => boolean;
|
|
||||||
notePlainSubtitleEmitted: (text: string) => void;
|
|
||||||
};
|
};
|
||||||
emitSubtitlePayload: (payload: SubtitleData, options?: { resumePrefetch?: boolean }) => void;
|
emitSubtitlePayload: (payload: SubtitleData) => void;
|
||||||
getSubtitlePrefetchService: () => AutoplaySubtitlePrimingPrefetchService | null;
|
getSubtitlePrefetchService: () => AutoplaySubtitlePrimingPrefetchService | null;
|
||||||
getLastObservedTimePos: () => number;
|
getLastObservedTimePos: () => number;
|
||||||
getVisibleOverlayVisible: () => boolean;
|
getVisibleOverlayVisible: () => boolean;
|
||||||
@@ -66,19 +64,6 @@ export function setMpvCurrentSecondarySubText(
|
|||||||
export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimingRuntimeDeps) {
|
export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimingRuntimeDeps) {
|
||||||
const { subtitleProcessingController, emitSubtitlePayload } = deps;
|
const { subtitleProcessingController, emitSubtitlePayload } = deps;
|
||||||
|
|
||||||
// Prefetching is paused so the on-screen line gets the parser to itself; the
|
|
||||||
// resume rides on the controller settling (see onProcessingSettled), not on
|
|
||||||
// an emit, which a suppressed duplicate or a failed tokenization never sends.
|
|
||||||
// When the controller reports it has nothing scheduled, no settle is coming
|
|
||||||
// either, so release the pause here or prefetching idles indefinitely.
|
|
||||||
function pausePrefetchUntilProcessed(scheduleTokenization: () => boolean): void {
|
|
||||||
const prefetch = deps.getSubtitlePrefetchService();
|
|
||||||
prefetch?.pause();
|
|
||||||
if (!scheduleTokenization()) {
|
|
||||||
prefetch?.resume();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let subtitlePrefetchRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
let subtitlePrefetchRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let autoplaySubtitlePrimedMediaPath: string | null = null;
|
let autoplaySubtitlePrimedMediaPath: string | null = null;
|
||||||
let visibleOverlaySubtitleRefreshAfterFirstPaintTimer: ReturnType<typeof setTimeout> | null =
|
let visibleOverlaySubtitleRefreshAfterFirstPaintTimer: ReturnType<typeof setTimeout> | null =
|
||||||
@@ -119,25 +104,12 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
|||||||
const cachedPayload = subtitleProcessingController.consumeCachedSubtitle(text);
|
const cachedPayload = subtitleProcessingController.consumeCachedSubtitle(text);
|
||||||
if (cachedPayload) {
|
if (cachedPayload) {
|
||||||
subtitleProcessingController.onSubtitleChange(text);
|
subtitleProcessingController.onSubtitleChange(text);
|
||||||
// This emit resumes prefetching, so no pause is left outstanding.
|
|
||||||
emitSubtitlePayload(cachedPayload);
|
emitSubtitlePayload(cachedPayload);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provisional raw emit: keep prefetch paused until the processing
|
emitSubtitlePayload({ text, tokens: null });
|
||||||
// controller is done with this line, and tell it this line has already been
|
subtitleProcessingController.onSubtitleChange(text);
|
||||||
// painted plain so it does not broadcast the same payload again.
|
|
||||||
emitSubtitlePayload({ text, tokens: null }, { resumePrefetch: false });
|
|
||||||
subtitleProcessingController.notePlainSubtitleEmitted(text);
|
|
||||||
// refreshCurrentSubtitle, not onSubtitleChange: the cache miss above can be
|
|
||||||
// an invalidation (mining a card) on text the controller still holds, and
|
|
||||||
// onSubtitleChange treats unchanged text as nothing to do, which would
|
|
||||||
// leave this line permanently unannotated. refreshCurrentSubtitle also
|
|
||||||
// re-tokenizes for a new cache generation.
|
|
||||||
if (!subtitleProcessingController.refreshCurrentSubtitle(text)) {
|
|
||||||
// Nothing scheduled, so no settle is coming to release the pause.
|
|
||||||
deps.getSubtitlePrefetchService()?.resume();
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,12 +153,14 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
|||||||
getCurrentSubtitleData: () => deps.getCurrentSubtitleData(),
|
getCurrentSubtitleData: () => deps.getCurrentSubtitleData(),
|
||||||
consumeCachedSubtitle: (text) => subtitleProcessingController.consumeCachedSubtitle(text),
|
consumeCachedSubtitle: (text) => subtitleProcessingController.consumeCachedSubtitle(text),
|
||||||
onSubtitleChange: (text) => {
|
onSubtitleChange: (text) => {
|
||||||
pausePrefetchUntilProcessed(() => subtitleProcessingController.onSubtitleChange(text));
|
deps.getSubtitlePrefetchService()?.pause();
|
||||||
|
deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos());
|
||||||
|
subtitleProcessingController.onSubtitleChange(text);
|
||||||
},
|
},
|
||||||
refreshCurrentSubtitle: (text) => {
|
refreshCurrentSubtitle: (text) => {
|
||||||
pausePrefetchUntilProcessed(() =>
|
deps.getSubtitlePrefetchService()?.pause();
|
||||||
subtitleProcessingController.refreshCurrentSubtitle(text),
|
deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos());
|
||||||
);
|
subtitleProcessingController.refreshCurrentSubtitle(text);
|
||||||
},
|
},
|
||||||
deferUncachedRefresh: true,
|
deferUncachedRefresh: true,
|
||||||
emitSubtitle: (payload) => emitSubtitlePayload(payload),
|
emitSubtitle: (payload) => emitSubtitlePayload(payload),
|
||||||
@@ -230,7 +204,9 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
|||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pausePrefetchUntilProcessed(() => subtitleProcessingController.refreshCurrentSubtitle(text));
|
deps.getSubtitlePrefetchService()?.pause();
|
||||||
|
deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos());
|
||||||
|
subtitleProcessingController.refreshCurrentSubtitle(text);
|
||||||
}, VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS);
|
}, VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS);
|
||||||
visibleOverlaySubtitleRefreshAfterFirstPaintTimer.unref?.();
|
visibleOverlaySubtitleRefreshAfterFirstPaintTimer.unref?.();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,52 +53,3 @@ test('character dictionary sync completion refreshes subtitle state when diction
|
|||||||
'log:[dictionary:auto-sync] refreshed current subtitle after sync (AniList 1, changed=yes, title=Frieren)',
|
'log:[dictionary:auto-sync] refreshed current subtitle after sync (AniList 1, changed=yes, title=Frieren)',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('character dictionary sync completion drops cached dictionary reads before refreshing', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
handleCharacterDictionaryAutoSyncComplete(
|
|
||||||
{
|
|
||||||
mediaId: 1,
|
|
||||||
mediaTitle: 'Frieren',
|
|
||||||
changed: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hasParserWindow: () => true,
|
|
||||||
invalidateCharacterDictionaryLookups: () => calls.push('invalidate-dictionary-lookups'),
|
|
||||||
clearParserCaches: () => calls.push('clear-parser'),
|
|
||||||
invalidateTokenizationCache: () => calls.push('invalidate'),
|
|
||||||
refreshSubtitlePrefetch: () => calls.push('prefetch'),
|
|
||||||
refreshCurrentSubtitle: () => calls.push('refresh-subtitle'),
|
|
||||||
logInfo: () => {},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Must run before the refreshes, or they re-tokenize against the character
|
|
||||||
// names and images from the previous dictionary build.
|
|
||||||
assert.equal(calls[0], 'invalidate-dictionary-lookups');
|
|
||||||
assert.ok(calls.indexOf('invalidate-dictionary-lookups') < calls.indexOf('refresh-subtitle'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('character dictionary sync completion leaves cached dictionary reads alone when unchanged', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
|
|
||||||
handleCharacterDictionaryAutoSyncComplete(
|
|
||||||
{
|
|
||||||
mediaId: 1,
|
|
||||||
mediaTitle: 'Frieren',
|
|
||||||
changed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hasParserWindow: () => true,
|
|
||||||
invalidateCharacterDictionaryLookups: () => calls.push('invalidate-dictionary-lookups'),
|
|
||||||
clearParserCaches: () => calls.push('clear-parser'),
|
|
||||||
invalidateTokenizationCache: () => calls.push('invalidate'),
|
|
||||||
refreshSubtitlePrefetch: () => calls.push('prefetch'),
|
|
||||||
refreshCurrentSubtitle: () => calls.push('refresh-subtitle'),
|
|
||||||
logInfo: () => {},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, []);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -7,12 +7,6 @@ export function handleCharacterDictionaryAutoSyncComplete(
|
|||||||
deps: {
|
deps: {
|
||||||
hasParserWindow: () => boolean;
|
hasParserWindow: () => boolean;
|
||||||
clearParserCaches: () => void;
|
clearParserCaches: () => void;
|
||||||
/**
|
|
||||||
* Drops cached reads of the generated dictionary (character images, and the
|
|
||||||
* name candidates the scanner uses to skip lookups). Runs before the
|
|
||||||
* refreshes below so they re-tokenize against the new dictionary content.
|
|
||||||
*/
|
|
||||||
invalidateCharacterDictionaryLookups?: () => void;
|
|
||||||
invalidateTokenizationCache: () => void;
|
invalidateTokenizationCache: () => void;
|
||||||
refreshSubtitlePrefetch: () => void;
|
refreshSubtitlePrefetch: () => void;
|
||||||
refreshCurrentSubtitle: () => void;
|
refreshCurrentSubtitle: () => void;
|
||||||
@@ -20,7 +14,6 @@ export function handleCharacterDictionaryAutoSyncComplete(
|
|||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
if (completion.changed) {
|
if (completion.changed) {
|
||||||
deps.invalidateCharacterDictionaryLookups?.();
|
|
||||||
if (deps.hasParserWindow()) {
|
if (deps.hasParserWindow()) {
|
||||||
deps.clearParserCaches();
|
deps.clearParserCaches();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,35 +225,24 @@ export function composeMpvRuntimeHandlers<
|
|||||||
}
|
}
|
||||||
return tokenizationWarmupInFlight;
|
return tokenizationWarmupInFlight;
|
||||||
};
|
};
|
||||||
// Built once and reused for every tokenization: per-call rebuilds create
|
const tokenizeSubtitle = async (text: string): Promise<TTokenizedSubtitle> => {
|
||||||
// fresh closures, which defeats identity-keyed caches downstream (the JLPT
|
if (!tokenizationWarmupCompleted) void startTokenizationWarmups();
|
||||||
// lookup cache keys on the getJlptLevel function, and the mecab availability
|
await ensureTokenizationPrerequisites();
|
||||||
// WeakSet keys on the runtime deps instance).
|
|
||||||
let cachedTokenizerRuntimeDeps: TTokenizerRuntimeDeps | null = null;
|
|
||||||
const getTokenizerRuntimeDeps = (): TTokenizerRuntimeDeps => {
|
|
||||||
if (cachedTokenizerRuntimeDeps) {
|
|
||||||
return cachedTokenizerRuntimeDeps;
|
|
||||||
}
|
|
||||||
const tokenizerMainDeps = buildTokenizerDepsHandler();
|
const tokenizerMainDeps = buildTokenizerDepsHandler();
|
||||||
const baseOnTokenizationReady = tokenizerMainDeps.onTokenizationReady;
|
if (shouldWarmupAnnotationDictionaries()) {
|
||||||
|
const onTokenizationReady = tokenizerMainDeps.onTokenizationReady;
|
||||||
tokenizerMainDeps.onTokenizationReady = (tokenizedText: string): void => {
|
tokenizerMainDeps.onTokenizationReady = (tokenizedText: string): void => {
|
||||||
if (!shouldWarmupAnnotationDictionaries()) {
|
|
||||||
baseOnTokenizationReady?.(tokenizedText);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
markTokenizationPlaybackReady();
|
markTokenizationPlaybackReady();
|
||||||
baseOnTokenizationReady?.(tokenizedText);
|
onTokenizationReady?.(tokenizedText);
|
||||||
if (!tokenizationWarmupCompleted) {
|
if (!tokenizationWarmupCompleted) {
|
||||||
void prewarmSubtitleDictionaries({ showLoadingOsd: true }).catch(() => {});
|
void prewarmSubtitleDictionaries({ showLoadingOsd: true }).catch(() => {});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
cachedTokenizerRuntimeDeps = options.tokenizer.createTokenizerRuntimeDeps(tokenizerMainDeps);
|
}
|
||||||
return cachedTokenizerRuntimeDeps;
|
return options.tokenizer.tokenizeSubtitle(
|
||||||
};
|
text,
|
||||||
const tokenizeSubtitle = async (text: string): Promise<TTokenizedSubtitle> => {
|
options.tokenizer.createTokenizerRuntimeDeps(tokenizerMainDeps),
|
||||||
if (!tokenizationWarmupCompleted) void startTokenizationWarmups();
|
);
|
||||||
await ensureTokenizationPrerequisites();
|
|
||||||
return options.tokenizer.tokenizeSubtitle(text, getTokenizerRuntimeDeps());
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const launchBackgroundWarmupTask = createLaunchBackgroundWarmupTaskFromStartup(
|
const launchBackgroundWarmupTask = createLaunchBackgroundWarmupTaskFromStartup(
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export function createBuildSubtitleProcessingControllerMainDepsHandler(
|
|||||||
return (): SubtitleProcessingControllerDeps => ({
|
return (): SubtitleProcessingControllerDeps => ({
|
||||||
tokenizeSubtitle: (text: string) => deps.tokenizeSubtitle(text),
|
tokenizeSubtitle: (text: string) => deps.tokenizeSubtitle(text),
|
||||||
emitSubtitle: (payload) => deps.emitSubtitle(payload),
|
emitSubtitle: (payload) => deps.emitSubtitle(payload),
|
||||||
onProcessingSettled: () => deps.onProcessingSettled?.(),
|
|
||||||
logDebug: deps.logDebug,
|
logDebug: deps.logDebug,
|
||||||
now: deps.now,
|
now: deps.now,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ type TokenizerMainDeps = TokenizerDepsRuntimeOptions & {
|
|||||||
getCurrentCharacterDictionaryMediaId?: NonNullable<
|
getCurrentCharacterDictionaryMediaId?: NonNullable<
|
||||||
TokenizerDepsRuntimeOptions['getCurrentCharacterDictionaryMediaId']
|
TokenizerDepsRuntimeOptions['getCurrentCharacterDictionaryMediaId']
|
||||||
>;
|
>;
|
||||||
getCharacterNameCandidates?: NonNullable<
|
|
||||||
TokenizerDepsRuntimeOptions['getCharacterNameCandidates']
|
|
||||||
>;
|
|
||||||
getFrequencyDictionaryEnabled: NonNullable<
|
getFrequencyDictionaryEnabled: NonNullable<
|
||||||
TokenizerDepsRuntimeOptions['getFrequencyDictionaryEnabled']
|
TokenizerDepsRuntimeOptions['getFrequencyDictionaryEnabled']
|
||||||
>;
|
>;
|
||||||
@@ -87,11 +84,6 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
|
|||||||
getCurrentCharacterDictionaryMediaId: () => deps.getCurrentCharacterDictionaryMediaId!(),
|
getCurrentCharacterDictionaryMediaId: () => deps.getCurrentCharacterDictionaryMediaId!(),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(deps.getCharacterNameCandidates
|
|
||||||
? {
|
|
||||||
getCharacterNameCandidates: () => deps.getCharacterNameCandidates!(),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
getFrequencyDictionaryEnabled: () => deps.getFrequencyDictionaryEnabled(),
|
getFrequencyDictionaryEnabled: () => deps.getFrequencyDictionaryEnabled(),
|
||||||
getFrequencyDictionaryMatchMode: () => deps.getFrequencyDictionaryMatchMode(),
|
getFrequencyDictionaryMatchMode: () => deps.getFrequencyDictionaryMatchMode(),
|
||||||
getFrequencyRank: (text: string) => deps.getFrequencyRank(text),
|
getFrequencyRank: (text: string) => deps.getFrequencyRank(text),
|
||||||
|
|||||||
@@ -1004,6 +1004,24 @@ test('normalizeSubtitle collapses explicit line breaks when collapseLineBreaks i
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('normalizeSubtitle leaves already-decoded text alone', () => {
|
||||||
|
// Primary subtitle text is decoded from ASS once, upstream: by mpv for live lines and
|
||||||
|
// by the cue parser for prefetched ones. A brace that survives that is literal text.
|
||||||
|
assert.equal(normalizeSubtitle('本文{\\pos(1,2)'), '本文{\\pos(1,2)');
|
||||||
|
assert.equal(normalizeSubtitle(' 余白 ', false), ' 余白 ');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prepareSecondarySubtitleLines drops ASS vector drawing runs', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
prepareSecondarySubtitleLines(
|
||||||
|
'{\\an5\\pos(730,1042)\\p1\\blur1}m 20 0 b 10 0 0 10 0 20 b 0 31 10 40 20 40 {\\p0}',
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert.deepEqual(prepareSecondarySubtitleLines('{\\p1}m 0 0 l 10 10{\\p0}本文'), ['本文']);
|
||||||
|
assert.deepEqual(prepareSecondarySubtitleLines('{\\pos(960,1068)\\bord3}位置指定'), ['位置指定']);
|
||||||
|
});
|
||||||
|
|
||||||
test('shouldRenderTokenizedSubtitle enables token rendering when tokens exist', () => {
|
test('shouldRenderTokenizedSubtitle enables token rendering when tokens exist', () => {
|
||||||
assert.equal(shouldRenderTokenizedSubtitle(5), true);
|
assert.equal(shouldRenderTokenizedSubtitle(5), true);
|
||||||
assert.equal(shouldRenderTokenizedSubtitle(0), false);
|
assert.equal(shouldRenderTokenizedSubtitle(0), false);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
SubtitleData,
|
SubtitleData,
|
||||||
SubtitleRendererStyleConfig,
|
SubtitleRendererStyleConfig,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
import { assToPlainText, normalizePlainSubtitleText } from '../core/services/ass-text.js';
|
||||||
import type { RendererContext } from './context';
|
import type { RendererContext } from './context';
|
||||||
import { PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS } from './yomitan-popup.js';
|
import { PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS } from './yomitan-popup.js';
|
||||||
|
|
||||||
@@ -42,17 +43,10 @@ function isWhitespaceOnly(value: string): boolean {
|
|||||||
return value.trim().length === 0;
|
return value.trim().length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Text reaching the overlay has already been decoded from ASS -- by mpv for live lines,
|
||||||
|
// by the cue parser for prefetched ones -- so this only settles line breaks.
|
||||||
export function normalizeSubtitle(text: string, trim = true, collapseLineBreaks = false): string {
|
export function normalizeSubtitle(text: string, trim = true, collapseLineBreaks = false): string {
|
||||||
if (!text) return '';
|
return normalizePlainSubtitleText(text, { trim, collapseLineBreaks });
|
||||||
|
|
||||||
let normalized = text.replace(/\\N/g, '\n').replace(/\\n/g, '\n');
|
|
||||||
normalized = normalized.replace(/\{[^}]*\}/g, '');
|
|
||||||
if (collapseLineBreaks) {
|
|
||||||
normalized = normalized.replace(/\n/g, ' ');
|
|
||||||
normalized = normalized.replace(/\s+/g, ' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
return trim ? normalized.trim() : normalized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||||
@@ -672,7 +666,10 @@ function isKaraokeLikeLineSet(lines: string[]): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function prepareSecondarySubtitleLines(text: string): string[] {
|
export function prepareSecondarySubtitleLines(text: string): string[] {
|
||||||
const normalized = normalizeSubtitle(text, true, false);
|
// The one display-side ASS decode: secondary text also reaches the overlay from
|
||||||
|
// websocket clients that forward their source line untouched, so unlike the primary
|
||||||
|
// path it cannot assume mpv already decoded it.
|
||||||
|
const normalized = assToPlainText(text).trim();
|
||||||
|
|
||||||
if (!normalized) return [];
|
if (!normalized) return [];
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { normalizePlainSubtitleText } from './core/services/ass-text';
|
||||||
|
|
||||||
interface TimingEntry {
|
interface TimingEntry {
|
||||||
startTime: number;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
@@ -191,23 +193,14 @@ export class SubtitleTimingTracker {
|
|||||||
return costs[shorter.length] || 0;
|
return costs[shorter.length] || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Both sides take text mpv has already decoded from ASS; only whitespace differs
|
||||||
|
// between the lookup key (single line) and the display form (line breaks kept).
|
||||||
private normalizeText(text: string): string {
|
private normalizeText(text: string): string {
|
||||||
return text
|
return normalizePlainSubtitleText(text, { collapseLineBreaks: true });
|
||||||
.replace(/\\N/g, ' ')
|
|
||||||
.replace(/\\n/g, ' ')
|
|
||||||
.replace(/\n/g, ' ')
|
|
||||||
.replace(/{[^}]*}/g, '')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private prepareDisplayText(text: string): string {
|
private prepareDisplayText(text: string): string {
|
||||||
// Convert ASS/SSA newlines to real newlines, strip tags
|
return normalizePlainSubtitleText(text);
|
||||||
return text
|
|
||||||
.replace(/\\N/g, '\n')
|
|
||||||
.replace(/\\n/g, '\n')
|
|
||||||
.replace(/{[^}]*}/g, '')
|
|
||||||
.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private startCleanup(): void {
|
private startCleanup(): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user