fix(overlay): keep frequency/JLPT highlight for kanji non-independent nouns (#150)

This commit is contained in:
2026-07-08 22:25:32 -07:00
committed by GitHub
parent 8b21a2bca8
commit 7f13aed50a
5 changed files with 112 additions and 60 deletions
+2 -2
View File
@@ -3486,8 +3486,8 @@ test('tokenizeSubtitle keeps known-word highlight for exact non-independent kanj
assert.equal(result.tokens?.[1]?.surface, '点');
assert.equal(result.tokens?.[1]?.isKnown, true);
assert.equal(result.tokens?.[1]?.isNPlusOneTarget, false);
assert.equal(result.tokens?.[1]?.frequencyRank, undefined);
assert.equal(result.tokens?.[1]?.jlptLevel, undefined);
assert.equal(result.tokens?.[1]?.frequencyRank, 1384);
assert.equal(result.tokens?.[1]?.jlptLevel, 'N3');
});
test('tokenizeSubtitle keeps mecab-tagged interjections tokenized while clearing annotation metadata', async () => {
@@ -575,7 +575,9 @@ test('shouldExcludeTokenFromSubtitleAnnotations keeps lexical tokens outside exp
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), false);
});
test('shouldExcludeTokenFromSubtitleAnnotations still excludes lexical non-independent kanji nouns from non-known annotations', () => {
test('shouldExcludeTokenFromSubtitleAnnotations keeps lexical non-independent kanji nouns', () => {
// Yomitan segments 以外/日/方 as standalone vocabulary tokens; MeCab's
// 非自立 tag must only suppress kana grammar nouns (こと, もの, とき).
const token = makeToken({
surface: '以外',
headword: '以外',
@@ -586,6 +588,21 @@ test('shouldExcludeTokenFromSubtitleAnnotations still excludes lexical non-indep
pos3: '副詞可能',
});
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), false);
assert.equal(shouldExcludeTokenFromVocabularyPersistence(token), false);
});
test('shouldExcludeTokenFromSubtitleAnnotations still excludes kana non-independent nouns', () => {
const token = makeToken({
surface: 'こと',
headword: 'こと',
reading: 'コト',
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞',
pos2: '非自立',
pos3: '一般',
});
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true);
assert.equal(shouldExcludeTokenFromVocabularyPersistence(token), true);
});
@@ -1421,8 +1438,8 @@ test('annotateTokens keeps known-word status for non-independent kanji noun toke
assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined);
assert.equal(result[0]?.frequencyRank, 1384);
assert.equal(result[0]?.jlptLevel, 'N3');
});
test('annotateTokens keeps known-word status for lexical non-independent kanji nouns', () => {
@@ -1450,23 +1467,54 @@ test('annotateTokens keeps known-word status for lexical non-independent kanji n
);
assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.frequencyRank, 437);
assert.equal(result[0]?.isNPlusOneTarget, false);
});
test('annotateTokens clears all annotations for non-independent kanji noun tokens under unified gate', () => {
test('annotateTokens keeps frequency for unknown non-independent kanji noun tokens', () => {
// 日 in いい日だったな: MeCab tags it 名詞/非自立 but Yomitan segments it as
// a standalone vocabulary token, so frequency highlighting must survive.
const tokens = [
makeToken({
surface: '',
reading: 'もの',
headword: '',
surface: '',
reading: '',
headword: '',
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞',
pos2: '非自立',
pos3: '副詞可能',
startPos: 2,
endPos: 3,
frequencyRank: 718,
}),
];
const result = annotateTokens(
tokens,
makeDeps({
getJlptLevel: (text) => (text === '日' ? 'N4' : null),
}),
{ minSentenceWordsForNPlusOne: 1 },
);
assert.equal(result[0]?.isKnown, false);
assert.equal(result[0]?.frequencyRank, 718);
assert.equal(result[0]?.jlptLevel, 'N4');
});
test('annotateTokens still clears annotations for kana non-independent noun tokens', () => {
const tokens = [
makeToken({
surface: 'こと',
reading: 'こと',
headword: 'こと',
partOfSpeech: PartOfSpeech.other,
pos1: '名詞',
pos2: '非自立',
pos3: '一般',
startPos: 0,
endPos: 1,
frequencyRank: 475,
endPos: 2,
frequencyRank: 96,
}),
];
@@ -10,6 +10,7 @@ import {
import { JlptLevel, MergedToken, NPlusOneMatchMode, PartOfSpeech } from '../../../types';
import { shouldIgnoreJlptByTerm, shouldIgnoreJlptForMecabPos1 } from '../jlpt-token-filter';
import {
isKanjiNonIndependentNounToken,
shouldExcludeTokenFromSubtitleAnnotations as sharedShouldExcludeTokenFromSubtitleAnnotations,
stripSubtitleAnnotationMetadata as sharedStripSubtitleAnnotationMetadata,
} from './subtitle-annotation-filter';
@@ -94,23 +95,6 @@ function normalizePos2Tag(pos2: string | undefined): string {
return typeof pos2 === 'string' ? pos2.trim() : '';
}
function hasKanjiChar(text: string): boolean {
for (const char of text) {
const code = char.codePointAt(0);
if (code === undefined) {
continue;
}
if (
(code >= 0x3400 && code <= 0x4dbf) ||
(code >= 0x4e00 && code <= 0x9fff) ||
(code >= 0xf900 && code <= 0xfaff)
) {
return true;
}
}
return false;
}
function isExcludedComponent(
pos1: string | undefined,
pos2: string | undefined,
@@ -283,34 +267,6 @@ function isFrequencyExcludedByPos(
);
}
function shouldKeepFrequencyForNonIndependentKanjiNoun(
token: MergedToken,
pos1Exclusions: ReadonlySet<string>,
): boolean {
if (pos1Exclusions.has('名詞')) {
return false;
}
const rank =
typeof token.frequencyRank === 'number' && Number.isFinite(token.frequencyRank)
? Math.max(1, Math.floor(token.frequencyRank))
: null;
if (rank === null) {
return false;
}
const pos1Parts = splitNormalizedTagParts(normalizePos1Tag(token.pos1));
const pos2Parts = splitNormalizedTagParts(normalizePos2Tag(token.pos2));
if (pos1Parts.length !== 1 || pos2Parts.length !== 1) {
return false;
}
if (pos1Parts[0] !== '名詞' || pos2Parts[0] !== '非自立') {
return false;
}
return hasKanjiChar(token.surface) || hasKanjiChar(token.headword);
}
export function shouldExcludeTokenFromVocabularyPersistence(
token: MergedToken,
options: Pick<AnnotationStageOptions, 'pos1Exclusions' | 'pos2Exclusions'> = {},
@@ -320,7 +276,8 @@ export function shouldExcludeTokenFromVocabularyPersistence(
return (
sharedShouldExcludeTokenFromSubtitleAnnotations(token, { pos1Exclusions, pos2Exclusions }) ||
isFrequencyExcludedByPos(token, pos1Exclusions, pos2Exclusions)
(isFrequencyExcludedByPos(token, pos1Exclusions, pos2Exclusions) &&
!isKanjiNonIndependentNounToken(token, pos1Exclusions))
);
}
@@ -721,7 +678,7 @@ function filterTokenFrequencyRank(
): number | undefined {
if (
isFrequencyExcludedByPos(token, pos1Exclusions, pos2Exclusions) &&
!shouldKeepFrequencyForNonIndependentKanjiNoun(token, pos1Exclusions)
!isKanjiNonIndependentNounToken(token, pos1Exclusions)
) {
return undefined;
}
@@ -137,6 +137,46 @@ function resolvePos2Exclusions(options: SubtitleAnnotationFilterOptions = {}): R
return resolveAnnotationPos2ExclusionSet(DEFAULT_ANNOTATION_POS2_EXCLUSION_CONFIG);
}
function hasKanjiChar(text: string): boolean {
for (const char of text) {
const code = char.codePointAt(0);
if (code === undefined) {
continue;
}
if (
(code >= 0x3400 && code <= 0x4dbf) ||
(code >= 0x4e00 && code <= 0x9fff) ||
(code >= 0xf900 && code <= 0xfaff)
) {
return true;
}
}
return false;
}
// Kanji-bearing non-independent nouns (日, 方, 上, …) are real vocabulary that
// Yomitan segments as standalone tokens; MeCab's 非自立 tag exists to suppress
// kana grammar nouns (こと, もの, とき) and must not hide these.
export function isKanjiNonIndependentNounToken(
token: MergedToken,
pos1Exclusions: ReadonlySet<string>,
): boolean {
if (pos1Exclusions.has('名詞')) {
return false;
}
const pos1Parts = splitNormalizedTagParts(normalizePosTag(token.pos1));
const pos2Parts = splitNormalizedTagParts(normalizePosTag(token.pos2));
if (pos1Parts.length !== 1 || pos2Parts.length !== 1) {
return false;
}
if (pos1Parts[0] !== '名詞' || pos2Parts[0] !== '非自立') {
return false;
}
return hasKanjiChar(token.surface) || hasKanjiChar(token.headword);
}
function normalizeKana(text: string): string {
const raw = text.trim();
if (!raw) {
@@ -445,7 +485,10 @@ export function shouldExcludeTokenFromSubtitleAnnotations(
return true;
}
if (isExcludedByTagSet(normalizedPos2, pos2Exclusions)) {
if (
isExcludedByTagSet(normalizedPos2, pos2Exclusions) &&
!isKanjiNonIndependentNounToken(token, pos1Exclusions)
) {
return true;
}