From 2997e8b79f88f7c2698ac73f2734a490ed86e4f3 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 20 Jun 2026 18:24:09 -0400 Subject: [PATCH] feat(epub): add unzip_epub Tauri command for import --- src-tauri/src/epub.rs | 94 +++++++++++++++++++++++++++++++++++++++++-- src-tauri/src/lib.rs | 1 + 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/epub.rs b/src-tauri/src/epub.rs index 9847c97..3c158e7 100644 --- a/src-tauri/src/epub.rs +++ b/src-tauri/src/epub.rs @@ -1,20 +1,30 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD; -use serde::Deserialize; -use std::io::Write; +use serde::{Deserialize, Serialize}; +use std::io::{Read, Write}; use zip::write::SimpleFileOptions; -use zip::{CompressionMethod, ZipWriter}; +use zip::{CompressionMethod, ZipArchive, ZipWriter}; static LITERATA: &[u8] = include_bytes!("../../public/fonts/Literata-VF.ttf"); static LITERATA_ITALIC: &[u8] = include_bytes!("../../public/fonts/Literata-Italic-VF.ttf"); -#[derive(Deserialize)] +#[derive(Deserialize, Serialize)] pub struct EpubFile { path: String, data: String, encoding: String, } +fn is_text(path: &str) -> bool { + let lower = path.to_lowercase(); + [ + ".opf", ".ncx", ".xhtml", ".html", ".htm", ".xml", ".css", ".txt", + ] + .iter() + .any(|ext| lower.ends_with(ext)) + || lower.ends_with("mimetype") +} + fn build(files: &[EpubFile]) -> Result, String> { let buffer = std::io::Cursor::new(Vec::new()); let mut zip = ZipWriter::new(buffer); @@ -53,3 +63,79 @@ fn build(files: &[EpubFile]) -> Result, String> { pub fn package_epub(files: Vec) -> Result { build(&files).map(tauri::ipc::Response::new) } + +fn unzip(bytes: &[u8]) -> Result, String> { + let mut archive = ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| e.to_string())?; + let mut files = Vec::new(); + for i in 0..archive.len() { + let mut entry = archive.by_index(i).map_err(|e| e.to_string())?; + if entry.is_dir() { + continue; + } + let path = entry.name().to_string(); + let mut buf = Vec::new(); + entry.read_to_end(&mut buf).map_err(|e| e.to_string())?; + let (data, encoding) = if is_text(&path) { + match String::from_utf8(buf) { + Ok(text) => (text, "utf8"), + Err(err) => (STANDARD.encode(err.into_bytes()), "base64"), + } + } else { + (STANDARD.encode(&buf), "base64") + }; + files.push(EpubFile { + path, + data, + encoding: encoding.to_string(), + }); + } + Ok(files) +} + +#[tauri::command] +pub fn unzip_epub(path: String) -> Result, String> { + let bytes = std::fs::read(&path).map_err(|e| format!("failed to read \"{path}\": {e}"))?; + unzip(&bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text(path: &str, data: &str) -> EpubFile { + EpubFile { + path: path.into(), + data: data.into(), + encoding: "utf8".into(), + } + } + + #[test] + fn round_trips_through_build_and_unzip() { + let png = STANDARD.encode([0x89, 0x50, 0x4e, 0x47]); + let input = vec![ + text("mimetype", "application/epub+zip"), + text("META-INF/container.xml", ""), + text("OEBPS/chapter-1.xhtml", "café"), + EpubFile { + path: "OEBPS/assets/figure-1.png".into(), + data: png.clone(), + encoding: "base64".into(), + }, + ]; + + let zipped = build(&input).expect("build"); + let out = unzip(&zipped).expect("unzip"); + let find = |p: &str| out.iter().find(|f| f.path == p).expect(p); + + assert_eq!(find("mimetype").data, "application/epub+zip"); + let chapter = find("OEBPS/chapter-1.xhtml"); + assert_eq!(chapter.encoding, "utf8"); + assert_eq!(chapter.data, "café"); + let figure = find("OEBPS/assets/figure-1.png"); + assert_eq!(figure.encoding, "base64"); + assert_eq!(figure.data, png); + // Fonts are embedded by build() and must come back as base64 binary. + assert_eq!(find("OEBPS/fonts/Literata-VF.ttf").encoding, "base64"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 478713a..bb7092c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -36,6 +36,7 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ epub::package_epub, + epub::unzip_epub, pdf::compile_pdf, project::read_file, project::write_file,