diff --git a/apps/readest-app/src/__tests__/components/BookCover.test.tsx b/apps/readest-app/src/__tests__/components/BookCover.test.tsx
index ec135b34..572c3a9b 100644
--- a/apps/readest-app/src/__tests__/components/BookCover.test.tsx
+++ b/apps/readest-app/src/__tests__/components/BookCover.test.tsx
@@ -38,4 +38,15 @@ describe('BookCover', () => {
expect(img).toBeTruthy();
expect(img?.getAttribute('loading')).toBe('lazy');
});
+
+ it('falls back to metadata.author on the fallback cover when book.author is empty', () => {
+ const book = makeBook({
+ author: '',
+ coverImageUrl: undefined,
+ metadata: { author: 'Edited Author' } as Book['metadata'],
+ });
+ const { container } = render();
+ const fallback = container.querySelector('.fallback-cover');
+ expect(fallback?.textContent).toContain('Edited Author');
+ });
});
diff --git a/apps/readest-app/src/__tests__/utils/txt-converter.test.ts b/apps/readest-app/src/__tests__/utils/txt-converter.test.ts
index 9a54e555..36897af5 100644
--- a/apps/readest-app/src/__tests__/utils/txt-converter.test.ts
+++ b/apps/readest-app/src/__tests__/utils/txt-converter.test.ts
@@ -1,7 +1,7 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
-import { TxtToEpubConverter } from '@/utils/txt';
+import { TxtToEpubConverter, extractTxtFilenameMetadata } from '@/utils/txt';
type TestChapter = {
title: string;
@@ -345,3 +345,71 @@ describe('TxtToEpubConverter', () => {
expect(cancelled).toBe(true);
});
});
+
+describe('extractTxtFilenameMetadata', () => {
+ it('extracts the title from CJK 《》 brackets', () => {
+ expect(extractTxtFilenameMetadata('《三体》.txt')).toEqual({ title: '三体' });
+ });
+
+ it('extracts title and labeled author with full-width colon', () => {
+ expect(extractTxtFilenameMetadata('《书名》作者:张三.txt')).toEqual({
+ title: '书名',
+ author: '张三',
+ });
+ expect(extractTxtFilenameMetadata('《书名》作者:张三.txt')).toEqual({
+ title: '书名',
+ author: '张三',
+ });
+ });
+
+ it('extracts title and labeled author with leading whitespace', () => {
+ expect(extractTxtFilenameMetadata('《书名》 作者:张三.txt')).toEqual({
+ title: '书名',
+ author: '张三',
+ });
+ });
+
+ it('extracts title and bracketed author after the title', () => {
+ expect(extractTxtFilenameMetadata('《书名》[张三].txt')).toEqual({
+ title: '书名',
+ author: '张三',
+ });
+ expect(extractTxtFilenameMetadata('《书名》(张三).txt')).toEqual({
+ title: '书名',
+ author: '张三',
+ });
+ expect(extractTxtFilenameMetadata('《书名》【张三】.txt')).toEqual({
+ title: '书名',
+ author: '张三',
+ });
+ });
+
+ it('extracts title and bare author after the title', () => {
+ expect(extractTxtFilenameMetadata('《书名》张三.txt')).toEqual({
+ title: '书名',
+ author: '张三',
+ });
+ });
+
+ it('strips leading/trailing punctuation from the author', () => {
+ expect(extractTxtFilenameMetadata('《书名》 - 张三.txt')).toEqual({
+ title: '书名',
+ author: '张三',
+ });
+ });
+
+ it('handles paths with directories', () => {
+ expect(extractTxtFilenameMetadata('/inbox/《书名》作者:张三.txt')).toEqual({
+ title: '书名',
+ author: '张三',
+ });
+ });
+
+ it('falls back to the base filename when no 《》 are present', () => {
+ expect(extractTxtFilenameMetadata('plain-name.txt')).toEqual({ title: 'plain-name' });
+ });
+
+ it('returns empty object for empty input', () => {
+ expect(extractTxtFilenameMetadata('')).toEqual({ title: '' });
+ });
+});
diff --git a/apps/readest-app/src/components/BookCover.tsx b/apps/readest-app/src/components/BookCover.tsx
index 43b73872..de76d928 100644
--- a/apps/readest-app/src/components/BookCover.tsx
+++ b/apps/readest-app/src/components/BookCover.tsx
@@ -138,7 +138,7 @@ const BookCover: React.FC = memo(
isPreview ? 'text-[0.4em]' : mode === 'grid' ? 'text-base' : 'text-xs',
)}
>
- {formatAuthors(book.author)}
+ {formatAuthors(book.author || book.metadata?.author || '')}
diff --git a/apps/readest-app/src/utils/txt.ts b/apps/readest-app/src/utils/txt.ts
index cc22b50f..d03bd80a 100644
--- a/apps/readest-app/src/utils/txt.ts
+++ b/apps/readest-app/src/utils/txt.ts
@@ -10,6 +10,46 @@ interface Metadata {
identifier: string;
}
+// Pull a title and (optionally) an author out of a TXT filename. Recognized
+// patterns center on Chinese conventions where books are named with the title
+// in 《》 and an author tacked on, e.g. 《书名》作者:张三.txt, 《书名》[张三].txt,
+// 《书名》张三.txt. Falls back to the base filename as the title when no
+// 《》 are present.
+export const extractTxtFilenameMetadata = (
+ filename: string,
+): { title: string; author?: string } => {
+ const base = getBaseFilename(filename);
+ const cjkMatch = base.match(/《([^》]+)》(.*)/);
+ if (!cjkMatch) {
+ return { title: base };
+ }
+ const title = cjkMatch[1]!.trim();
+ const rest = (cjkMatch[2] ?? '').trim();
+ const author = parseAuthorFragment(rest);
+ return author ? { title, author } : { title };
+};
+
+const parseAuthorFragment = (text: string): string => {
+ if (!text) return '';
+ // 作者:X / 作者:X / 作者 X — labeled author wins
+ const labeled = text.match(/作者\s*[::\s]\s*(.+)$/);
+ if (labeled) return stripWrappingPunctuation(labeled[1]!);
+ // [X] (X) 【X】 (X)[X] — bracketed author
+ const bracketed = text.match(/[[((【[]\s*([^\]))】]]+?)\s*[\]))】]]/);
+ if (bracketed) return stripWrappingPunctuation(bracketed[1]!);
+ // bare token — strip any leading separator like " - " / "·" / "-"
+ return stripWrappingPunctuation(text);
+};
+
+const stripWrappingPunctuation = (text: string): string => {
+ const trimmed = text.trim();
+ try {
+ return trimmed.replace(/^[\p{P}\p{S}\s]+|[\p{P}\p{S}\s]+$/gu, '');
+ } catch {
+ return trimmed;
+ }
+};
+
interface Chapter {
title: string;
content: string;
@@ -73,18 +113,19 @@ export class TxtToEpubConverter {
const decoder = new TextDecoder(runtimeEncoding);
const txtContent = decoder.decode(fileContent).trim();
- const bookTitle = this.extractBookTitle(getBaseFilename(txtFile.name));
+ const filenameMeta = extractTxtFilenameMetadata(txtFile.name);
+ const bookTitle = filenameMeta.title;
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 || '';
+ let matchedAuthor = authorMatch ? authorMatch[1]!.trim() : '';
try {
matchedAuthor = matchedAuthor.replace(/^[\p{P}\p{S}]+|[\p{P}\p{S}]+$/gu, '');
} catch {}
- const author = matchedAuthor || providedAuthor || '';
+ const author = matchedAuthor || filenameMeta.author || providedAuthor || '';
const language = providedLanguage || detectLanguage(fileHeader);
// console.log(`Detected language: ${language}`);
const identifier = await partialMD5(txtFile);
@@ -126,7 +167,8 @@ export class TxtToEpubConverter {
const runtimeEncoding = this.resolveSupportedEncoding(detectedEncoding);
// console.log(`Detected encoding: ${detectedEncoding}, runtime encoding: ${runtimeEncoding}`);
- const bookTitle = this.extractBookTitle(getBaseFilename(txtFile.name));
+ const filenameMeta = extractTxtFilenameMetadata(txtFile.name);
+ const bookTitle = filenameMeta.title;
const fileName = `${bookTitle}.epub`;
const fileHeader = await this.readHeaderTextFromFile(
txtFile,
@@ -137,7 +179,7 @@ export class TxtToEpubConverter {
const { author, language } = this.extractAuthorAndLanguage(
fileHeader,
- providedAuthor,
+ filenameMeta.author ?? providedAuthor,
providedLanguage,
);
// console.log(`Detected language: ${language}`);
@@ -931,11 +973,6 @@ export class TxtToEpubConverter {
return 'utf-8';
}
- private extractBookTitle(filename: string): string {
- const match = filename.match(/《([^》]+)》/);
- return match ? match[1]! : filename.split('.')[0]!;
- }
-
private assertStrictUtf8Sample(sample: Uint8Array): void {
const decoder = new TextDecoder('utf-8', { fatal: true });
try {