From b1bd42fe2f8220e5f37104f1d66b09f7a2349626 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 20 Jun 2026 13:02:30 -0400 Subject: [PATCH] feat(epub): add EPUB3 generator and zip packager --- src-tauri/Cargo.lock | 268 +++++++++++++++++++++++++++ src-tauri/Cargo.toml | 1 + src-tauri/src/epub.rs | 47 +++++ src-tauri/src/lib.rs | 2 + src/export/epub.ts | 412 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 730 insertions(+) create mode 100644 src-tauri/src/epub.rs create mode 100644 src/export/epub.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 938246e..dda1ec7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -65,6 +76,15 @@ dependencies = [ "object", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -428,6 +448,25 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "cairo-rs" version = "0.18.5" @@ -502,6 +541,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -597,6 +638,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "citationberg" version = "0.6.1" @@ -671,6 +722,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "cookie" version = "0.18.1" @@ -739,6 +796,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -909,6 +981,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + [[package]] name = "deranged" version = "0.5.8" @@ -918,6 +996,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -947,6 +1036,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1607,9 +1697,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1947,6 +2039,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -2408,6 +2509,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2506,6 +2616,16 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.102" @@ -2756,6 +2876,27 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "margin-app" version = "0.1.0" @@ -2770,6 +2911,7 @@ dependencies = [ "typst", "typst-as-lib", "typst-pdf", + "zip", ] [[package]] @@ -3296,6 +3438,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "pdf-writer" version = "0.14.0" @@ -4212,6 +4364,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -4456,6 +4619,12 @@ dependencies = [ "write-fonts", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svgtypes" version = "0.15.3" @@ -6769,6 +6938,15 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + [[package]] name = "yaml-rust" version = "0.4.5" @@ -6927,6 +7105,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "zerotrie" version = "0.1.3" @@ -6996,6 +7194,36 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.4", + "hmac", + "indexmap 2.14.0", + "lzma-rs", + "memchr", + "pbkdf2", + "sha1", + "thiserror 2.0.18", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + [[package]] name = "zlib-rs" version = "0.6.3" @@ -7008,6 +7236,46 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zune-core" version = "0.4.12" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cffdc0f..d8a334b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,6 +24,7 @@ tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" base64 = "0.22" +zip = "2" typst = "0.14.2" typst-pdf = "0.14.2" typst-as-lib = "0.15.5" diff --git a/src-tauri/src/epub.rs b/src-tauri/src/epub.rs new file mode 100644 index 0000000..a236068 --- /dev/null +++ b/src-tauri/src/epub.rs @@ -0,0 +1,47 @@ +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use serde::Deserialize; +use std::io::Write; +use zip::write::SimpleFileOptions; +use zip::{CompressionMethod, ZipWriter}; + +#[derive(Deserialize)] +pub struct EpubFile { + path: String, + data: String, + encoding: String, +} + +fn build(files: &[EpubFile]) -> Result, String> { + let buffer = std::io::Cursor::new(Vec::new()); + let mut zip = ZipWriter::new(buffer); + + let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored); + zip.start_file("mimetype", stored) + .map_err(|e| e.to_string())?; + zip.write_all(b"application/epub+zip") + .map_err(|e| e.to_string())?; + + let deflated = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); + for file in files { + if file.path == "mimetype" { + continue; + } + let bytes = match file.encoding.as_str() { + "base64" => STANDARD + .decode(file.data.as_bytes()) + .map_err(|e| format!("failed to decode \"{}\": {e}", file.path))?, + _ => file.data.as_bytes().to_vec(), + }; + zip.start_file(&file.path, deflated) + .map_err(|e| e.to_string())?; + zip.write_all(&bytes).map_err(|e| e.to_string())?; + } + + Ok(zip.finish().map_err(|e| e.to_string())?.into_inner()) +} + +#[tauri::command] +pub fn package_epub(files: Vec) -> Result { + build(&files).map(tauri::ipc::Response::new) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cbf69f4..b653be2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod epub; mod pdf; mod project; @@ -7,6 +8,7 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .invoke_handler(tauri::generate_handler![ + epub::package_epub, pdf::compile_pdf, project::read_file, project::write_file, diff --git a/src/export/epub.ts b/src/export/epub.ts new file mode 100644 index 0000000..dc15011 --- /dev/null +++ b/src/export/epub.ts @@ -0,0 +1,412 @@ +import type { JSONContent } from "@tiptap/core"; +import { invoke } from "@tauri-apps/api/core"; +import type { Book } from "../model/book"; + +export interface EpubFile { + path: string; + data: string; + encoding: "utf8" | "base64"; +} + +function esc(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">"); +} + +function attr(text: string): string { + return esc(text).replace(/"/g, """); +} + +function slug(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function imageExtension(dataUrl: string): string { + const match = /^data:image\/([a-z0-9.+-]+)/i.exec(dataUrl); + const kind = (match?.[1] ?? "png").toLowerCase(); + return kind === "jpeg" ? "jpg" : kind; +} + +function mediaType(path: string): string { + if (path.endsWith(".jpg")) return "image/jpeg"; + if (path.endsWith(".png")) return "image/png"; + if (path.endsWith(".gif")) return "image/gif"; + if (path.endsWith(".webp")) return "image/webp"; + if (path.endsWith(".svg")) return "image/svg+xml"; + return "application/octet-stream"; +} + +function inline(node: JSONContent): string { + if (node.type === "text") { + let text = esc(node.text ?? ""); + const marks = (node.marks ?? []).map((m) => m.type); + if (marks.includes("bold")) text = `${text}`; + if (marks.includes("italic")) text = `${text}`; + return text; + } + if (node.type === "hardBreak") return "
"; + return ""; +} + +function inlines(content: JSONContent[] = []): string { + return content.map(inline).join(""); +} + +function listItem(item: JSONContent): string { + const inner = (item.content ?? []) + .filter((c) => c.type === "paragraph") + .map((p) => inlines(p.content)) + .join(" "); + return `
  • ${inner}
  • `; +} + +function figure(node: JSONContent, paths: Map): string { + const path = paths.get(node.attrs?.src); + if (!path) return ""; + const placement = node.attrs?.placement ?? "inline"; + const alt = attr(node.attrs?.alt ?? ""); + const caption = node.attrs?.caption + ? `
    ${esc(node.attrs.caption)}
    ` + : ""; + return `
    ${alt}${caption}
    `; +} + +function block(node: JSONContent, paths: Map): string { + switch (node.type) { + case "paragraph": + return `

    ${inlines(node.content)}

    `; + case "heading": + return node.attrs?.level === 3 + ? `

    ${inlines(node.content)}

    ` + : `

    ${inlines(node.content)}

    `; + case "blockquote": + return `
    ${(node.content ?? []).map((n) => block(n, paths)).join("")}
    `; + case "bulletList": + return `
      ${(node.content ?? []).map(listItem).join("")}
    `; + case "orderedList": + return `
      ${(node.content ?? []).map(listItem).join("")}
    `; + case "horizontalRule": + return `
    `; + case "figure": + return figure(node, paths); + default: + return ""; + } +} + +function chapterBody(content: JSONContent, paths: Map): string { + return (content.content ?? []) + .map((n) => block(n, paths)) + .filter((s) => s.length > 0) + .join("\n"); +} + +function extractFigures(book: Book): { assets: EpubFile[]; paths: Map } { + const paths = new Map(); + const assets: EpubFile[] = []; + + const visit = (node: JSONContent) => { + if (node.type === "figure") { + const src: string | undefined = node.attrs?.src; + if (src && src.startsWith("data:") && !paths.has(src)) { + const path = `assets/figure-${assets.length + 1}.${imageExtension(src)}`; + paths.set(src, path); + assets.push({ + path: `OEBPS/${path}`, + data: src.slice(src.indexOf(",") + 1), + encoding: "base64", + }); + } + } + (node.content ?? []).forEach(visit); + }; + + book.chapters.forEach((chapter) => visit(chapter.content)); + return { assets, paths }; +} + +function identifier(book: Book): string { + if (book.metadata.isbn) return book.metadata.isbn; + const seed = slug(book.metadata.title || "untitled") || "untitled"; + return `urn:uuid:margin-${seed}`; +} + +function containerXml(): string { + return ` + + + + +`; +} + +function contentOpf(book: Book, chapterIds: string[], assetPaths: string[]): string { + const meta = book.metadata; + const manifest: string[] = [ + ``, + ``, + ``, + ``, + ]; + chapterIds.forEach((_, i) => { + manifest.push( + ``, + ); + }); + assetPaths.forEach((path, i) => { + manifest.push( + ``, + ); + }); + + const spine = [``] + .concat(chapterIds.map((_, i) => ``)) + .join("\n "); + + return ` + + + ${esc(identifier(book))} + ${esc(meta.title || "Untitled")} + ${esc(meta.author || "")} + ${esc(meta.language || "en")} + 2026-01-01T00:00:00Z + + + ${manifest.join("\n ")} + + + ${spine} + +`; +} + +function navXhtml(book: Book): string { + const items = book.chapters + .map( + (chapter, i) => + `
  • ${esc(chapter.title || `Chapter ${i + 1}`)}
  • `, + ) + .join("\n"); + return ` + + + ${esc(book.metadata.title || "Untitled")} + + + + + +`; +} + +function chapterXhtml(book: Book, index: number, paths: Map): string { + const chapter = book.chapters[index]; + const title = chapter.title || `Chapter ${index + 1}`; + return ` + + + ${esc(title)} + + + +
    +

    Chapter ${index + 1}

    +

    ${esc(title)}

    +
    + ${chapterBody(chapter.content, paths)} + +`; +} + +function styleCss(): string { + return `@font-face { + font-family: "Literata"; + font-style: normal; + font-weight: 300 700; + src: url("fonts/Literata-VF.ttf"); +} + +@font-face { + font-family: "Literata"; + font-style: italic; + font-weight: 300 700; + src: url("fonts/Literata-Italic-VF.ttf"); +} + +html { + font-family: "Literata", Georgia, serif; +} + +body { + margin: 0 5%; + line-height: 1.5; +} + +p { + margin: 0; + text-align: justify; + text-indent: 1.3em; + hyphens: auto; + -webkit-hyphens: auto; + -epub-hyphens: auto; +} + +h1, h2, h3 { + text-indent: 0; + hyphens: none; + line-height: 1.25; + font-weight: 500; +} + +h2, h3 { + margin: 1.4em 0 0.6em; +} + +p + p { + text-indent: 1.3em; +} + +.chapter-opener { + text-align: center; + margin: 3em 0 2.2em; +} + +.chapter-opener p, +header + p, +h2 + p, +h3 + p, +blockquote + p, +figure + p, +hr + p { + text-indent: 0; +} + +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.28em; + font-size: 0.72em; + margin: 0 0 0.7em; +} + +.chapter-opener h1 { + font-size: 1.7em; + margin: 0; +} + +blockquote { + margin: 1.2em 1.6em; + font-style: italic; +} + +blockquote p { + text-indent: 0; + text-align: left; +} + +ul, ol { + margin: 1em 0; + padding-left: 1.6em; +} + +li { + text-align: left; +} + +hr.scene-break { + border: 0; + margin: 1.6em 0; + text-align: center; +} + +hr.scene-break::before { + content: "\\2042"; + letter-spacing: 0.4em; +} + +figure { + margin: 1.4em 0; + text-align: center; +} + +figure img { + max-width: 100%; +} + +figcaption { + font-size: 0.82em; + font-style: italic; + text-indent: 0; + margin-top: 0.5em; +} + +figure.placement-full-width img, +figure.placement-full-page img { + width: 100%; +} + +figure.placement-full-page { + margin: 0; + page-break-before: always; + page-break-after: always; +} + +figure.placement-float-top { + float: top; +} +`; +} + +export function bookToEpub(book: Book): EpubFile[] { + const { assets, paths } = extractFigures(book); + const chapterIds = book.chapters.map((c) => c.id); + const assetPaths = Array.from(paths.values()); + + const files: EpubFile[] = [ + { path: "mimetype", data: "application/epub+zip", encoding: "utf8" }, + { path: "META-INF/container.xml", data: containerXml(), encoding: "utf8" }, + { path: "OEBPS/content.opf", data: contentOpf(book, chapterIds, assetPaths), encoding: "utf8" }, + { path: "OEBPS/nav.xhtml", data: navXhtml(book), encoding: "utf8" }, + { path: "OEBPS/style.css", data: styleCss(), encoding: "utf8" }, + ]; + + book.chapters.forEach((_, i) => { + files.push({ + path: `OEBPS/chapter-${i + 1}.xhtml`, + data: chapterXhtml(book, i, paths), + encoding: "utf8", + }); + }); + + files.push(...assets); + return files; +} + +function toBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary); +} + +async function fontFile(url: string, path: string): Promise { + const response = await fetch(url); + const buffer = await response.arrayBuffer(); + return { path, data: toBase64(new Uint8Array(buffer)), encoding: "base64" }; +} + +export async function buildEpub(book: Book): Promise { + const files = bookToEpub(book); + files.push( + await fontFile("/fonts/Literata-VF.ttf", "OEBPS/fonts/Literata-VF.ttf"), + await fontFile("/fonts/Literata-Italic-VF.ttf", "OEBPS/fonts/Literata-Italic-VF.ttf"), + ); + const buf = await invoke("package_epub", { files }); + return new Uint8Array(buf); +}