diff --git a/apps/readest-app/src/__tests__/utils/replacement.test.ts b/apps/readest-app/src/__tests__/utils/replacement.test.ts index d5df80f0..0cdc3519 100644 --- a/apps/readest-app/src/__tests__/utils/replacement.test.ts +++ b/apps/readest-app/src/__tests__/utils/replacement.test.ts @@ -209,6 +209,60 @@ describe('proofreadTransformer', () => { expect(result).toContain('dog'); expect(result).toContain('category'); // Should not replace "cat" in "category" }); + + test('should replace CJK characters correctly', async () => { + const rules: ProofreadRule[] = [ + { + id: '1', + scope: 'book', + pattern: '猫', + replacement: '狗', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 1, + wholeWord: true, + }, + ]; + const ctx = createMockContext(rules, '
我有一只猫。
'); + const result = await proofreadTransformer.transform(ctx); + + expect(result).toContain('我有一只狗。'); + expect(result).not.toContain('猫'); + }); + + test('should replace multiple different words correctly', async () => { + const rules: ProofreadRule[] = [ + { + id: '1', + scope: 'book', + pattern: '猫', + replacement: '狗', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 1, + wholeWord: true, + }, + { + id: '2', + scope: 'book', + pattern: '我', + replacement: '你', + enabled: true, + isRegex: false, + caseSensitive: true, + order: 2, + wholeWord: true, + }, + ]; + const ctx = createMockContext(rules, '我有一只猫。
'); + const result = await proofreadTransformer.transform(ctx); + + expect(result).toContain('你有一只狗。'); + expect(result).not.toContain('我'); + expect(result).not.toContain('猫'); + }); }); describe('regex functionality', () => { diff --git a/apps/readest-app/src/services/transformers/proofread.ts b/apps/readest-app/src/services/transformers/proofread.ts index e6be78fe..48a85c88 100644 --- a/apps/readest-app/src/services/transformers/proofread.ts +++ b/apps/readest-app/src/services/transformers/proofread.ts @@ -11,6 +11,29 @@ interface NormalizedPattern { const isUnicodeWordChar = (char: string): boolean => /[\p{L}\p{N}_]/u.test(char || ''); const hasUnicodeChars = (text: string): boolean => /[^\x00-\x7F]/.test(text); +// Scripts that don't use spaces between words (no word boundaries) +// CJK: Chinese, Japanese, Korean +// Thai, Lao, Khmer, Myanmar, Tibetan +const NO_WORD_BOUNDARY_RANGES = [ + '\u4E00-\u9FFF', // CJK Unified Ideographs + '\u3400-\u4DBF', // CJK Unified Ideographs Extension A + '\u3000-\u303F', // CJK Symbols and Punctuation + '\uFF00-\uFFEF', // Halfwidth and Fullwidth Forms + '\u3040-\u309F', // Hiragana + '\u30A0-\u30FF', // Katakana + '\uAC00-\uD7AF', // Korean Hangul Syllables + '\u0E00-\u0E7F', // Thai + '\u0E80-\u0EFF', // Lao + '\u1780-\u17FF', // Khmer + '\u1000-\u109F', // Myanmar + '\u0F00-\u0FFF', // Tibetan +].join(''); + +const isNoWordBoundaryChar = (char: string): boolean => + new RegExp(`[${NO_WORD_BOUNDARY_RANGES}]`).test(char || ''); +const isPureNoWordBoundaryScript = (text: string): boolean => + new RegExp(`^[${NO_WORD_BOUNDARY_RANGES}]+$`).test(text); + function normalizePattern( pattern: string, isRegex: boolean, @@ -61,11 +84,15 @@ function isValidMatch(text: string, match: RegExpExecArray, rule: ProofreadRule) if (!isExactMatch) return false; } - if (hasUnicodeChars(rule.pattern)) { + // Skip word boundary check for scripts without word boundaries (CJK, Thai, Lao, Khmer, Myanmar, Tibetan) + if (hasUnicodeChars(rule.pattern) && !isPureNoWordBoundaryScript(rule.pattern)) { const charBefore = text[match.index - 1] ?? ''; const charAfter = text[match.index + match[0].length] ?? ''; - if (isUnicodeWordChar(charBefore) || isUnicodeWordChar(charAfter)) { - return false; + // Only check word boundaries if adjacent chars are from scripts that use word boundaries + if (!isNoWordBoundaryChar(charBefore) && !isNoWordBoundaryChar(charAfter)) { + if (isUnicodeWordChar(charBefore) || isUnicodeWordChar(charAfter)) { + return false; + } } }