feat(epub): add EPUB3 generator and zip packager

This commit is contained in:
pj committed 2026-06-20 13:02:30 -04:00
1 parent 4443eda401
commit b1bd42fe2f
5 files changed
+730

No files matched your search

+268
View File
@@ -8,6 +8,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 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]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.4" version = "1.1.4"
@@ -65,6 +76,15 @@ dependencies = [
"object", "object",
] ]
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]] [[package]]
name = "arrayref" name = "arrayref"
version = "0.3.9" version = "0.3.9"
@@ -428,6 +448,25 @@ dependencies = [
"serde", "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]] [[package]]
name = "cairo-rs" name = "cairo-rs"
version = "0.18.5" version = "0.18.5"
@@ -502,6 +541,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver",
"libc",
"shlex", "shlex",
] ]
@@ -597,6 +638,16 @@ dependencies = [
"half", "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]] [[package]]
name = "citationberg" name = "citationberg"
version = "0.6.1" version = "0.6.1"
@@ -671,6 +722,12 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "constant_time_eq"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
[[package]] [[package]]
name = "cookie" name = "cookie"
version = "0.18.1" version = "0.18.1"
@@ -739,6 +796,21 @@ dependencies = [
"libc", "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]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.0" version = "1.5.0"
@@ -909,6 +981,12 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.8" version = "0.5.8"
@@ -918,6 +996,17 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.1.1" version = "2.1.1"
@@ -947,6 +1036,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer",
"crypto-common", "crypto-common",
"subtle",
] ]
[[package]] [[package]]
@@ -1607,9 +1697,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 5.3.0", "r-efi 5.3.0",
"wasip2", "wasip2",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1947,6 +2039,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "html5ever" name = "html5ever"
version = "0.38.0" version = "0.38.0"
@@ -2408,6 +2509,15 @@ dependencies = [
"cfb", "cfb",
] ]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.0" version = "2.12.0"
@@ -2506,6 +2616,16 @@ dependencies = [
"syn 2.0.118", "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]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.102" version = "0.3.102"
@@ -2756,6 +2876,27 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" 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]] [[package]]
name = "margin-app" name = "margin-app"
version = "0.1.0" version = "0.1.0"
@@ -2770,6 +2911,7 @@ dependencies = [
"typst", "typst",
"typst-as-lib", "typst-as-lib",
"typst-pdf", "typst-pdf",
"zip",
] ]
[[package]] [[package]]
@@ -3296,6 +3438,16 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" 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]] [[package]]
name = "pdf-writer" name = "pdf-writer"
version = "0.14.0" version = "0.14.0"
@@ -4212,6 +4364,17 @@ dependencies = [
"stable_deref_trait", "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]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@@ -4456,6 +4619,12 @@ dependencies = [
"write-fonts", "write-fonts",
] ]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "svgtypes" name = "svgtypes"
version = "0.15.3" version = "0.15.3"
@@ -6769,6 +6938,15 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" 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]] [[package]]
name = "yaml-rust" name = "yaml-rust"
version = "0.4.5" version = "0.4.5"
@@ -6927,6 +7105,26 @@ dependencies = [
"synstructure", "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]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.1.3" version = "0.1.3"
@@ -6996,6 +7194,36 @@ dependencies = [
"syn 2.0.118", "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]] [[package]]
name = "zlib-rs" name = "zlib-rs"
version = "0.6.3" version = "0.6.3"
@@ -7008,6 +7236,46 @@ version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" 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]] [[package]]
name = "zune-core" name = "zune-core"
version = "0.4.12" version = "0.4.12"
+1
View File
@@ -24,6 +24,7 @@ tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
base64 = "0.22" base64 = "0.22"
zip = "2"
typst = "0.14.2" typst = "0.14.2"
typst-pdf = "0.14.2" typst-pdf = "0.14.2"
typst-as-lib = "0.15.5" typst-as-lib = "0.15.5"
+47
View File
@@ -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<Vec<u8>, 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<EpubFile>) -> Result<tauri::ipc::Response, String> {
build(&files).map(tauri::ipc::Response::new)
}
+2
View File
@@ -1,3 +1,4 @@
mod epub;
mod pdf; mod pdf;
mod project; mod project;
@@ -7,6 +8,7 @@ pub fn run() {
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
epub::package_epub,
pdf::compile_pdf, pdf::compile_pdf,
project::read_file, project::read_file,
project::write_file, project::write_file,
+412
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function attr(text: string): string {
return esc(text).replace(/"/g, "&quot;");
}
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 = `<strong>${text}</strong>`;
if (marks.includes("italic")) text = `<em>${text}</em>`;
return text;
}
if (node.type === "hardBreak") return "<br/>";
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 `<li>${inner}</li>`;
}
function figure(node: JSONContent, paths: Map<string, string>): 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
? `<figcaption>${esc(node.attrs.caption)}</figcaption>`
: "";
return `<figure class="placement-${attr(placement)}"><img src="${attr(path)}" alt="${alt}"/>${caption}</figure>`;
}
function block(node: JSONContent, paths: Map<string, string>): string {
switch (node.type) {
case "paragraph":
return `<p>${inlines(node.content)}</p>`;
case "heading":
return node.attrs?.level === 3
? `<h3>${inlines(node.content)}</h3>`
: `<h2>${inlines(node.content)}</h2>`;
case "blockquote":
return `<blockquote>${(node.content ?? []).map((n) => block(n, paths)).join("")}</blockquote>`;
case "bulletList":
return `<ul>${(node.content ?? []).map(listItem).join("")}</ul>`;
case "orderedList":
return `<ol>${(node.content ?? []).map(listItem).join("")}</ol>`;
case "horizontalRule":
return `<hr class="scene-break"/>`;
case "figure":
return figure(node, paths);
default:
return "";
}
}
function chapterBody(content: JSONContent, paths: Map<string, string>): string {
return (content.content ?? [])
.map((n) => block(n, paths))
.filter((s) => s.length > 0)
.join("\n");
}
function extractFigures(book: Book): { assets: EpubFile[]; paths: Map<string, string> } {
const paths = new Map<string, string>();
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 `<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`;
}
function contentOpf(book: Book, chapterIds: string[], assetPaths: string[]): string {
const meta = book.metadata;
const manifest: string[] = [
`<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>`,
`<item id="css" href="style.css" media-type="text/css"/>`,
`<item id="font-regular" href="fonts/Literata-VF.ttf" media-type="font/ttf"/>`,
`<item id="font-italic" href="fonts/Literata-Italic-VF.ttf" media-type="font/ttf"/>`,
];
chapterIds.forEach((_, i) => {
manifest.push(
`<item id="chapter-${i + 1}" href="chapter-${i + 1}.xhtml" media-type="application/xhtml+xml"/>`,
);
});
assetPaths.forEach((path, i) => {
manifest.push(
`<item id="asset-${i + 1}" href="${attr(path)}" media-type="${mediaType(path)}"/>`,
);
});
const spine = [`<itemref idref="nav" linear="no"/>`]
.concat(chapterIds.map((_, i) => `<itemref idref="chapter-${i + 1}"/>`))
.join("\n ");
return `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="book-id" xml:lang="${attr(meta.language || "en")}">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="book-id">${esc(identifier(book))}</dc:identifier>
<dc:title>${esc(meta.title || "Untitled")}</dc:title>
<dc:creator>${esc(meta.author || "")}</dc:creator>
<dc:language>${esc(meta.language || "en")}</dc:language>
<meta property="dcterms:modified">2026-01-01T00:00:00Z</meta>
</metadata>
<manifest>
${manifest.join("\n ")}
</manifest>
<spine>
${spine}
</spine>
</package>`;
}
function navXhtml(book: Book): string {
const items = book.chapters
.map(
(chapter, i) =>
` <li><a href="chapter-${i + 1}.xhtml">${esc(chapter.title || `Chapter ${i + 1}`)}</a></li>`,
)
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${attr(book.metadata.language || "en")}">
<head>
<title>${esc(book.metadata.title || "Untitled")}</title>
<link rel="stylesheet" type="text/css" href="style.css"/>
</head>
<body>
<nav epub:type="toc" id="toc">
<h1>Contents</h1>
<ol>
${items}
</ol>
</nav>
</body>
</html>`;
}
function chapterXhtml(book: Book, index: number, paths: Map<string, string>): string {
const chapter = book.chapters[index];
const title = chapter.title || `Chapter ${index + 1}`;
return `<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${attr(book.metadata.language || "en")}">
<head>
<title>${esc(title)}</title>
<link rel="stylesheet" type="text/css" href="style.css"/>
</head>
<body epub:type="chapter">
<header class="chapter-opener">
<p class="eyebrow">Chapter ${index + 1}</p>
<h1>${esc(title)}</h1>
</header>
${chapterBody(chapter.content, paths)}
</body>
</html>`;
}
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<EpubFile> {
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<Uint8Array> {
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<ArrayBuffer>("package_epub", { files });
return new Uint8Array(buf);
}