From 1faa931a0e312f8a172021ce52469bb1a1bb6619 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 19 Jun 2026 16:09:04 +0800 Subject: [PATCH] fix(txt): stop detecting measure-word prose as chapters in TXT import (#4658) (#4660) The Chinese chapter-detection regex treated certain measure words (the classifiers for "letter" and "book") as chapter units and let a title attach directly after the unit. As a result, ordinary prose such as "the first letter" or "the fourth book records..." was split out as bogus TOC entries when importing Chinese TXT novels. Split the unit characters into two explicit tiers that share the same number prefix and stay in one alternation (a single split pass, so a segment mixing chapter and volume headings is handled together): - Chapter units may carry a title attached directly, unchanged. - Volume/measure-word units only start a heading when the title is introduced by a separator (colon, comma, space, or parentheses) or the line ends, never a bare noun directly after the unit. Real volume and chapter headings still match. Adds regex-level and end-to-end tests covering both reported cases. Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/__tests__/utils/txt.test.ts | 64 +++++++++++++++++++ apps/readest-app/src/utils/txt.ts | 27 ++++++-- 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/apps/readest-app/src/__tests__/utils/txt.test.ts b/apps/readest-app/src/__tests__/utils/txt.test.ts index 84cca16f..72a4a9d7 100644 --- a/apps/readest-app/src/__tests__/utils/txt.test.ts +++ b/apps/readest-app/src/__tests__/utils/txt.test.ts @@ -148,6 +148,41 @@ describe('createChapterRegexps — Chinese (zh) regex matching', () => { }); }); + // Measure-word phrases such as 第一封信 ("the first letter") and 第四本书 + // ("the fourth book") are running prose, not chapter headings. The volume + // measure words 卷/本/册/部/封 only start a heading when followed by a + // separator or the line end — not when a noun follows them directly. See + // issue #4658. + describe('measure-word false positives (issue #4658)', () => { + it.each([ + '第一封信', + '第一封信。', + '第七封信', + '第四本书记载着锤法', + '第四本书记载着锤法,来自孙家,可惜对秦铭用处不大了,他已经彻底掌握。', + '第三本书', + '第十部手机', + '第一册书', + '第二卷书卷', + ])('should NOT match measure-word prose "%s"', (line) => { + const regex = getFirstRegex('zh'); + expect(regex.test(`\n${line}\n`)).toBe(false); + }); + + it.each([ + '第一封', + '第一封 致读者', + '第一本', + '第一本 标题', + '第二部 中篇', + '第一册 上', + '第一卷 起始篇', + ])('should still match a real heading "%s"', (line) => { + const regex = getFirstRegex('zh'); + expect(regex.test(`\n${line}\n`)).toBe(true); + }); + }); + describe('should not match', () => { it('should not match chapter heading embedded mid-line', () => { const regex = getFirstRegex('zh'); @@ -387,6 +422,35 @@ describe('extractChaptersFromSegment — Chinese (zh)', () => { expect(chapters[0]!.content).toContain('前文'); }); + it('should not extract 第N封信 letter markers as chapters (issue #4658)', () => { + const text = [ + '第一百八十九章 许七安的七封信', + '驿卒将信递了过来。', + '第一封信', + '写这封信的时候我正在云州。', + '第二封信', + '临安公主亲启。', + ].join('\n'); + const chapters = extractChapters(text, 'zh'); + const titles = chapters.map((c) => c.title); + expect(titles).toEqual(['第一百八十九章 许七安的七封信']); + expect(chapters[0]!.content).toContain('第一封信'); + expect(chapters[0]!.content).toContain('第二封信'); + }); + + it('should not extract 第N本书 + sentence as a chapter (issue #4658)', () => { + const text = [ + '第八十四章 比异人强一点', + '他翻阅着那几本秘籍。', + '第四本书记载着锤法,来自孙家,可惜对秦铭用处不大了,他已经彻底掌握。', + '日子一天天过去。', + ].join('\n'); + const chapters = extractChapters(text, 'zh'); + const titles = chapters.map((c) => c.title); + expect(titles).toEqual(['第八十四章 比异人强一点']); + expect(chapters[0]!.content).toContain('第四本书记载着锤法'); + }); + it('should extract 番外 chapters mixed with regular chapters (issue #4016)', () => { const text = [ '第57章 s级冰系掌控', diff --git a/apps/readest-app/src/utils/txt.ts b/apps/readest-app/src/utils/txt.ts index a2db46b2..5a543f56 100644 --- a/apps/readest-app/src/utils/txt.ts +++ b/apps/readest-app/src/utils/txt.ts @@ -719,15 +719,28 @@ export class TxtToEpubConverter { const chapterRegexps: RegExp[] = []; if (language === 'zh') { + // 第N + unit, expressed as two explicit tiers that share the 第N prefix and + // the trailing boundary. They stay in ONE alternation (and so one split + // pass) on purpose: a segment often mixes chapter and volume headings (a + // volume wraps chapters), and the regexps array is a fallback chain — the + // first regex that splits "well enough" wins — so separate entries would + // recognize one tier and silently drop the other. + const cjkNumber = '第[  零〇一二三四五六七八九十0-9][  零〇一二三四五六七八九十百千万0-9]*'; + // Tier 1 — chapter units. Real headings; a title may attach directly + // (第一章天地初开) or after a separator. + const chapterUnit = String.raw`[章节回讲篇话](?:[::、  \(\)0-9]*[^\n-]{0,36})`; + // Tier 2 — volume/measure-word units. These double as 量词 in prose + // (第一封信 "the first letter", 第四本书 "the fourth book"), so a title only + // counts when introduced by a separator (::、, space, parens) or the line + // ends — never a bare noun directly after the unit. See issue #4658. + const volumeUnit = String.raw`[卷本册部封](?:[::、  \(\)][::、  \(\)0-9]*[^\n-]{0,36})?`; + const numberedHeading = String.raw`${cjkNumber}(?:${chapterUnit}|${volumeUnit})(?!\S)`; + const prefaceHeading = String.raw`(?:楔子|前言|简介|引言|序言|序章|总论|概论|后记|番外篇|番外|外传)(?:[::  ][^\n-]{0,36})?(?!\S)`; + const englishHeading = String.raw`chapter[\s.]*[0-9]+(?:[::.  ]+[^\n-]{0,50})?(?!\S)`; chapterRegexps.push( new RegExp( - String.raw`(?:^|\n)\s*` + - '(' + - [ - String.raw`第[  零〇一二三四五六七八九十0-9][  零〇一二三四五六七八九十百千万0-9]*(?:[章卷节回讲篇封本册部话])(?:[::、  \(\)0-9]*[^\n-]{0,36})(?!\S)`, - String.raw`(?:楔子|前言|简介|引言|序言|序章|总论|概论|后记|番外篇|番外|外传)(?:[::  ][^\n-]{0,36})?(?!\S)`, - String.raw`chapter[\s.]*[0-9]+(?:[::.  ]+[^\n-]{0,50})?(?!\S)`, - ].join('|') + + String.raw`(?:^|\n)\s*(` + + [numberedHeading, prefaceHeading, englishHeading].join('|') + ')', 'gui', ),