diff --git a/packages/app/package.json b/packages/app/package.json index 7cff054..60c3b64 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -124,4 +124,4 @@ "typescript-eslint": "^8.35.1", "vite": "^7.0.4" } -} \ No newline at end of file +} diff --git a/packages/app/src-tauri/binaries/woff2_compress-x86_64-unknown-linux-gnu b/packages/app/src-tauri/binaries/woff2_compress-x86_64-unknown-linux-gnu new file mode 100755 index 0000000..2aba3c5 --- /dev/null +++ b/packages/app/src-tauri/binaries/woff2_compress-x86_64-unknown-linux-gnu @@ -0,0 +1,56 @@ +#!/usr/bin/env node + +/** + * 使用 fonteditor-core 将 TTF 字体转换为 WOFF2。 + * 作为 Tauri sidecar,在 Linux 平台上替代 woff2_compress 原生二进制。 + */ + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const require = createRequire(import.meta.url); + +const { Font, woff2 } = require("fonteditor-core"); + +async function main() { + const [, , inputPath] = process.argv; + + if (!inputPath) { + console.error("用法: woff2_compress <字体路径>"); + process.exit(1); + } + + if (!fs.existsSync(inputPath)) { + console.error(`文件不存在: ${inputPath}`); + process.exit(1); + } + + const ext = path.extname(inputPath).toLowerCase(); + if (ext !== ".ttf") { + console.error("仅支持 .ttf 文件转换"); + process.exit(1); + } + + const outputPath = inputPath.replace(/\.ttf$/i, ".woff2"); + + try { + await woff2.init(); + const fontBuffer = fs.readFileSync(inputPath); + const font = Font.create(fontBuffer, { type: "ttf", combinePath: true }); + const woff2ArrayBuffer = font.write({ type: "woff2", hinting: true }); + const woff2Buffer = Buffer.from(new Uint8Array(woff2ArrayBuffer)); + fs.writeFileSync(outputPath, woff2Buffer); + + process.exit(0); + } catch (error) { + console.error(`转换失败: ${error.message || error}`); + process.exit(1); + } +} + +main(); diff --git a/packages/app/src-tauri/plugins/tauri-plugin-epub/src/commands.rs b/packages/app/src-tauri/plugins/tauri-plugin-epub/src/commands.rs index 2c4797c..a4befb9 100644 --- a/packages/app/src-tauri/plugins/tauri-plugin-epub/src/commands.rs +++ b/packages/app/src-tauri/plugins/tauri-plugin-epub/src/commands.rs @@ -1,4 +1,5 @@ use serde::Serialize; +use std::path::{Path, PathBuf}; use tauri::{AppHandle, Emitter, Manager, Runtime, State}; use crate::database::VectorDatabase; @@ -13,6 +14,36 @@ use crate::models::{ }; use epub2mdbook::convert_epub_to_mdbook; +fn find_epub_in_dir(dir: &Path) -> Option { + let entries = std::fs::read_dir(dir).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("epub")) + .unwrap_or(false) + { + return Some(path); + } + } + None +} + +fn resolve_epub_path(book_dir: &Path) -> Result { + let default_path = book_dir.join("book.epub"); + if default_path.exists() { + return Ok(default_path); + } + if let Some(candidate) = find_epub_in_dir(book_dir) { + return Ok(candidate); + } + Err(format!( + "EPUB not found under directory: {}", + book_dir.to_string_lossy() + )) +} + /// Parse an EPUB under $AppData/books/{book_id} and return basic metadata. #[tauri::command] pub async fn parse_epub( @@ -25,7 +56,7 @@ pub async fn parse_epub( } let app_data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; let book_dir = app_data_dir.join("books").join(&book_id); - let epub_path = book_dir.join("book.epub"); + let epub_path = resolve_epub_path(&book_dir)?; let reader = EpubReader::new().map_err(|e| e.to_string())?; let content = reader.read_epub(&epub_path).map_err(|e| e.to_string())?; Ok(ParsedBook { @@ -114,7 +145,7 @@ pub async fn convert_to_mdbook( let app_data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; let book_dir = app_data_dir.join("books").join(&book_id); - let epub_path = book_dir.join("book.epub"); + let epub_path = resolve_epub_path(&book_dir)?; let mdbook_dir = book_dir.join("mdbook"); if !epub_path.exists() { diff --git a/packages/app/src-tauri/plugins/tauri-plugin-epub/src/pipeline.rs b/packages/app/src-tauri/plugins/tauri-plugin-epub/src/pipeline.rs index f9af370..5e8bf21 100644 --- a/packages/app/src-tauri/plugins/tauri-plugin-epub/src/pipeline.rs +++ b/packages/app/src-tauri/plugins/tauri-plugin-epub/src/pipeline.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result}; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::database::VectorDatabase; use crate::epub::EpubReader; @@ -12,6 +12,22 @@ use crate::models::{ }; use epub2mdbook::convert_epub_to_mdbook; +fn find_epub_in_dir(dir: &Path) -> Option { + let entries = std::fs::read_dir(dir).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("epub")) + .unwrap_or(false) + { + return Some(path); + } + } + None +} + /// Core pipeline: book_dir -> locate book.epub -> parse -> write chapters -> vectorize -> persist to SQLite pub async fn process_epub_to_db, F>( book_dir: P, @@ -22,17 +38,25 @@ where F: FnMut(ProgressUpdate) + Send, { let book_dir = book_dir.as_ref(); - let epub_path = book_dir.join("book.epub"); + let mut epub_path = book_dir.join("book.epub"); let db_path = book_dir.join("vectors.sqlite"); if !epub_path.exists() { - log::error!("EPUB file not found at path: {:?}", epub_path); - log::error!("Book directory contents: {:?}", - std::fs::read_dir(book_dir) - .map(|entries| entries.collect::, _>>()) - .unwrap_or_else(|e| Err(e)) - ); - anyhow::bail!("EPUB not found: {:?}", epub_path); + if let Some(candidate) = find_epub_in_dir(book_dir) { + log::warn!( + "默认 EPUB 文件不存在,使用目录中的候选文件: {:?}", + candidate + ); + epub_path = candidate; + } else { + log::error!("EPUB file not found at path: {:?}", epub_path); + log::error!("Book directory contents: {:?}", + std::fs::read_dir(book_dir) + .map(|entries| entries.collect::, _>>()) + .unwrap_or_else(|e| Err(e)) + ); + anyhow::bail!("EPUB not found: {:?}", epub_path); + } } log::info!("Starting EPUB processing pipeline for book directory: {:?}", book_dir); @@ -577,4 +601,4 @@ fn write_metadata_markdown(book_dir: &Path, epub_content: &EpubContent, flat_toc log::info!("已更新 metadata.json,添加了 base_dir: {:?}", toc_base_dir); Ok(()) -} \ No newline at end of file +} diff --git a/packages/app/src-tauri/src/core/books/commands.rs b/packages/app/src-tauri/src/core/books/commands.rs index 8583b14..80a16be 100644 --- a/packages/app/src-tauri/src/core/books/commands.rs +++ b/packages/app/src-tauri/src/core/books/commands.rs @@ -23,18 +23,76 @@ pub async fn save_book(app_handle: AppHandle, data: BookUploadData) -> Result {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + log::warn!( + "rename failed (missing source): {:?} -> {:?}, fallback to copy", + data.temp_file_path, + epub_path + ); + if let Some(parent) = epub_path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("创建书籍目录失败: {}", e))?; + } + std::fs::copy(&data.temp_file_path, &epub_path) + .map_err(|e| format!("复制书籍文件失败: {}", e))?; + } + Err(err) => { + return Err(format!("移动书籍文件失败: {}", err)); + } + } + } let cover_path = if let Some(cover_temp_path) = &data.cover_temp_file_path { let cover_file = book_dir.join("cover.jpg"); - std::fs::rename(cover_temp_path, &cover_file) - .map_err(|e| format!("移动封面文件失败: {}", e))?; + match std::fs::rename(cover_temp_path, &cover_file) { + Ok(_) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + log::warn!( + "rename cover failed (missing source): {:?} -> {:?}, fallback to copy", + cover_temp_path, + cover_file + ); + std::fs::copy(cover_temp_path, &cover_file) + .map_err(|e| format!("复制封面文件失败: {}", e))?; + } + Err(err) => { + return Err(format!("移动封面文件失败: {}", err)); + } + } Some(format!("books/{}/cover.jpg", data.id)) } else { None }; + if let Some(derived_files) = &data.derived_files { + for derived in derived_files { + let target_path = book_dir.join(&derived.filename); + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("创建衍生文件目录失败: {}", e))?; + } + + match std::fs::rename(&derived.temp_file_path, &target_path) { + Ok(_) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + log::warn!( + "rename derived failed (missing source): {:?} -> {:?}, fallback to copy", + derived.temp_file_path, + target_path + ); + std::fs::copy(&derived.temp_file_path, &target_path) + .map_err(|e| format!("复制衍生文件失败: {}", e))?; + } + Err(err) => { + return Err(format!("移动衍生文件失败: {}", err)); + } + } + } + } + let metadata_path = book_dir.join("metadata.json"); let metadata_json = serde_json::to_string_pretty(&data.metadata) .map_err(|e| format!("序列化元数据失败: {}", e))?; diff --git a/packages/app/src-tauri/src/core/books/models.rs b/packages/app/src-tauri/src/core/books/models.rs index a99f6f0..4338b9c 100644 --- a/packages/app/src-tauri/src/core/books/models.rs +++ b/packages/app/src-tauri/src/core/books/models.rs @@ -21,6 +21,13 @@ pub struct SimpleBook { pub updated_at: i64, } +#[derive(Deserialize, Debug)] +pub struct DerivedFileUpload { + #[serde(rename = "tempFilePath")] + pub temp_file_path: String, + pub filename: String, +} + #[derive(Deserialize, Debug)] pub struct BookUploadData { pub id: String, @@ -35,6 +42,8 @@ pub struct BookUploadData { #[serde(rename = "coverTempFilePath")] pub cover_temp_file_path: Option, pub metadata: serde_json::Value, + #[serde(rename = "derivedFiles")] + pub derived_files: Option>, } #[derive(Deserialize, Debug)] diff --git a/packages/app/src-tauri/tauri.conf.json b/packages/app/src-tauri/tauri.conf.json index e006793..8d4c8f3 100644 --- a/packages/app/src-tauri/tauri.conf.json +++ b/packages/app/src-tauri/tauri.conf.json @@ -120,7 +120,12 @@ "assetProtocol": { "enable": true, "scope": [ - "**" + "**", + "$RESOURCE/**", + "$APPDATA/**", + "$APPDATA", + "$CACHE/**", + "$TEMP/**" ] } } @@ -155,4 +160,4 @@ "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEZFNTAwRUM3NDVDQTVGMjkKUldRcFg4cEZ4dzVRL28wSWZ0S3FZdEVkNlFCTUlaYlRaR2ZyOWJoQTNRNm9EdXF5cnhXc1IwU3QK" } } -} \ No newline at end of file +} diff --git a/packages/app/src/lib/document.ts b/packages/app/src/lib/document.ts index 6b02491..04adb77 100644 --- a/packages/app/src/lib/document.ts +++ b/packages/app/src/lib/document.ts @@ -99,6 +99,8 @@ export const EXTS: Record = { EPUB: "epub", PDF: "pdf", MOBI: "mobi", + AZW: "azw", + AZW3: "azw3", CBZ: "cbz", FB2: "fb2", FBZ: "fbz", @@ -212,7 +214,14 @@ export class DocumentLoader { const fflate = await import("foliate-js/vendor/fflate.js"); const { MOBI } = await import("foliate-js/mobi.js"); book = await new MOBI({ unzlib: fflate.unzlibSync }).open(this.file); - format = "MOBI"; + const ext = this.file.name.split(".").pop()?.toLowerCase(); + if (ext === "azw3") { + format = "AZW3"; + } else if (ext === "azw") { + format = "AZW"; + } else { + format = "MOBI"; + } } else if (this.isFB2()) { const { makeFB2 } = await import("foliate-js/fb2.js"); book = await makeFB2(this.file); diff --git a/packages/app/src/pages/library/components/upload.tsx b/packages/app/src/pages/library/components/upload.tsx index 56fa79e..f09f605 100644 --- a/packages/app/src/pages/library/components/upload.tsx +++ b/packages/app/src/pages/library/components/upload.tsx @@ -1,8 +1,8 @@ -import { Upload as UploadIcon } from "lucide-react"; - import { Button } from "@/components/ui/button"; import { useBookUpload } from "@/hooks/use-book-upload"; import { useThemeStore } from "@/store/theme-store"; +import { FILE_ACCEPT_FORMATS } from "@/services/constants"; +import { Upload as UploadIcon } from "lucide-react"; export default function Upload() { const { isDarkMode } = useThemeStore(); @@ -27,7 +27,7 @@ export default function Upload() { {isUploading ? "上传中..." : "拖拽书籍到此处上传"} -

支持的格式:.epub

+

支持的格式:{FILE_ACCEPT_FORMATS}

diff --git a/packages/app/src/pages/reader/store/create-reader-store.ts b/packages/app/src/pages/reader/store/create-reader-store.ts index ed2ab80..5895a13 100644 --- a/packages/app/src/pages/reader/store/create-reader-store.ts +++ b/packages/app/src/pages/reader/store/create-reader-store.ts @@ -1,7 +1,7 @@ import { DocumentLoader } from "@/lib/document"; import type { BookDoc } from "@/lib/document"; import { loadBookConfig, saveBookConfig } from "@/services/app-service"; -import { getBookWithStatusById } from "@/services/book-service"; +import { getBookWithStatusById, loadReadableBookFile } from "@/services/book-service"; import { useAppSettingsStore } from "@/store/app-settings-store"; import { useLibraryStore } from "@/store/library-store"; import type { Book, BookConfig, BookNote, BookProgress } from "@/types/book"; @@ -78,19 +78,14 @@ export const createReaderStore = (bookId: string) => { if (!simpleBook) throw new Error("Book not found"); if (!simpleBook.filePath) throw new Error("Book file path is missing"); - const fileUrl = simpleBook.fileUrl; const baseDir = await appDataDir(); - - const response = await fetch(fileUrl); - if (!response.ok) { - throw new Error(`Failed to fetch book file: ${response.status} ${response.statusText}`); - } - - const arrayBuffer = await response.arrayBuffer(); - const filename = simpleBook.filePath.split("/").pop() || "book.epub"; - const file = new File([arrayBuffer], filename, { - type: "application/epub+zip", - }); + const file = await loadReadableBookFile( + { + filePath: simpleBook.filePath, + format: simpleBook.format, + }, + bookId, + ); const book = { id: simpleBook.id, diff --git a/packages/app/src/services/book-service.ts b/packages/app/src/services/book-service.ts index ff34b05..61229a1 100644 --- a/packages/app/src/services/book-service.ts +++ b/packages/app/src/services/book-service.ts @@ -1,4 +1,5 @@ -import { DocumentLoader } from "@/lib/document"; +import type { BookDoc } from "@/lib/document"; +import { DocumentLoader, EXTS } from "@/lib/document"; import type { BookQueryOptions, BookStatus, @@ -23,12 +24,12 @@ import { partialMD5 } from "@/utils/md5"; import { convertFileSrc, invoke } from "@tauri-apps/api/core"; import { appDataDir, tempDir } from "@tauri-apps/api/path"; import { join } from "@tauri-apps/api/path"; -import { writeFile } from "@tauri-apps/plugin-fs"; +import { readFile, writeFile } from "@tauri-apps/plugin-fs"; export async function uploadBook(file: File): Promise { try { const format = getBookFormat(file.name); - if (!["EPUB", "PDF", "MOBI", "CBZ", "FB2", "FBZ"].includes(format)) { + if (!["EPUB", "MOBI", "AZW", "AZW3", "CBZ", "FB2", "FBZ"].includes(format)) { throw new Error(`不支持的文件格式: ${format}`); } @@ -38,12 +39,25 @@ export async function uploadBook(file: File): Promise { const tempFilePath = await join(tempDirPath, tempFileName); const fileData = await file.arrayBuffer(); await writeFile(tempFilePath, new Uint8Array(fileData)); - const metadata = await extractMetadataOnly(file); + + const defaultMetadata = getDefaultMetadata(file.name); + const bookDoc = shouldParseWithDocumentLoader(format) + ? await tryParseBookDocument(fileData, file.name) + : null; + const metadata = { + ...defaultMetadata, + ...(bookDoc?.metadata ?? {}), + }; + + const formattedTitle = formatTitle(metadata.title) || getFileNameWithoutExt(file.name); + const formattedAuthor = formatAuthors(metadata.author) || "Unknown"; + const normalizedLanguage = normalizeLanguage(metadata.language); + metadata.language = normalizedLanguage; + const primaryLanguage = normalizedLanguage || "en"; let coverTempFilePath: string | undefined; - if (format === "EPUB") { + if (bookDoc) { try { - const bookDoc = await parseEpubFile(fileData, file.name); const coverBlob = await bookDoc.getCover(); if (coverBlob) { const coverTempFileName = `cover_${bookHash}.jpg`; @@ -57,16 +71,36 @@ export async function uploadBook(file: File): Promise { } } + let derivedFiles: BookUploadData["derivedFiles"]; + if (["MOBI", "AZW", "AZW3"].includes(format) && bookDoc) { + const derivedEpubPath = await createDerivedEpubFromBookDoc(bookDoc, { + tempDirPath, + bookHash, + title: formattedTitle, + author: formattedAuthor, + language: primaryLanguage, + }); + if (derivedEpubPath) { + derivedFiles = [ + { + tempFilePath: derivedEpubPath, + filename: "book.epub", + }, + ]; + } + } + const uploadData: BookUploadData = { id: bookHash, - title: formatTitle(metadata.title) || getFileNameWithoutExt(file.name), - author: formatAuthors(metadata.author) || "Unknown", + title: formattedTitle, + author: formattedAuthor, format, fileSize: file.size, - language: getPrimaryLanguage(metadata.language) || "en", + language: primaryLanguage, tempFilePath: tempFilePath, coverTempFilePath, - metadata: metadata, + metadata, + ...(derivedFiles?.length ? { derivedFiles } : {}), }; const result = await invoke("save_book", { data: uploadData }); @@ -77,27 +111,303 @@ export async function uploadBook(file: File): Promise { } } -async function extractMetadataOnly(file: File): Promise { +const DOCUMENT_LOADER_SUPPORTED_FORMATS: SimpleBook["format"][] = ["EPUB", "MOBI", "AZW", "AZW3", "CBZ", "FB2", "FBZ"]; + +function shouldParseWithDocumentLoader(format: SimpleBook["format"]): boolean { + return DOCUMENT_LOADER_SUPPORTED_FORMATS.includes(format); +} + +function getDefaultMetadata(fileName: string) { + return { + title: getFileNameWithoutExt(fileName), + author: "Unknown", + language: "en", + }; +} + +function normalizeLanguage(language: BookDoc["metadata"]["language"], fallback = "en"): string { + const candidate = Array.isArray(language) ? language.find(isValidLanguageTag) : language; + return isValidLanguageTag(candidate) ? candidate! : fallback; +} + +function isValidLanguageTag(tag: string | undefined | null): tag is string { + if (!tag) return false; + try { + Intl.getCanonicalLocales(tag); + return true; + } catch { + return false; + } +} + +async function tryParseBookDocument(fileData: ArrayBuffer, fileName: string): Promise { + try { + return await parseBookDocument(fileData, fileName); + } catch (error) { + console.warn("解析书籍文件失败,使用默认元数据:", error); + return null; + } +} + +interface DerivedEpubOptions { + tempDirPath: string; + bookHash: string; + title: string; + author: string; + language: string; +} + +interface DerivedChapter { + title: string; + content: string; +} + +interface SimpleEpubMetadata { + title: string; + author: string; + language: string; + identifier: string; +} + +const DERIVED_ZIP_OPTIONS = { + lastAccessDate: new Date(0), + lastModDate: new Date(0), +}; + +const escapeXml = (str: string) => + (str || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + +async function createDerivedEpubFromBookDoc(bookDoc: BookDoc, options: DerivedEpubOptions): Promise { try { - if (file.name.toLowerCase().endsWith(".epub")) { - const arrayBuffer = await file.arrayBuffer(); - const bookDoc = await parseEpubFile(arrayBuffer, file.name); - return bookDoc.metadata; + const chapters = await extractChaptersFromBookDoc(bookDoc); + if (!chapters.length) { + return null; } - return { - title: getFileNameWithoutExt(file.name), - author: "Unknown", - language: "en", - }; + const blob = await buildSimpleEpub(chapters, { + title: options.title, + author: options.author, + language: options.language || "en", + identifier: options.bookHash, + }); + const arrayBuffer = await blob.arrayBuffer(); + const derivedTempPath = await join(options.tempDirPath, `derived_epub_${options.bookHash}.epub`); + await writeFile(derivedTempPath, new Uint8Array(arrayBuffer)); + return derivedTempPath; } catch (error) { - console.warn("元数据提取失败,使用默认值:", error); - return { - title: getFileNameWithoutExt(file.name), - author: "Unknown", - language: "en", - }; + console.warn("生成 MOBI 衍生 EPUB 失败:", error); + return null; + } +} + +async function extractChaptersFromBookDoc(bookDoc: BookDoc): Promise { + const sections = bookDoc.sections ?? []; + if (!sections.length) return []; + + const serializer = new XMLSerializer(); + const tocTitleMap = buildSectionTitleMap(bookDoc); + const chapters: DerivedChapter[] = []; + + for (let index = 0; index < sections.length; index++) { + const section: any = sections[index]; + if (typeof section?.createDocument !== "function") continue; + + try { + const created = await section.createDocument(); + const doc = ensureDocument(created); + if (!doc) continue; + + sanitizeChapterDocument(doc); + const body = doc.querySelector("body"); + const content = body?.innerHTML?.trim() || serializer.serializeToString(doc); + if (!content) continue; + + const tocLabel = tocTitleMap.get(index); + const heading = pickHeadingTitle(doc); + const title = (tocLabel || heading || `第${index + 1}章`).trim() || `第${index + 1}章`; + + chapters.push({ + title, + content, + }); + } catch (error) { + console.warn("读取 MOBI 章节失败:", error); + } + } + + return chapters; +} + +function ensureDocument(input: unknown): Document | null { + if (!input) return null; + if (typeof (input as Document).querySelector === "function") { + return input as Document; + } + if (typeof input === "string") { + return new DOMParser().parseFromString(input, "application/xhtml+xml"); + } + if (typeof (input as { toString: () => string }).toString === "function") { + return new DOMParser().parseFromString((input as { toString: () => string }).toString(), "application/xhtml+xml"); } + return null; +} + +function buildSectionTitleMap(bookDoc: BookDoc): Map { + const map = new Map(); + const tocItems = bookDoc.toc ?? []; + const splitHref = bookDoc.splitTOCHref?.bind(bookDoc); + + const traverse = (items: any[]) => { + for (const item of items) { + if (!item?.href || typeof item.href !== "string") continue; + if (splitHref) { + try { + const parts = splitHref(item.href); + const ref = parts?.[0]; + if (typeof ref === "number" && !map.has(ref)) { + const label = typeof item.label === "string" ? item.label.trim() : ""; + if (label) { + map.set(ref, label); + } + } + } catch { + // ignore split errors + } + } + if (Array.isArray(item?.subitems) && item.subitems.length) { + traverse(item.subitems); + } + } + }; + + traverse(tocItems); + return map; +} + +function pickHeadingTitle(doc: Document): string | undefined { + const heading = doc.querySelector("h1, h2, h3, h4, h5, h6"); + return heading?.textContent?.trim() || undefined; +} + +function sanitizeChapterDocument(doc: Document): void { + const removableSelectors = [ + "script", + "style", + "link", + "img", + "video", + "audio", + "source", + "picture", + "iframe", + "object", + "embed", + "svg", + ]; + + removableSelectors.forEach((selector) => { + doc.querySelectorAll(selector).forEach((el) => el.remove()); + }); + + doc.querySelectorAll("[src^='blob:']").forEach((el) => el.removeAttribute("src")); +} + +async function buildSimpleEpub(chapters: DerivedChapter[], metadata: SimpleEpubMetadata): Promise { + const { ZipWriter, BlobWriter, TextReader } = await import("@zip.js/zip.js"); + + const zipWriter = new ZipWriter(new BlobWriter("application/epub+zip"), { + extendedTimestamp: false, + }); + await zipWriter.add("mimetype", new TextReader("application/epub+zip"), DERIVED_ZIP_OPTIONS); + + const containerXml = ` + + + + +`; + await zipWriter.add("META-INF/container.xml", new TextReader(containerXml.trim()), DERIVED_ZIP_OPTIONS); + + const manifestItems = chapters + .map( + (_, index) => + ``, + ) + .join("\n"); + + const spineItems = chapters.map((_, index) => ``).join("\n"); + + const navPoints = chapters + .map( + (chapter, index) => ` + ${escapeXml(chapter.title)} + +`, + ) + .join("\n"); + + const tocNcx = ` + + + + + + + + ${escapeXml(metadata.title)} + ${escapeXml(metadata.author)} + + ${navPoints} + +`; + await zipWriter.add("toc.ncx", new TextReader(tocNcx.trim()), DERIVED_ZIP_OPTIONS); + + const cssContent = ` +body { line-height: 1.6; font-size: 1em; font-family: serif; text-align: justify; } +p { text-indent: 2em; margin: 0; } +h1, h2, h3, h4, h5, h6 { text-indent: 0; } +`; + await zipWriter.add("style.css", new TextReader(cssContent.trim()), DERIVED_ZIP_OPTIONS); + + for (let i = 0; i < chapters.length; i++) { + const chapter = chapters[i]!; + const chapterContent = ` + + + + ${escapeXml(chapter.title)} + + + ${chapter.content} +`; + await zipWriter.add(`OEBPS/chapter${i + 1}.xhtml`, new TextReader(chapterContent.trim()), DERIVED_ZIP_OPTIONS); + } + + const contentOpf = ` + + + ${escapeXml(metadata.title)} + ${escapeXml(metadata.language)} + ${escapeXml(metadata.author)} + ${escapeXml(metadata.identifier)} + + + ${manifestItems} + + + + + ${spineItems} + +`; + await zipWriter.add("content.opf", new TextReader(contentOpf.trim()), DERIVED_ZIP_OPTIONS); + + return zipWriter.close(); } export async function getBooks(options: BookQueryOptions = {}): Promise { @@ -132,7 +442,7 @@ export async function convertBookWithStatusUrls(book: BookWithStatus): Promise, + bookId: string, +): Promise { + const appDataDirPath = await appDataDir(); + const candidates: Array<{ path: string; filename: string }> = []; + + if (book.filePath) { + const formatKey = book.format as keyof typeof EXTS; + const defaultExt = EXTS[formatKey] ?? book.format.toLowerCase(); + const originalFilename = book.filePath.split("/").pop() || `book.${defaultExt}`; + candidates.push({ + path: book.filePath, + filename: originalFilename, + }); + } + + if (!candidates.length) { + throw new Error("书籍文件路径缺失"); + } + + for (const candidate of candidates) { + const absolutePath = candidate.path.startsWith("/") + ? candidate.path + : `${appDataDirPath}/${candidate.path}`; + try { + const fileContent = await readFile(absolutePath); + const buffer = fileContent instanceof Uint8Array ? fileContent : new Uint8Array(fileContent as ArrayBuffer); + return new File([buffer], candidate.filename, { + type: getFileMimeType(candidate.filename), + }); + } catch (error) { + console.warn(`加载文件失败: ${candidate.path}`, error); + continue; + } + } + + throw new Error("无法加载书籍文件"); +} + export async function getBookWithStatusById(id: string): Promise { try { const bookWithStatus = await invoke("get_book_with_status_by_id", { id }); @@ -327,13 +677,13 @@ export async function updateBookVectorizationMeta( return updateBookStatus(bookId, { metadata: newMetadata }); } -async function parseEpubFile(fileData: ArrayBuffer, fileName: string) { +async function parseBookDocument(fileData: ArrayBuffer, fileName: string): Promise { const file = new File([fileData], fileName, { type: getFileMimeType(fileName), }); const loader = new DocumentLoader(file); const { book } = await loader.open(); - return book; + return book ?? null; } function getBookFormat(fileName: string): SimpleBook["format"] { @@ -345,6 +695,10 @@ function getBookFormat(fileName: string): SimpleBook["format"] { return "PDF"; case "mobi": return "MOBI"; + case "azw": + return "AZW"; + case "azw3": + return "AZW3"; case "cbz": return "CBZ"; case "fb2": @@ -356,7 +710,7 @@ function getBookFormat(fileName: string): SimpleBook["format"] { } } -function getFileMimeType(fileName: string): string { +export function getFileMimeType(fileName: string): string { const ext = fileName.toLowerCase().split(".").pop(); switch (ext) { case "epub": @@ -365,6 +719,10 @@ function getFileMimeType(fileName: string): string { return "application/pdf"; case "mobi": return "application/x-mobipocket-ebook"; + case "azw": + return "application/vnd.amazon.ebook"; + case "azw3": + return "application/x-mobi8-ebook"; case "cbz": return "application/vnd.comicbook+zip"; case "fb2": @@ -379,3 +737,63 @@ function getFileMimeType(fileName: string): string { function getFileNameWithoutExt(fileName: string): string { return fileName.replace(/\.[^/.]+$/, ""); } + +async function createCoverUrl(path: string): Promise { + try { + const bytes = await readFile(path); + if (!bytes) return undefined; + const data = normalizeToUint8Array(bytes); + if (!data?.length) return undefined; + const mime = getMimeFromFilename(path); + return bytesToDataUrl(data, mime); + } catch (error) { + console.warn("读取封面文件失败,回退 convertFileSrc:", error); + try { + return convertFileSrc(path); + } catch (err) { + console.warn("convertFileSrc 加载封面失败:", err); + return undefined; + } + } +} + +function normalizeToUint8Array(value: unknown): Uint8Array | undefined { + if (!value) return undefined; + if (value instanceof Uint8Array) return value; + if (value instanceof ArrayBuffer) return new Uint8Array(value); + if (Array.isArray(value)) return Uint8Array.from(value); + return undefined; +} + +function bytesToDataUrl(bytes: Uint8Array, mime = "application/octet-stream"): string { + if (typeof window === "undefined" && typeof Buffer !== "undefined") { + const base64 = Buffer.from(bytes).toString("base64"); + return `data:${mime};base64,${base64}`; + } + + let binary = ""; + const chunkSize = 0x8000; + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize); + binary += String.fromCharCode(...chunk); + } + const base64 = window.btoa(binary); + return `data:${mime};base64,${base64}`; +} + +function getMimeFromFilename(path: string): string { + const ext = path.toLowerCase().split(".").pop(); + switch (ext) { + case "png": + return "image/png"; + case "jpeg": + case "jpg": + return "image/jpeg"; + case "webp": + return "image/webp"; + case "gif": + return "image/gif"; + default: + return "image/jpeg"; + } +} diff --git a/packages/app/src/services/constants.ts b/packages/app/src/services/constants.ts index 4483118..0995bae 100644 --- a/packages/app/src/services/constants.ts +++ b/packages/app/src/services/constants.ts @@ -19,7 +19,7 @@ export const LOCAL_BOOKS_SUBDIR = "Readest/Books"; export const CLOUD_BOOKS_SUBDIR = "Readest/Books"; // export const SUPPORTED_FILE_EXTS = ["epub", "mobi", "azw", "azw3", "fb2", "zip", "cbz", "pdf", "txt"]; -export const SUPPORTED_FILE_EXTS = ["epub"]; +export const SUPPORTED_FILE_EXTS = ["epub", "mobi", "azw", "azw3"]; export const FILE_ACCEPT_FORMATS = SUPPORTED_FILE_EXTS.map((ext) => `.${ext}`).join(", "); export const BOOK_UNGROUPED_NAME = ""; export const BOOK_UNGROUPED_ID = ""; diff --git a/packages/app/src/store/chat-reader-store.ts b/packages/app/src/store/chat-reader-store.ts index ed8e75c..96bde89 100644 --- a/packages/app/src/store/chat-reader-store.ts +++ b/packages/app/src/store/chat-reader-store.ts @@ -1,7 +1,7 @@ import type { BookDoc } from "@/lib/document"; import { DocumentLoader } from "@/lib/document"; import { loadBookConfig } from "@/services/app-service"; -import { getBookWithStatusById } from "@/services/book-service"; +import { getBookWithStatusById, loadReadableBookFile } from "@/services/book-service"; import type { Book, BookConfig } from "@/types/book"; import type { Thread } from "@/types/thread"; import { appDataDir } from "@tauri-apps/api/path"; @@ -68,19 +68,14 @@ export const useChatReaderStore = create((set, get) => ({ if (!simpleBook) throw new Error("Book not found"); if (!simpleBook.filePath) throw new Error("Book file path is missing"); - const fileUrl = simpleBook.fileUrl; const baseDir = await appDataDir(); - - const response = await fetch(fileUrl); - if (!response.ok) { - throw new Error(`Failed to fetch book file: ${response.status} ${response.statusText}`); - } - - const arrayBuffer = await response.arrayBuffer(); - const filename = simpleBook.filePath.split("/").pop() || "book.epub"; - const file = new File([arrayBuffer], filename, { - type: "application/epub+zip", - }); + const file = await loadReadableBookFile( + { + filePath: simpleBook.filePath, + format: simpleBook.format, + }, + bookId, + ); const book = { id: simpleBook.id, diff --git a/packages/app/src/store/reader-store.ts b/packages/app/src/store/reader-store.ts index 834610c..0142eff 100644 --- a/packages/app/src/store/reader-store.ts +++ b/packages/app/src/store/reader-store.ts @@ -1,6 +1,6 @@ import { type BookDoc, DocumentLoader } from "@/lib/document"; import { loadBookConfig, saveBookConfig } from "@/services/app-service"; -import { getBookById } from "@/services/book-service"; +import { getBookById, loadReadableBookFile } from "@/services/book-service"; import type { Book, BookConfig, BookNote, BookProgress } from "@/types/book"; import type { SystemSettings } from "@/types/settings"; import type { SimpleBook } from "@/types/simple-book"; @@ -436,12 +436,13 @@ export const useReaderStore = create((set, get) => ({ if (!book) { throw new Error("Book not found"); } - const { convertFileSrc } = await import("@tauri-apps/api/core"); - const fileUrl = convertFileSrc(book.filePath!); - const response = await fetch(fileUrl); - const arrayBuffer = await response.arrayBuffer(); - const filename = book.filePath!.split("/").pop() || "book.epub"; - const file = new File([arrayBuffer], filename, { type: "application/epub+zip" }); + const file = await loadReadableBookFile( + { + filePath: book.filePath, + format: book.format, + }, + bookId, + ); const config = await loadBookConfig(bookId, settings); if (!config) { throw new Error("Config not found"); diff --git a/packages/app/src/types/book.ts b/packages/app/src/types/book.ts index 5affd88..c16c29d 100644 --- a/packages/app/src/types/book.ts +++ b/packages/app/src/types/book.ts @@ -1,4 +1,4 @@ -export type BookFormat = "EPUB" | "PDF" | "MOBI" | "CBZ" | "FB2" | "FBZ"; +export type BookFormat = "EPUB" | "PDF" | "MOBI" | "AZW" | "AZW3" | "CBZ" | "FB2" | "FBZ"; export type BookNoteType = "bookmark" | "annotation" | "excerpt"; export type HighlightStyle = "highlight" | "underline" | "squiggly"; export type HighlightColor = "red" | "yellow" | "green" | "blue" | "violet"; diff --git a/packages/app/src/types/simple-book.ts b/packages/app/src/types/simple-book.ts index 0965be2..d900e42 100644 --- a/packages/app/src/types/simple-book.ts +++ b/packages/app/src/types/simple-book.ts @@ -25,6 +25,10 @@ export interface BookUploadData { tempFilePath: string; coverTempFilePath?: string; metadata: any; + derivedFiles?: Array<{ + tempFilePath: string; + filename: string; + }>; } export interface BookQueryOptions { @@ -87,7 +91,7 @@ export interface BookWithStatusAndUrls extends BookWithStatus { coverUrl?: string; } -export type BookFormat = "EPUB" | "PDF" | "MOBI" | "CBZ" | "FB2" | "FBZ"; +export type BookFormat = "EPUB" | "PDF" | "MOBI" | "AZW" | "AZW3" | "CBZ" | "FB2" | "FBZ"; // ---- Vectorization metadata (stored under book_status.metadata.vectorization) ---- export type VectorizationStatus = "idle" | "processing" | "success" | "failed"; diff --git a/packages/app/src/utils/misc.ts b/packages/app/src/utils/misc.ts index fcd2de2..f0367db 100644 --- a/packages/app/src/utils/misc.ts +++ b/packages/app/src/utils/misc.ts @@ -37,7 +37,8 @@ export const getLocale = () => { export const getUserLang = () => { const locale = getLocale(); - return locale.split("-")[0] || "en"; + const lang = locale.split("-")[0] || "en"; + return isValidLanguageTag(lang) ? lang : "en"; }; export const getTargetLang = () => { @@ -45,7 +46,17 @@ export const getTargetLang = () => { if (locale.startsWith("zh")) { return locale === "zh-Hant" || locale === "zh-HK" || locale === "zh-TW" ? "zh-Hant" : "zh-Hans"; } - return locale.split("-")[0] || "en"; + const lang = locale.split("-")[0] || "en"; + return isValidLanguageTag(lang) ? lang : "en"; +}; + +const isValidLanguageTag = (tag: string): boolean => { + try { + Intl.getCanonicalLocales(tag); + return true; + } catch { + return false; + } }; export const isCJKEnv = () => { diff --git a/packages/app/src/vite-env.d.ts b/packages/app/src/vite-env.d.ts index 197fd3e..0294730 100644 --- a/packages/app/src/vite-env.d.ts +++ b/packages/app/src/vite-env.d.ts @@ -45,6 +45,7 @@ declare module "foliate-js/pdf.js" { export function makePDF(file: File): Promise; } + declare module "foliate-js/mobi.js" { export function isMOBI(file: File): Promise; export class MOBI {