mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 11:07:04 +00:00
feat(epub): add EPUB3 generator and zip packager
This commit is contained in:
1 parent
4443eda401
commit
b1bd42fe2f
5 files changed
+730
No files matched your search
Generated
+268
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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, "<").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 = `<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);
|
||||
}
|
||||
Reference in new issue
Block a user