From e41bcfbac9f168fe08a7941fcb3375a9c682f43f Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sat, 21 Mar 2026 02:00:33 +0800 Subject: [PATCH] fix(txt): fix chapter detection for bare numbered headings, closes #3570 (#3572) Three bugs in createChapterRegexps for English txt-to-epub conversion: 1. numberPattern had a capturing group (\d+|...) that leaked single digits into split() results instead of chapter titles. Changed to non-capturing (?:\d+|...). 2. combinedPattern wrapped the heading in (?:...) so split() consumed the title without returning it. Changed to (...) capturing group, matching the Chinese regex structure. 3. The prefix (?:^|\n|\s) allowed matching Chapter/Section after any whitespace, causing mid-sentence false positives. Changed to (?:^|\n) for line-start only. Added a second-tier regex for bare numbered headings (e.g. "1.1The Elements of Programming", "1Building Abstractions") commonly found in academic texts. Dotted numbers allow optional space before title; single bare digits require immediate title to avoid footnote matches. Co-authored-by: Claude Opus 4.6 (1M context) --- .../__tests__/utils/txt-chapter-regex.test.ts | 117 ++++++++++++++++++ .../src/__tests__/utils/txt.test.ts | 12 +- apps/readest-app/src/utils/txt.ts | 11 +- 3 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 apps/readest-app/src/__tests__/utils/txt-chapter-regex.test.ts diff --git a/apps/readest-app/src/__tests__/utils/txt-chapter-regex.test.ts b/apps/readest-app/src/__tests__/utils/txt-chapter-regex.test.ts new file mode 100644 index 00000000..12d92c80 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/txt-chapter-regex.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest'; +import { TxtToEpubConverter } from '@/utils/txt'; + +/** + * Access private createChapterRegexps via a thin test subclass. + */ +class TestableConverter extends TxtToEpubConverter { + getChapterRegexps(language: string): RegExp[] { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (this as any).createChapterRegexps(language); + } +} + +function splitByRegexps(text: string, regexps: RegExp[]): string[] { + for (const regex of regexps) { + const parts = text.split(regex); + const meaningful = parts.filter((p) => p && p.trim().length > 0); + if (meaningful.length > 1) return parts; + } + return []; +} + +describe('English chapter regexps', () => { + const converter = new TestableConverter(); + + it('should match "Chapter N" headings', () => { + const text = + 'Intro text\nChapter 1 Getting Started\nContent here\nChapter 2 Next Topic\nMore content'; + const regexps = converter.getChapterRegexps('en'); + const parts = splitByRegexps(text, regexps); + expect(parts.length).toBeGreaterThan(1); + }); + + it('should match preface keywords like Foreword and Preface', () => { + const text = + 'Foreword\nSome foreword text\nPreface\nSome preface text\nChapter 1 Start\nContent'; + const regexps = converter.getChapterRegexps('en'); + const parts = splitByRegexps(text, regexps); + expect(parts.length).toBeGreaterThan(3); + }); + + it('should match bare dotted-number headings like 1.1Title', () => { + const text = [ + 'Preface text here', + '1Building Abstractions', + 'Chapter 1 content here', + '1.1The Elements of Programming', + 'Section content here about elements', + '1.1.1Expressions', + 'Details about expressions', + '1.2Procedures and Processes', + 'More content about procedures', + ].join('\n'); + const regexps = converter.getChapterRegexps('en'); + const parts = splitByRegexps(text, regexps); + expect(parts.length).toBeGreaterThan(3); + }); + + it('should match dotted-number headings with space before title', () => { + const text = [ + 'Intro', + '1.1 The Elements of Programming', + 'Content', + '1.2 Procedures and Processes', + 'More content', + ].join('\n'); + const regexps = converter.getChapterRegexps('en'); + const parts = splitByRegexps(text, regexps); + expect(parts.length).toBeGreaterThan(1); + }); + + it('should not match bare numbers in code or data lines', () => { + // Numbers like "12.7" or "314.159" within content should NOT be treated as headings + const text = [ + '1.1The Elements of Programming', + 'The value is 12.7 and also 314.159', + '62.8318', + '1.2Naming and the Environment', + 'More content', + ].join('\n'); + const regexps = converter.getChapterRegexps('en'); + const parts = splitByRegexps(text, regexps); + // Should split on 1.1... and 1.2... but not on 12.7, 314.159, 62.8318 + const headings = parts.filter((p) => p && /^\d+\.\d/.test(p.trim())); + for (const h of headings) { + // Every matched heading should start with a dotted number followed by a letter + expect(h.trim()).toMatch(/^\d+(\.\d+)*[A-Z]/); + } + }); + + it('should match single-digit chapter heading like 1Title', () => { + const text = + '1Building Abstractions with Procedures\nContent\n2Building Abstractions with Data\nMore'; + const regexps = converter.getChapterRegexps('en'); + const parts = splitByRegexps(text, regexps); + expect(parts.length).toBeGreaterThan(1); + }); + + it('should not match footnote-like lines with digit-space-lowercase', () => { + // Lines like "1 The Lisp..." or "2 The two dialects..." are footnotes, not headings + const text = [ + '1.1The Elements of Programming', + 'Some content here', + '1 The Lisp 1 Programmers Manual appeared in 1960', + '2 The two dialects in which most major Lisp programs were written', + '3 One such special application was a breakthrough', + '1.2Naming and the Environment', + 'More content', + ].join('\n'); + const regexps = converter.getChapterRegexps('en'); + const parts = splitByRegexps(text, regexps); + // Should split on 1.1... and 1.2... but NOT on "1 The Lisp...", "2 The two...", "3 One such..." + const meaningful = parts.filter((p) => p && p.trim().length > 0); + // With 2 headings we expect ~5 parts (pre, heading1, content, heading2, content) + expect(meaningful.length).toBeLessThanOrEqual(5); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/txt.test.ts b/apps/readest-app/src/__tests__/utils/txt.test.ts index 1c8b47d3..970a2fb9 100644 --- a/apps/readest-app/src/__tests__/utils/txt.test.ts +++ b/apps/readest-app/src/__tests__/utils/txt.test.ts @@ -389,16 +389,16 @@ describe('createChapterRegexps — structure', () => { expect(api.createChapterRegexps('zh').length).toBe(2); }); - it('should produce one regexp for English', () => { + it('should produce two regexps for English', () => { const api = getApi(); - expect(api.createChapterRegexps('en').length).toBe(1); + expect(api.createChapterRegexps('en').length).toBe(2); }); - it('should produce one regexp for any non-zh language', () => { + it('should produce two regexps for any non-zh language', () => { const api = getApi(); - expect(api.createChapterRegexps('fr').length).toBe(1); - expect(api.createChapterRegexps('ja').length).toBe(1); - expect(api.createChapterRegexps('de').length).toBe(1); + expect(api.createChapterRegexps('fr').length).toBe(2); + expect(api.createChapterRegexps('ja').length).toBe(2); + expect(api.createChapterRegexps('de').length).toBe(2); }); it('Chinese first regex should be case-insensitive with unicode flag', () => { diff --git a/apps/readest-app/src/utils/txt.ts b/apps/readest-app/src/utils/txt.ts index 244c69f3..d75f8f9a 100644 --- a/apps/readest-app/src/utils/txt.ts +++ b/apps/readest-app/src/utils/txt.ts @@ -655,7 +655,7 @@ export class TxtToEpubConverter { 'Afterword', ]; - const numberPattern = String.raw`(\d+|(?:[IVXLCDM]{2,}|V|X|L|C|D|M)\b)`; + const numberPattern = String.raw`(?:\d+|(?:[IVXLCDM]{2,}|V|X|L|C|D|M)\b)`; const dotNumberPattern = String.raw`\.\d{1,4}`; const titlePattern = String.raw`[^\n]{0,50}`; @@ -670,9 +670,16 @@ export class TxtToEpubConverter { .map((k) => String.raw`${k}(?:[:.\-–—]?\s*${titlePattern})?`) .join('|'); - const combinedPattern = String.raw`(?:^|\n|\s)(?:${normalChapterPattern}|${prefacePattern})(?=\s|$)`; + const combinedPattern = String.raw`(?:^|\n)(${normalChapterPattern}|${prefacePattern})(?=\s|$)`; chapterRegexps.push(new RegExp(combinedPattern, 'gi')); + // Second-tier: bare numbered headings like "1.1The Elements" or "1Building Data" + // Dotted numbers (1.1, 1.2.3) allow an optional space before the title. + // Single bare digits (1, 2) require the title to start immediately (no space) + // to avoid matching footnotes like "1 The Lisp...". + const numberedHeadingPattern = String.raw`(?:^|\n)(\d+\.\d+(?:\.\d+)* ?[A-Z][^\n]{0,80}|\d+[A-Z][^\n]{0,80})`; + chapterRegexps.push(new RegExp(numberedHeadingPattern, 'g')); + return chapterRegexps; }