chapter-splitter.ts 2.3 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
  1. export interface SplitChapter {
  2. readonly title: string;
  3. readonly content: string;
  4. }
  5. /**
  6. * Split a single text file into chapters by matching title lines.
  7. *
  8. * Default pattern matches:
  9. * - "第一章 xxxx" / "第1章 xxxx"
  10. * - "第一回 xxxx" / "第1回 xxxx"
  11. * - "# 第1章 xxxx" / "## 第23章 xxxx"
  12. * - "CHAPTER I." / "CHAPTER II."
  13. *
  14. * Each match marks the start of a new chapter. Content between matches
  15. * belongs to the preceding chapter.
  16. */
  17. export function splitChapters(
  18. text: string,
  19. pattern?: string,
  20. ): ReadonlyArray<SplitChapter> {
  21. const defaultPattern = /^#{0,2}\s*(?:第[零〇○O0一二三四五六七八九十百千万\d]+(?:章|回)(?:[::]|\s+)?\s*(.*)|Chapter\s+(?:\d+|[IVXLCDM]+)(?:\.|:|\s+)?\s*(.*))/i;
  22. const regex = pattern ? new RegExp(pattern, "m") : defaultPattern;
  23. const lines = text.split("\n");
  24. const chapters: Array<{ title: string; startLine: number }> = [];
  25. for (let i = 0; i < lines.length; i++) {
  26. const match = lines[i]!.match(regex);
  27. if (match) {
  28. chapters.push({
  29. title: (match[1] ?? match[2] ?? "").trim(),
  30. startLine: i,
  31. });
  32. }
  33. }
  34. if (chapters.length === 0) {
  35. return [];
  36. }
  37. const result: SplitChapter[] = [];
  38. for (let i = 0; i < chapters.length; i++) {
  39. const chapter = chapters[i]!;
  40. const nextStart = i + 1 < chapters.length ? chapters[i + 1]!.startLine : lines.length;
  41. // Content starts after the title line
  42. const contentLines = lines.slice(chapter.startLine + 1, nextStart);
  43. const content = stripTrailingLicense(contentLines.join("\n")).trim();
  44. result.push({
  45. title: chapter.title || inferFallbackTitle(lines[chapter.startLine] ?? "", i + 1),
  46. content,
  47. });
  48. }
  49. return result;
  50. }
  51. function stripTrailingLicense(content: string): string {
  52. const trailerMatch = content.match(/^\s*Project Gutenberg(?:™|\(TM\))?.*$/im);
  53. if (!trailerMatch || trailerMatch.index === undefined) {
  54. return content;
  55. }
  56. return content.slice(0, trailerMatch.index).trimEnd();
  57. }
  58. function inferFallbackTitle(headingLine: string, chapterNumber: number): string {
  59. if (/chapter\s+(?:\d+|[ivxlcdm]+)/i.test(headingLine)) {
  60. return `Chapter ${chapterNumber}`;
  61. }
  62. if (/第[零一二三四五六七八九十百千万\d]+回/.test(headingLine)) {
  63. return `第${chapterNumber}回`;
  64. }
  65. return `第${chapterNumber}章`;
  66. }