The TXT-to-EPUB segment regex splits on dash dividers (`-{8,}`), which
authors commonly use as in-chapter scene breaks. Each heading-less section
after such a divider was emitted as its own chapter — a numbered paragraph
fallback chapter, or a chapter titled after a stray sentence — flooding the
generated TOC with entries that aren't real chapters.
Mark chapters with whether their title came from a detected heading, and
merge heading-less chapters into the preceding detected chapter instead of
pushing them as separate TOC entries. Fully heading-less text still chunks
into numbered fallback chapters as before.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -346,6 +346,86 @@ describe('TxtToEpubConverter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('scene-break dividers do not pollute the TOC (issue #4063)', () => {
|
||||
const zhMetadata: TestMetadata = {
|
||||
bookTitle: 'Test',
|
||||
author: '',
|
||||
language: 'zh',
|
||||
identifier: 'test',
|
||||
};
|
||||
const option = { linesBetweenSegments: 8, fallbackParagraphsPerChapter: 100 };
|
||||
const divider = '-'.repeat(37);
|
||||
|
||||
it('keeps a single chapter when dash dividers split scene breaks', () => {
|
||||
const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI;
|
||||
const text = [
|
||||
'第15章',
|
||||
'这是第十五章的开头内容。',
|
||||
divider,
|
||||
'场景切换后的内容继续。',
|
||||
divider,
|
||||
'又一个场景的内容。',
|
||||
].join('\n');
|
||||
|
||||
const chapters = converter.extractChapters(text, zhMetadata, option);
|
||||
|
||||
expect(chapters.length).toBe(1);
|
||||
expect(chapters[0]!.title).toContain('第15章');
|
||||
expect(chapters[0]!.content).toContain('场景切换后的内容继续');
|
||||
expect(chapters[0]!.content).toContain('又一个场景的内容');
|
||||
});
|
||||
|
||||
it('merges mid-chapter content split off before the next heading', () => {
|
||||
const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI;
|
||||
const text = [
|
||||
'第1章',
|
||||
'绿水青山就是金山银山。',
|
||||
divider,
|
||||
'分隔符之后继续讲述的内容。',
|
||||
'第2章',
|
||||
'新的章节正式开始了。',
|
||||
].join('\n');
|
||||
|
||||
const chapters = converter.extractChapters(text, zhMetadata, option);
|
||||
|
||||
expect(chapters.map((c) => c.title)).toEqual(['第1章', '第2章']);
|
||||
expect(chapters[0]!.content).toContain('分隔符之后继续讲述的内容');
|
||||
expect(chapters[0]!.content).not.toContain('<h3>');
|
||||
});
|
||||
|
||||
it('still chunks heading-less plain text by paragraph fallback', () => {
|
||||
const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI;
|
||||
const paragraphs = Array.from({ length: 250 }, (_, i) => `段落${i + 1}`).join('\n');
|
||||
|
||||
const chapters = converter.extractChapters(paragraphs, zhMetadata, option);
|
||||
|
||||
expect(chapters.length).toBe(3);
|
||||
});
|
||||
|
||||
it('merges divider-split scene breaks in the chunked file path', async () => {
|
||||
const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI;
|
||||
const text = [
|
||||
'第15章',
|
||||
'这是第十五章的开头内容。',
|
||||
divider,
|
||||
'场景切换后的内容继续。',
|
||||
divider,
|
||||
'又一个场景的内容。',
|
||||
].join('\n');
|
||||
const file = new File([text], 'sample.txt');
|
||||
|
||||
const chapters = await converter.extractChaptersFromFileBySegments(
|
||||
file,
|
||||
'utf-8',
|
||||
zhMetadata,
|
||||
option,
|
||||
);
|
||||
|
||||
expect(chapters.length).toBe(1);
|
||||
expect(chapters[0]!.title).toContain('第15章');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTxtFilenameMetadata', () => {
|
||||
it('extracts the title from CJK 《》 brackets', () => {
|
||||
expect(extractTxtFilenameMetadata('《三体》.txt')).toEqual({ title: '三体' });
|
||||
|
||||
@@ -54,6 +54,11 @@ interface Chapter {
|
||||
title: string;
|
||||
content: string;
|
||||
isVolume: boolean;
|
||||
// True when the title came from a detected chapter heading. Chapters whose
|
||||
// content was not found under a heading (paragraph fallback, or stray text
|
||||
// split off by the segment regex) are merged into the preceding detected
|
||||
// chapter instead of becoming bogus TOC entries. See issue #4063.
|
||||
detected?: boolean;
|
||||
}
|
||||
|
||||
interface Txt2EpubOptions {
|
||||
@@ -242,12 +247,30 @@ export class TxtToEpubConverter {
|
||||
option,
|
||||
chapters.length,
|
||||
);
|
||||
chapters.push(...segmentChapters);
|
||||
this.appendSegmentChapters(chapters, segmentChapters);
|
||||
}
|
||||
|
||||
return chapters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a segment's chapters to the running list. The segment regex also
|
||||
* splits on dash dividers, which authors frequently use as in-chapter scene
|
||||
* breaks; the content after such a divider has no heading of its own. When a
|
||||
* heading-less chapter follows a detected chapter, merge its content into
|
||||
* that chapter instead of emitting a separate (bogus) TOC entry. See #4063.
|
||||
*/
|
||||
private appendSegmentChapters(chapters: Chapter[], segmentChapters: Chapter[]): void {
|
||||
for (const chapter of segmentChapters) {
|
||||
const previous = chapters[chapters.length - 1];
|
||||
if (!chapter.detected && previous?.detected) {
|
||||
previous.content += chapter.content.replace(/^<h[1-6][^>]*>[\s\S]*?<\/h[1-6]>/, '');
|
||||
} else {
|
||||
chapters.push(chapter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private probeChapterCount(
|
||||
txtContent: string,
|
||||
metadata: Metadata,
|
||||
@@ -286,7 +309,7 @@ export class TxtToEpubConverter {
|
||||
option,
|
||||
chapters.length,
|
||||
);
|
||||
chapters.push(...segmentChapters);
|
||||
this.appendSegmentChapters(chapters, segmentChapters);
|
||||
}
|
||||
return chapters;
|
||||
}
|
||||
@@ -545,7 +568,7 @@ export class TxtToEpubConverter {
|
||||
const formattedSegment = this.formatSegment(chunks.join('\n'));
|
||||
const title = `${chapterOffset + chapters.length + 1}`;
|
||||
const content = `<h2>${title}</h2><p>${formattedSegment}</p>`;
|
||||
chapters.push({ title, content, isVolume: false });
|
||||
chapters.push({ title, content, isVolume: false, detected: false });
|
||||
}
|
||||
return chapters;
|
||||
}
|
||||
@@ -568,6 +591,7 @@ export class TxtToEpubConverter {
|
||||
title: escapeXml(title),
|
||||
content: `${headTitle}<p>${formattedSegment}</p>`,
|
||||
isVolume,
|
||||
detected: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -582,6 +606,7 @@ export class TxtToEpubConverter {
|
||||
title: escapeXml(segmentTitle),
|
||||
content: `<h3></h3><p>${formattedSegment}</p>`,
|
||||
isVolume: false,
|
||||
detected: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: 4361f29b5a...3c597a6dc5
Reference in New Issue
Block a user