From 2265c2ec83b89eda9751883e41f612f581467180 Mon Sep 17 00:00:00 2001 From: huyiyong Date: Fri, 10 Oct 2025 11:30:54 +0800 Subject: [PATCH 1/4] Add Linux WOFF2 compressor binary using fonteditor-core This provides a Node.js-based alternative to the native woff2_compress binary for Linux platforms, converting TTF fonts to WOFF2 format using fonteditor-core library. Also expands asset protocol scope to include additional system directories for proper resource access. --- .../woff2_compress-x86_64-unknown-linux-gnu | 56 +++++++++++++++++++ packages/app/src-tauri/tauri.conf.json | 9 ++- 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100755 packages/app/src-tauri/binaries/woff2_compress-x86_64-unknown-linux-gnu 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/tauri.conf.json b/packages/app/src-tauri/tauri.conf.json index 329c80b..72a2dc7 100644 --- a/packages/app/src-tauri/tauri.conf.json +++ b/packages/app/src-tauri/tauri.conf.json @@ -93,7 +93,12 @@ "assetProtocol": { "enable": true, "scope": [ - "**" + "**", + "$RESOURCE/**", + "$APPDATA/**", + "$APPDATA", + "$CACHE/**", + "$TEMP/**" ] } } @@ -128,4 +133,4 @@ "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEZFNTAwRUM3NDVDQTVGMjkKUldRcFg4cEZ4dzVRL28wSWZ0S3FZdEVkNlFCTUlaYlRaR2ZyOWJoQTNRNm9EdXF5cnhXc1IwU3QK" } } -} \ No newline at end of file +} From 7c1a1aa6a036578e0368e243eeff87716ef0c453 Mon Sep 17 00:00:00 2001 From: huyiyong Date: Fri, 10 Oct 2025 14:29:54 +0800 Subject: [PATCH 2/4] Add EPUB file discovery and MOBI support - Add EPUB file discovery logic to handle non-standard filenames - Support MOBI format upload with automatic EPUB derivation - Update file upload to accept multiple formats including MOBI - Add derived file handling for MOBI to EPUB conversion --- .../plugins/tauri-plugin-epub/src/commands.rs | 35 +- .../plugins/tauri-plugin-epub/src/pipeline.rs | 44 ++- .../app/src-tauri/src/core/books/commands.rs | 11 + .../app/src-tauri/src/core/books/models.rs | 9 + .../src/pages/library/components/upload.tsx | 8 +- .../pages/reader/store/create-reader-store.ts | 7 +- packages/app/src/services/book-service.ts | 345 ++++++++++++++++-- packages/app/src/services/constants.ts | 2 +- packages/app/src/store/chat-reader-store.ts | 7 +- packages/app/src/store/reader-store.ts | 6 +- packages/app/src/types/simple-book.ts | 4 + 11 files changed, 426 insertions(+), 52 deletions(-) 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 5122513..9689739 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); @@ -565,4 +589,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..76b3d27 100644 --- a/packages/app/src-tauri/src/core/books/commands.rs +++ b/packages/app/src-tauri/src/core/books/commands.rs @@ -35,6 +35,17 @@ pub async fn save_book(app_handle: AppHandle, data: BookUploadData) -> Result, pub metadata: serde_json::Value, + #[serde(rename = "derivedFiles")] + pub derived_files: Option>, } #[derive(Deserialize, Debug)] 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 ae66a96..2712fd4 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, getFileMimeType } 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"; @@ -83,9 +83,10 @@ export const createReaderStore = (bookId: string) => { } const arrayBuffer = await response.arrayBuffer(); - const filename = simpleBook.filePath.split("/").pop() || "book.epub"; + const filename = + simpleBook.filePath?.split("/").pop() || `book.${simpleBook.format.toLowerCase()}`; const file = new File([arrayBuffer], filename, { - type: "application/epub+zip", + type: getFileMimeType(filename), }); const book = { diff --git a/packages/app/src/services/book-service.ts b/packages/app/src/services/book-service.ts index ff34b05..d76e8dc 100644 --- a/packages/app/src/services/book-service.ts +++ b/packages/app/src/services/book-service.ts @@ -1,3 +1,4 @@ +import type { BookDoc } from "@/lib/document"; import { DocumentLoader } from "@/lib/document"; import type { BookQueryOptions, @@ -38,12 +39,23 @@ 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 primaryLanguage = getPrimaryLanguage(metadata.language) || "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 +69,36 @@ export async function uploadBook(file: File): Promise { } } + let derivedFiles: BookUploadData["derivedFiles"]; + if (format === "MOBI" && 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 +109,288 @@ export async function uploadBook(file: File): Promise { } } -async function extractMetadataOnly(file: File): Promise { +const DOCUMENT_LOADER_SUPPORTED_FORMATS: SimpleBook["format"][] = ["EPUB", "MOBI", "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", + }; +} + +async function tryParseBookDocument(fileData: ArrayBuffer, fileName: string): Promise { try { - if (file.name.toLowerCase().endsWith(".epub")) { - const arrayBuffer = await file.arrayBuffer(); - const bookDoc = await parseEpubFile(arrayBuffer, file.name); - return bookDoc.metadata; + 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 { + 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 { @@ -327,13 +620,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"] { @@ -356,7 +649,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": diff --git a/packages/app/src/services/constants.ts b/packages/app/src/services/constants.ts index 4483118..c70e6c9 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"]; 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 e2a0290..a3594b8 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, getFileMimeType } from "@/services/book-service"; import type { Book, BookConfig } from "@/types/book"; import { appDataDir } from "@tauri-apps/api/path"; import { create } from "zustand"; @@ -73,9 +73,10 @@ export const useChatReaderStore = create((set, get) => ({ } const arrayBuffer = await response.arrayBuffer(); - const filename = simpleBook.filePath.split("/").pop() || "book.epub"; + const filename = + simpleBook.filePath?.split("/").pop() || `book.${simpleBook.format.toLowerCase()}`; const file = new File([arrayBuffer], filename, { - type: "application/epub+zip", + type: getFileMimeType(filename), }); const book = { diff --git a/packages/app/src/store/reader-store.ts b/packages/app/src/store/reader-store.ts index 834610c..5f4be72 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, getFileMimeType } 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"; @@ -440,8 +440,8 @@ export const useReaderStore = create((set, get) => ({ 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 filename = book.filePath?.split("/").pop() || `book.${book.format.toLowerCase()}`; + const file = new File([arrayBuffer], filename, { type: getFileMimeType(filename) }); const config = await loadBookConfig(bookId, settings); if (!config) { throw new Error("Config not found"); diff --git a/packages/app/src/types/simple-book.ts b/packages/app/src/types/simple-book.ts index 0965be2..d26db70 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 { From 33918c8e6a57a2a98eda3ee4858ae98e1b927045 Mon Sep 17 00:00:00 2001 From: huyiyong Date: Fri, 10 Oct 2025 15:29:55 +0800 Subject: [PATCH 3/4] Add support for AZW and AZW3 formats - Add format detection and MIME types for AZW/AZW3 - Handle language normalization for book metadata using Intl API - Improve file loading with fallback from rename to copy operations - Update document loader to distinguish between MOBI formats - Extend supported file extensions to include AZW/AZW3 --- packages/app/package.json | 2 +- .../app/src-tauri/src/core/books/commands.rs | 42 +++++++++- packages/app/src/lib/document.ts | 11 ++- .../pages/reader/store/create-reader-store.ts | 22 ++---- packages/app/src/services/book-service.ts | 79 +++++++++++++++++-- packages/app/src/services/constants.ts | 2 +- packages/app/src/store/chat-reader-store.ts | 22 ++---- packages/app/src/store/reader-store.ts | 15 ++-- packages/app/src/types/book.ts | 2 +- packages/app/src/types/simple-book.ts | 2 +- packages/app/src/utils/misc.ts | 15 +++- packages/app/src/vite-env.d.ts | 1 + 12 files changed, 164 insertions(+), 51 deletions(-) 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/src/core/books/commands.rs b/packages/app/src-tauri/src/core/books/commands.rs index 76b3d27..4f0e668 100644 --- a/packages/app/src-tauri/src/core/books/commands.rs +++ b/packages/app/src-tauri/src/core/books/commands.rs @@ -23,8 +23,28 @@ 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"); @@ -41,8 +61,22 @@ pub async fn save_book(app_handle: AppHandle, data: BookUploadData) -> Result {} + 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)); + } + } } } 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/reader/store/create-reader-store.ts b/packages/app/src/pages/reader/store/create-reader-store.ts index 2712fd4..b26716b 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, getFileMimeType } 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"; @@ -74,20 +74,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.${simpleBook.format.toLowerCase()}`; - const file = new File([arrayBuffer], filename, { - type: getFileMimeType(filename), - }); + 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 d76e8dc..f718710 100644 --- a/packages/app/src/services/book-service.ts +++ b/packages/app/src/services/book-service.ts @@ -1,5 +1,5 @@ import type { BookDoc } from "@/lib/document"; -import { DocumentLoader } from "@/lib/document"; +import { DocumentLoader, EXTS } from "@/lib/document"; import type { BookQueryOptions, BookStatus, @@ -29,7 +29,7 @@ import { 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}`); } @@ -51,7 +51,9 @@ export async function uploadBook(file: File): Promise { const formattedTitle = formatTitle(metadata.title) || getFileNameWithoutExt(file.name); const formattedAuthor = formatAuthors(metadata.author) || "Unknown"; - const primaryLanguage = getPrimaryLanguage(metadata.language) || "en"; + const normalizedLanguage = normalizeLanguage(metadata.language); + metadata.language = normalizedLanguage; + const primaryLanguage = normalizedLanguage || "en"; let coverTempFilePath: string | undefined; if (bookDoc) { @@ -70,7 +72,7 @@ export async function uploadBook(file: File): Promise { } let derivedFiles: BookUploadData["derivedFiles"]; - if (format === "MOBI" && bookDoc) { + if (["MOBI", "AZW", "AZW3"].includes(format) && bookDoc) { const derivedEpubPath = await createDerivedEpubFromBookDoc(bookDoc, { tempDirPath, bookHash, @@ -109,7 +111,7 @@ export async function uploadBook(file: File): Promise { } } -const DOCUMENT_LOADER_SUPPORTED_FORMATS: SimpleBook["format"][] = ["EPUB", "MOBI", "CBZ", "FB2", "FBZ"]; +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); @@ -123,6 +125,21 @@ function getDefaultMetadata(fileName: string) { }; } +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); @@ -438,6 +455,50 @@ 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 fileUrl = convertFileSrc(absolutePath); + const response = await fetch(fileUrl); + if (!response.ok) { + continue; + } + const arrayBuffer = await response.arrayBuffer(); + return new File([arrayBuffer], 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 }); @@ -638,6 +699,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": @@ -658,6 +723,10 @@ export 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": diff --git a/packages/app/src/services/constants.ts b/packages/app/src/services/constants.ts index c70e6c9..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", "mobi"]; +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 a3594b8..e4127ce 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, getFileMimeType } from "@/services/book-service"; +import { getBookWithStatusById, loadReadableBookFile } from "@/services/book-service"; import type { Book, BookConfig } from "@/types/book"; import { appDataDir } from "@tauri-apps/api/path"; import { create } from "zustand"; @@ -64,20 +64,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.${simpleBook.format.toLowerCase()}`; - const file = new File([arrayBuffer], filename, { - type: getFileMimeType(filename), - }); + 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 5f4be72..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, getFileMimeType } 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.${book.format.toLowerCase()}`; - const file = new File([arrayBuffer], filename, { type: getFileMimeType(filename) }); + 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 d26db70..d900e42 100644 --- a/packages/app/src/types/simple-book.ts +++ b/packages/app/src/types/simple-book.ts @@ -91,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 { From 3f57883e57ff5a991eda55ba5b4754dbc661bfb8 Mon Sep 17 00:00:00 2001 From: huyiyong Date: Fri, 10 Oct 2025 16:45:13 +0800 Subject: [PATCH 4/4] Handle missing cover files during book save When renaming cover files fails due to missing source files, fall back to copying instead. Also improve cover URL generation by reading files directly as data URLs rather than using convertFileSrc, which provides better reliability for cover images. --- .../app/src-tauri/src/core/books/commands.rs | 17 ++++- packages/app/src/services/book-service.ts | 74 ++++++++++++++++--- 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/packages/app/src-tauri/src/core/books/commands.rs b/packages/app/src-tauri/src/core/books/commands.rs index 4f0e668..80a16be 100644 --- a/packages/app/src-tauri/src/core/books/commands.rs +++ b/packages/app/src-tauri/src/core/books/commands.rs @@ -48,8 +48,21 @@ pub async fn save_book(app_handle: AppHandle, data: BookUploadData) -> Result {} + 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 diff --git a/packages/app/src/services/book-service.ts b/packages/app/src/services/book-service.ts index f718710..61229a1 100644 --- a/packages/app/src/services/book-service.ts +++ b/packages/app/src/services/book-service.ts @@ -24,7 +24,7 @@ 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 { @@ -442,7 +442,7 @@ export async function convertBookWithStatusUrls(book: BookWithStatus): 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"; + } +}