Files
readest/apps/readest-app/src/utils/txt.ts
T
Huang Xin aa3cd2a0c5 txt: trim punctuations in parsed author names (#1707)
* txt: trim punctuations in parsed author names

* release: version 0.9.68
2025-07-29 20:50:21 +02:00

454 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getBaseFilename } from './book';
import { partialMD5 } from './md5';
interface Metadata {
bookTitle: string;
author: string;
language: string;
identifier: string;
}
interface Chapter {
title: string;
content: string;
}
interface Txt2EpubOptions {
file: File;
author?: string;
language?: string;
}
interface ExtractChapterOptions {
linesBetweenSegments: number;
fallbackParagraphsPerChapter: number;
}
interface ConversionResult {
file: File;
bookTitle: string;
chapterCount: number;
language: string;
}
const zipWriteOptions = {
lastAccessDate: new Date(0),
lastModDate: new Date(0),
};
const escapeXml = (str: string) => {
if (!str) return '';
return str
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
};
export class TxtToEpubConverter {
public async convert(options: Txt2EpubOptions): Promise<ConversionResult> {
const { file: txtFile, author: providedAuthor, language: providedLanguage } = options;
const fileContent = await txtFile.arrayBuffer();
const detectedEncoding = this.detectEncoding(fileContent) || 'utf-8';
const decoder = new TextDecoder(detectedEncoding);
const txtContent = decoder.decode(fileContent).trim();
const bookTitle = this.extractBookTitle(getBaseFilename(txtFile.name));
const fileName = `${bookTitle}.epub`;
const fileHeader = txtContent.slice(0, 1024);
const authorMatch =
fileHeader.match(/[【\[]?作者[】\]]?[:\s]\s*(.+)\r?\n/) ||
fileHeader.match(/[【\[]?\s*(.+)\s+著\s*[】\]]?\r?\n/);
let matchedAuthor = authorMatch ? authorMatch[1]!.trim() : providedAuthor || '';
try {
matchedAuthor = matchedAuthor.replace(/^[\p{P}\p{S}]+|[\p{P}\p{S}]+$/gu, '');
} catch {}
const author = matchedAuthor || providedAuthor || '';
const language = providedLanguage || this.detectLanguage(fileHeader);
const identifier = await partialMD5(txtFile);
const metadata = { bookTitle, author, language, identifier };
let chapters: Chapter[] = [];
for (let i = 8; i >= 6; i--) {
chapters = this.extractChapters(txtContent, metadata, {
linesBetweenSegments: i,
fallbackParagraphsPerChapter: 100,
});
if (chapters.length === 0) {
throw new Error('No chapters detected.');
} else if (chapters.length > 1) {
break;
}
}
const blob = await this.createEpub(chapters, metadata);
return {
file: new File([blob], fileName),
bookTitle,
chapterCount: chapters.length,
language,
};
}
private extractChapters(
txtContent: string,
metadata: Metadata,
option: ExtractChapterOptions,
): Chapter[] {
const { language } = metadata;
const { linesBetweenSegments, fallbackParagraphsPerChapter } = option;
const segmentRegex = new RegExp(`(?:\\r?\\n){${linesBetweenSegments},}|-{8,}\r?\n`);
const chapterRegexps: RegExp[] = [];
if (language === 'zh') {
chapterRegexps.push(
new RegExp(
String.raw`(?:^|\n)\s*` +
'(' +
[
String.raw`第[零〇一二三四五六七八九十0-9][零〇一二三四五六七八九十百千万0-9]*(?:[章卷节回讲篇封])(?:[:、  \(\)0-9]*[^\n-]{0,24})(?!\S)`,
String.raw`(?:楔子|前言|简介|引言|序言|序章|总论|概论)(?:[:  ][^\n-]{0,24})?(?!\S)`,
].join('|') +
')',
'gu',
),
);
chapterRegexps.push(
new RegExp(
String.raw`(?:^|\n)\s*` +
'(' +
[
String.raw`[一二三四五六七八九十][零〇一二三四五六七八九十百千万]?[::、  ][^\n-]{0,24}(?=\n|$)`,
String.raw`[0-9]+[^\n]{0,16}(?=\n|$)`,
].join('|') +
')',
'gu',
),
);
} else {
chapterRegexps.push(
/(?:^|\n|\s)(?:(Chapter|Part)\s+(\d+|[IVXLCDM]+)(?:[:.\-–—]?\s+[^\n]*)?|(?:Prologue|Epilogue|Introduction|Foreword)(?:[:.\-–—]?\s+[^\n]*)?)(?=\s|$)/gi,
);
}
const formatSegment = (segment: string): string => {
segment = escapeXml(segment);
return segment
.replace(/-{8,}|_{8,}/g, '\n')
.split(/\n+/)
.map((line) => line.trim())
.filter((line) => line)
.join('</p><p>');
};
const joinAroundUndefined = (arr: (string | undefined)[]) =>
arr.reduce<string[]>((acc, curr, i, src) => {
if (
curr === undefined &&
i > 0 &&
i < src.length - 1 &&
src[i - 1] !== undefined &&
src[i + 1] !== undefined
) {
acc[acc.length - 1] += src[i + 1]!;
return acc;
}
if (curr !== undefined && (i === 0 || src[i - 1] !== undefined)) {
acc.push(curr);
}
return acc;
}, []);
const isGoodMatches = (matches: string[], maxLength: number = 100000): boolean => {
const meaningfulParts = matches.filter((part) => part.trim().length > 0);
if (meaningfulParts.length <= 1) return false;
const hasLongParts = meaningfulParts.some((part) => part.length > maxLength);
return !hasLongParts;
};
const chapters: Chapter[] = [];
const segments = txtContent.split(segmentRegex);
for (const segment of segments) {
const trimmedSegment = segment.replace(/<!--.*?-->/g, '').trim();
if (!trimmedSegment) continue;
const segmentChapters = [];
let matches: string[] = [];
for (const chapterRegex of chapterRegexps) {
const tryMatches = trimmedSegment.split(chapterRegex);
if (isGoodMatches(tryMatches)) {
matches = joinAroundUndefined(tryMatches);
break;
}
}
if (matches.length === 0 && fallbackParagraphsPerChapter > 0) {
const paragraphs = trimmedSegment.split(/\n+/);
const totalParagraphs = paragraphs.length;
for (let i = 0; i < totalParagraphs; i += fallbackParagraphsPerChapter) {
const chunks = paragraphs.slice(i, i + fallbackParagraphsPerChapter);
const formattedSegment = formatSegment(chunks.join('\n'));
const title = `${chapters.length + 1}`;
const content = `<h2>${title}</h2><p>${formattedSegment}</p>`;
chapters.push({ title, content });
}
continue;
}
for (let j = 1; j < matches.length; j += 2) {
const title = matches[j]?.trim() || '';
const content = matches[j + 1]?.trim() || '';
let isVolume = false;
if (language === 'zh') {
isVolume = /第[零〇一二三四五六七八九十百千万0-9]+卷/.test(title);
} else {
isVolume = /\b(Part|Volume|Book)\b/i.test(title);
}
const headTitle = isVolume ? `<h1>${title}</h1>` : `<h2>${title}</h2>`;
const formattedSegment = formatSegment(content);
segmentChapters.push({
title: escapeXml(title),
content: `${headTitle}<p>${formattedSegment}</p>`,
});
}
if (matches[0] && matches[0].trim()) {
const initialContent = matches[0].trim();
const firstLine = initialContent.split('\n')[0]!.trim();
const segmentTitle =
(firstLine.length > 16 ? initialContent.split(/[\n\s\p{P}]/u)[0]!.trim() : firstLine) ||
initialContent.slice(0, 16);
const formattedSegment = formatSegment(initialContent);
segmentChapters.unshift({
title: escapeXml(segmentTitle),
content: `<h3></h3><p>${formattedSegment}</p>`,
});
}
chapters.push(...segmentChapters);
}
return chapters;
}
private async createEpub(chapters: Chapter[], metadata: Metadata): Promise<Blob> {
const { BlobWriter, TextReader, ZipWriter } = await import('@zip.js/zip.js');
const { bookTitle, author, language, identifier } = metadata;
const zipWriter = new ZipWriter(new BlobWriter('application/epub+zip'), {
extendedTimestamp: false,
});
await zipWriter.add('mimetype', new TextReader('application/epub+zip'), zipWriteOptions);
// Add META-INF/container.xml
const containerXml = `<?xml version="1.0" encoding="UTF-8"?>
<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0">
<rootfiles>
<rootfile full-path="content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`.trim();
await zipWriter.add('META-INF/container.xml', new TextReader(containerXml), zipWriteOptions);
// Create navigation points for TOC
const navPoints = chapters
.map((chapter, index) => {
const id = `chapter${index + 1}`;
const playOrder = index + 1;
return `
<navPoint id="navPoint-${id}" playOrder="${playOrder}">
<navLabel>
<text>${chapter.title}</text>
</navLabel>
<content src="./OEBPS/${id}.xhtml" />
</navPoint>
`.trim();
})
.join('\n');
// Add NCX file (table of contents)
const tocNcx = `<?xml version="1.0" encoding="UTF-8"?>
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
<head>
<meta name="dtb:uid" content="book-id" />
<meta name="dtb:depth" content="1" />
<meta name="dtb:totalPageCount" content="0" />
<meta name="dtb:maxPageNumber" content="0" />
</head>
<docTitle>
<text>${escapeXml(bookTitle)}</text>
</docTitle>
<docAuthor>
<text>${escapeXml(author)}</text>
</docAuthor>
<navMap>
${navPoints}
</navMap>
</ncx>`.trim();
await zipWriter.add('toc.ncx', new TextReader(tocNcx), zipWriteOptions);
// Create manifest and spine items
const manifest = chapters
.map(
(_, index) => `
<item id="chap${index + 1}" href="OEBPS/chapter${index + 1}.xhtml" media-type="application/xhtml+xml"/>
`,
)
.join('\n')
.trim();
const spine = chapters
.map(
(_, index) => `
<itemref idref="chap${index + 1}"/>`,
)
.join('\n')
.trim();
// Add CSS stylesheet
const css = `
body { line-height: 1.6; font-size: 1em; font-family: 'Arial', sans-serif; text-align: justify; }
p { text-indent: 2em; margin: 0; }
`;
await zipWriter.add('style.css', new TextReader(css), zipWriteOptions);
// Add chapter files
for (let i = 0; i < chapters.length; i++) {
const chapter = chapters[i]!;
const chapterContent = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="zh">
<head>
<title>${chapter.title}</title>
<link rel="stylesheet" type="text/css" href="../style.css"/>
</head>
<body>${chapter.content}</body>
</html>`.trim();
await zipWriter.add(
`OEBPS/chapter${i + 1}.xhtml`,
new TextReader(chapterContent),
zipWriteOptions,
);
}
const tocManifest = `<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>`;
// Add content.opf file
const contentOpf = `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="book-id" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>${escapeXml(bookTitle)}</dc:title>
<dc:language>${language}</dc:language>
<dc:creator>${escapeXml(author)}</dc:creator>
<dc:identifier id="book-id">${identifier}</dc:identifier>
</metadata>
<manifest>
${manifest}
${tocManifest}
</manifest>
<spine toc="ncx">
${spine}
</spine>
</package>`.trim();
await zipWriter.add('content.opf', new TextReader(contentOpf), zipWriteOptions);
return await zipWriter.close();
}
private detectEncoding(buffer: ArrayBuffer): string | undefined {
try {
new TextDecoder('utf-8', { fatal: true }).decode(buffer);
return 'utf-8';
} catch {
// If UTF-8 decoding fails, try to detect other encodings
}
const headerBytes = new Uint8Array(buffer.slice(0, 4));
if (headerBytes[0] === 0xff && headerBytes[1] === 0xfe) {
return 'utf-16le';
}
if (headerBytes[0] === 0xfe && headerBytes[1] === 0xff) {
return 'utf-16be';
}
if (headerBytes[0] === 0xef && headerBytes[1] === 0xbb && headerBytes[2] === 0xbf) {
return 'utf-8';
}
// Analyze a sample of the content to guess between common East Asian encodings
// If the content has a high ratio of bytes in the 0x80-0xFF range, it's likely GBK/GB18030
const sample = new Uint8Array(buffer.slice(0, Math.min(1024, buffer.byteLength)));
let highByteCount = 0;
for (let i = 0; i < sample.length; i++) {
if (sample[i]! >= 0x80) {
highByteCount++;
}
}
const highByteRatio = highByteCount / sample.length;
if (highByteRatio > 0.3) {
return 'gbk';
}
if (highByteRatio > 0.1) {
let sjisPattern = false;
for (let i = 0; i < sample.length - 1; i++) {
const b1 = sample[i]!;
const b2 = sample[i + 1]!;
if (
((b1 >= 0x81 && b1 <= 0x9f) || (b1 >= 0xe0 && b1 <= 0xfc)) &&
((b2 >= 0x40 && b2 <= 0x7e) || (b2 >= 0x80 && b2 <= 0xfc))
) {
sjisPattern = true;
break;
}
}
if (sjisPattern) {
return 'shift-jis';
}
return 'gb18030';
}
return 'utf-8';
}
private detectLanguage(fileHeader: string): string {
const sample = fileHeader;
let chineseCount = 0;
for (let i = 0; i < sample.length; i++) {
const code = sample.charCodeAt(i);
if (
(code >= 0x4e00 && code <= 0x9fff) ||
(code >= 0x3400 && code <= 0x4dbf) ||
(code >= 0x20000 && code <= 0x2a6df)
) {
chineseCount++;
}
}
if (chineseCount / sample.length > 0.05) {
return 'zh';
}
return 'en';
}
private extractBookTitle(filename: string): string {
const match = filename.match(/《([^》]+)》/);
return match ? match[1]! : filename.split('.')[0]!;
}
}