fix(canvas): 小说导入 - 增强 TXT 分章与编码识别 (#28)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
48
web/test/canvas-document.test.ts
Normal file
48
web/test/canvas-document.test.ts
Normal 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正文");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user