fix(canvas): 小说导入 - 增强 TXT 分章与编码识别 (#28)

This commit is contained in:
Cyrus Auyeung
2026-07-23 10:34:59 +08:00
committed by GitHub
parent d85b06a971
commit ca19e2f15a
3 changed files with 98 additions and 5 deletions

View File

@@ -45,7 +45,7 @@ import { TextStyle } from "@tiptap/extension-text-style";
import Color from "@tiptap/extension-color";
import type { Editor } from "@tiptap/core";
import type { CanvasDocumentChapter, CanvasNodeData, CanvasRichDocument } from "@/types/canvas";
import { buildRichDocument, createDocumentChapter, emptyDocument, normalizeDocumentChapters, splitTextIntoChapters } from "@/lib/canvas/canvas-document";
import { buildRichDocument, createDocumentChapter, decodeNovelText, emptyDocument, normalizeDocumentChapters, splitTextIntoChapters } from "@/lib/canvas/canvas-document";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -281,7 +281,7 @@ export function CanvasDocumentEditorModal({ node, open, saving = false, analyzin
if (file.name.toLowerCase().endsWith(".docx")) {
const mammoth = await import("mammoth");
text = (await mammoth.extractRawText({ arrayBuffer: await file.arrayBuffer() })).value;
} else text = await file.text();
} else text = decodeNovelText(await file.arrayBuffer());
if (!text.trim()) throw new Error("文件中没有可编辑的文字");
const imported = splitTextIntoChapters(text);
setChapters(imported);

View File

@@ -1,6 +1,19 @@
import type { JSONContent } from "@tiptap/core";
import type { CanvasDocumentChapter, CanvasRichDocument } from "@/types/canvas";
const maxChapterHeadingLength = 120;
const chineseChapterNumber = "[零〇○一二两三四五六七八九十百千万亿壹贰叁肆伍陆柒捌玖拾佰仟\\d]+";
const englishChapterNumber = "(?:\\d+|[ivxlcdm]+|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)";
const chapterTitleSuffix = "(?:\\s*[-—–::·.]\\s*.*|\\s+.*)?";
const chapterHeadingPatterns = [
new RegExp(`^(?:正文\\s*)?第\\s*${chineseChapterNumber}\\s*[章节卷回部篇集季幕]${chapterTitleSuffix}$`, "i"),
new RegExp(`^(?:卷|篇|部|集|季|幕)\\s*${chineseChapterNumber}${chapterTitleSuffix}$`, "i"),
new RegExp(`^(?:序|序幕|序章|序言|前言|前记|楔子|引子|引言|开篇|终章|尾声|后记)${chapterTitleSuffix}$`, "i"),
new RegExp(`^(?:番外(?:篇)?|附录)(?:\\s*(?:第\\s*)?${chineseChapterNumber}\\s*[章节回篇]?)?${chapterTitleSuffix}$`, "i"),
new RegExp(`^(?:chapter|book|part|volume)\\s+${englishChapterNumber}\\b${chapterTitleSuffix}$`, "i"),
new RegExp(`^(?:prologue|preface|introduction|epilogue|afterword|appendix)${chapterTitleSuffix}$`, "i"),
];
export function emptyDocument(): JSONContent {
return { type: "doc", content: [{ type: "paragraph" }] };
}
@@ -40,19 +53,51 @@ export function normalizeDocumentChapters(document: CanvasRichDocument | undefin
return [chapter];
}
function chapterTitleFromLine(line: string) {
const trimmed = line.trim();
if (!trimmed || Array.from(trimmed).length > maxChapterHeadingLength) return null;
const title = trimmed
.replace(/^[##]{1,6}\s*/, "")
.replace(/^[\s=_*~\-—–]+|[\s=_*~\-—–]+$/g, "")
.trim();
const candidate = title
.normalize("NFKC")
.replace(/[【】\[\]「」『』〈〉《》〖〗]/g, " ")
.replace(/\s+/g, " ")
.trim();
// 只把较短且整行符合章节结构的文本当作标题,避免正文中提到“第一章”时误切分。
return chapterHeadingPatterns.some((pattern) => pattern.test(candidate)) ? title : null;
}
export function decodeNovelText(buffer: ArrayBuffer) {
const bytes = new Uint8Array(buffer);
if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) return new TextDecoder("utf-8").decode(bytes.subarray(3));
if (bytes[0] === 0xff && bytes[1] === 0xfe) return new TextDecoder("utf-16le").decode(bytes.subarray(2));
if (bytes[0] === 0xfe && bytes[1] === 0xff) return new TextDecoder("utf-16be").decode(bytes.subarray(2));
try {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
// 中文网文 TXT 常由旧版编辑器导出为 GBK;GB18030 兼容 GBK,并能覆盖更多汉字。
return new TextDecoder("gb18030").decode(bytes);
}
}
export function splitTextIntoChapters(text: string) {
const normalized = text.replace(/\r\n?/g, "\n").trim();
if (!normalized) return [createDocumentChapter("第 1 章")];
const lines = normalized.split("\n");
const headingPattern = /^\s*(第[零〇一二两三四五六七八九十百千万\d]+[章节卷回部篇]|chapter\s+\d+\b|序章|楔子|引子|尾声|后记)(?:[\s::.-]+.*)?\s*$/i;
const sections: Array<{ title: string; lines: string[] }> = [];
let current: { title: string; lines: string[] } | null = null;
const preface: string[] = [];
for (const line of lines) {
if (headingPattern.test(line.trim())) {
const chapterTitle = chapterTitleFromLine(line);
if (chapterTitle) {
if (current) sections.push(current);
current = { title: line.trim(), lines: [] };
current = { title: chapterTitle, lines: [] };
continue;
}
if (current) current.lines.push(line);

View File

@@ -0,0 +1,48 @@
import { describe, expect, test } from "bun:test";
import { decodeNovelText, splitTextIntoChapters } from "../src/lib/canvas/canvas-document";
describe("splitTextIntoChapters", () => {
test("识别常见中文、Markdown、装饰符与英文标题", () => {
const chapters = splitTextIntoChapters(`书名与作者
第 1 章 初入江湖
第一章正文
# 第2章 风雨欲来
第二章正文
【第三章】再会故人
第三章正文
卷四 山河故人
第四章正文
Epilogue: 重逢
尾声正文`);
expect(chapters.map((chapter) => chapter.title)).toEqual(["序章", "第 1 章 初入江湖", "第2章 风雨欲来", "【第三章】再会故人", "卷四 山河故人", "Epilogue: 重逢"]);
expect(chapters[0].plainText).toBe("书名与作者");
expect(chapters[2].plainText).toBe("第二章正文");
});
test("普通正文和未识别文本保持为单个章节", () => {
const text = "这是第一章中发生的故事。\n角色继续前行。";
const chapters = splitTextIntoChapters(text);
expect(chapters).toHaveLength(1);
expect(chapters[0].title).toBe("第 1 章");
expect(chapters[0].plainText).toBe(text);
});
});
describe("decodeNovelText", () => {
test("解码带 BOM 的 UTF-16LE", () => {
const bytes = new Uint8Array([0xff, 0xfe, 0x2c, 0x7b, 0x00, 0x4e, 0xe0, 0x7a]);
expect(decodeNovelText(bytes.buffer)).toBe("第一章");
});
test("UTF-8 严格解码失败后回退到 GB18030", () => {
const bytes = new Uint8Array([0xb5, 0xda, 0xd2, 0xbb, 0xd5, 0xc2, 0x0a, 0xd5, 0xfd, 0xce, 0xc4]);
expect(decodeNovelText(bytes.buffer)).toBe("第一章\n正文");
});
});