use std::io::{Seek, Write};
use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
use crate::{
annotation::MapArea,
djvu_document::{DjVuBookmark, DjVuDocument, DjVuPage, DocError},
djvu_render::{RenderError, RenderOptions},
export_control::{ExportObserver, NoOpObserver},
};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum EpubError {
#[error("document error: {0}")]
Doc(#[from] DocError),
#[error("render error: {0}")]
Render(#[from] RenderError),
#[error("zip error: {0}")]
Zip(#[from] zip::result::ZipError),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("export cancelled")]
Cancelled,
}
#[derive(Debug, Clone)]
pub struct EpubOptions {
pub title: String,
pub author: String,
pub dpi: u32,
pub language: String,
pub modified: Option<String>,
pub reflowable_text: bool,
pub jpeg_quality: Option<u8>,
pub adaptive: bool,
}
impl Default for EpubOptions {
fn default() -> Self {
Self {
title: "DjVu Document".to_owned(),
author: String::new(),
dpi: 150,
language: "en".to_owned(),
modified: None,
reflowable_text: false,
jpeg_quality: None,
adaptive: false,
}
}
}
pub fn djvu_to_epub(doc: &DjVuDocument, opts: &EpubOptions) -> Result<Vec<u8>, EpubError> {
let mut cursor = std::io::Cursor::new(Vec::new());
djvu_to_epub_writer(doc, opts, &mut cursor)?;
Ok(cursor.into_inner())
}
pub fn djvu_to_epub_writer<W: Write + Seek>(
doc: &DjVuDocument,
opts: &EpubOptions,
sink: W,
) -> Result<(), EpubError> {
let mut observer = NoOpObserver;
djvu_to_epub_writer_with_observer(doc, opts, sink, &mut observer)
}
pub fn djvu_to_epub_writer_with_observer<W: Write + Seek>(
doc: &DjVuDocument,
opts: &EpubOptions,
sink: W,
observer: &mut dyn ExportObserver,
) -> Result<(), EpubError> {
let mut zip = ZipWriter::new(sink);
zip.start_file(
"mimetype",
SimpleFileOptions::default().compression_method(CompressionMethod::Stored),
)?;
zip.write_all(b"application/epub+zip")?;
zip.start_file(
"META-INF/container.xml",
SimpleFileOptions::default().compression_method(CompressionMethod::Deflated),
)?;
zip.write_all(CONTAINER_XML.as_bytes())?;
let page_count = doc.page_count();
let mut image_names: Vec<String> = Vec::with_capacity(page_count);
#[cfg(feature = "parallel")]
{
use rayon::prelude::*;
let chunk = rayon::current_num_threads().max(1) * 8;
let mut start = 0;
while start < page_count {
if observer.cancelled() {
return finish_cancelled_epub(zip);
}
let end = (start + chunk).min(page_count);
let artifacts: Vec<PageArtifacts> = (start..end)
.into_par_iter()
.map(|i| {
let page = doc.page(i)?.clone();
build_page_artifacts(&page, i, opts)
})
.collect::<Result<_, EpubError>>()?;
for (offset, art) in artifacts.iter().enumerate() {
if observer.cancelled() {
return finish_cancelled_epub(zip);
}
write_page_artifacts(&mut zip, art)?;
image_names.push(art.img_name.clone());
observer.on_progress(start + offset + 1, page_count);
}
start = end;
}
}
#[cfg(not(feature = "parallel"))]
for i in 0..page_count {
if observer.cancelled() {
return finish_cancelled_epub(zip);
}
let page = doc.page(i)?.clone();
let art = build_page_artifacts(&page, i, opts)?;
write_page_artifacts(&mut zip, &art)?;
image_names.push(art.img_name.clone());
observer.on_progress(i + 1, page_count);
}
let nav_xhtml = build_nav(doc.bookmarks(), page_count);
zip.start_file(
"OEBPS/nav.xhtml",
SimpleFileOptions::default().compression_method(CompressionMethod::Deflated),
)?;
zip.write_all(nav_xhtml.as_bytes())?;
let opf = build_opf(opts, page_count, &image_names);
zip.start_file(
"OEBPS/content.opf",
SimpleFileOptions::default().compression_method(CompressionMethod::Deflated),
)?;
zip.write_all(opf.as_bytes())?;
zip.finish()?;
Ok(())
}
fn finish_cancelled_epub<W: Write + Seek>(zip: ZipWriter<W>) -> Result<(), EpubError> {
zip.finish()?;
Err(EpubError::Cancelled)
}
struct PageArtifacts {
img_path: String,
img_name: String,
png_bytes: Vec<u8>,
xhtml_path: String,
xhtml_bytes: Vec<u8>,
}
fn write_page_artifacts<W: Write + Seek>(
zip: &mut ZipWriter<W>,
art: &PageArtifacts,
) -> Result<(), EpubError> {
zip.start_file(
&art.img_path,
SimpleFileOptions::default().compression_method(CompressionMethod::Stored),
)?;
zip.write_all(&art.png_bytes)?;
zip.start_file(
&art.xhtml_path,
SimpleFileOptions::default().compression_method(CompressionMethod::Deflated),
)?;
zip.write_all(&art.xhtml_bytes)?;
Ok(())
}
fn build_page_artifacts(
page: &DjVuPage,
index: usize,
opts: &EpubOptions,
) -> Result<PageArtifacts, EpubError> {
let pw = page.width() as u32;
let ph = page.height() as u32;
let (w, h) = crate::export_common::size_at_dpi(page, opts.dpi as f32);
let render_opts = RenderOptions {
width: w,
height: h,
..RenderOptions::default()
};
let mut rgba = Vec::with_capacity(w as usize * h as usize * 4);
crate::export_common::render_rows_or_pixmap(page, &render_opts, |row| {
rgba.extend_from_slice(row);
})?;
let gray = rgba
.as_chunks::<4>()
.0
.iter()
.all(|px| px[0] == px[1] && px[1] == px[2]);
let make_png = || encode_rgba_to_png(&rgba, w, h, gray);
let make_jpeg = |q: u8| encode_rgba_to_jpeg(&rgba, w, h, q, gray);
let (img_bytes, is_jpeg) = match (opts.jpeg_quality, opts.adaptive) {
(None, _) => (make_png(), false),
(Some(q), false) => {
let j = make_jpeg(q);
if j.is_empty() {
(make_png(), false)
} else {
(j, true)
}
}
(Some(q), true) => {
let p = make_png();
let j = make_jpeg(q);
if !j.is_empty() && j.len() < p.len() {
(j, true)
} else {
(p, false)
}
}
};
let png_bytes = img_bytes;
let page_num = index + 1;
let ext = if is_jpeg { "jpg" } else { "png" };
let img_name = format!("page_{page_num:04}.{ext}");
let img_path = format!("OEBPS/images/{img_name}");
let text_overlay = build_text_overlay(page, pw, ph);
let hyperlinks = page.hyperlinks().unwrap_or_default();
let reflowable: Vec<String> = if opts.reflowable_text {
page.text_layer()
.ok()
.flatten()
.map(|tl| {
tl.reflowable_text()
.into_iter()
.map(|p| p.text)
.collect::<Vec<_>>()
})
.unwrap_or_default()
} else {
Vec::new()
};
let xhtml = build_page_xhtml(
&img_name,
w,
h,
pw,
ph,
&text_overlay,
&hyperlinks,
&reflowable,
);
let xhtml_path = format!("OEBPS/pages/page_{page_num:04}.xhtml");
Ok(PageArtifacts {
img_path,
img_name,
png_bytes,
xhtml_path,
xhtml_bytes: xhtml.into_bytes(),
})
}
fn encode_rgba_to_png(rgba: &[u8], width: u32, height: u32, gray: bool) -> Vec<u8> {
let data: Vec<u8> = if gray {
rgba.as_chunks::<4>().0.iter().map(|px| px[0]).collect()
} else {
rgba_to_rgb(rgba)
};
let mut buf = Vec::new();
{
let mut enc = png::Encoder::new(std::io::Cursor::new(&mut buf), width, height);
enc.set_color(if gray {
png::ColorType::Grayscale
} else {
png::ColorType::Rgb
});
enc.set_depth(png::BitDepth::Eight);
if let Ok(mut writer) = enc.write_header() {
let _ = writer.write_image_data(&data);
}
}
buf
}
fn encode_rgba_to_jpeg(rgba: &[u8], width: u32, height: u32, quality: u8, gray: bool) -> Vec<u8> {
use jpeg_encoder::{ColorType, Encoder};
let mut out = Vec::new();
let (data, ct): (Vec<u8>, ColorType) = if gray {
(
rgba.as_chunks::<4>().0.iter().map(|px| px[0]).collect(),
ColorType::Luma,
)
} else {
(rgba_to_rgb(rgba), ColorType::Rgb)
};
let enc = Encoder::new(&mut out, quality);
if enc.encode(&data, width as u16, height as u16, ct).is_err() {
return Vec::new();
}
out
}
fn rgba_to_rgb(rgba: &[u8]) -> Vec<u8> {
let mut rgb = Vec::with_capacity(rgba.len() / 4 * 3);
for px in rgba.as_chunks::<4>().0 {
rgb.extend_from_slice(&px[..3]);
}
rgb
}
fn build_text_overlay(page: &DjVuPage, pw: u32, ph: u32) -> Vec<(f32, f32, f32, f32, String)> {
let text_layer = match page.text_layer() {
Ok(Some(tl)) => tl,
_ => return Vec::new(),
};
let mut spans = Vec::new();
for span in crate::export_common::word_spans(&text_layer) {
let r = span.rect;
let x = r.x as f32 / pw as f32 * 100.0;
let y = crate::export_common::flip_y_bottom(ph, r.y, r.height) as f32 / ph as f32 * 100.0;
let w = r.width as f32 / pw as f32 * 100.0;
let h = r.height as f32 / ph as f32 * 100.0;
if w > 0.0 && h > 0.0 {
spans.push((x, y, w, h, xml_escape(span.text)));
}
}
spans
}
#[allow(clippy::too_many_arguments)]
fn build_page_xhtml(
img_name: &str,
w: u32,
h: u32,
pw: u32,
ph: u32,
text_overlay: &[(f32, f32, f32, f32, String)],
hyperlinks: &[MapArea],
reflowable: &[String],
) -> String {
let mut html = String::new();
html.push_str(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head>
<meta charset="UTF-8"/>
<title>Page</title>
<style>
body { margin: 0; padding: 0; }
.djvu-page { position: relative; display: block; }
.djvu-page img { display: block; width: 100%; height: auto; }
.djvu-text {
position: absolute;
color: transparent;
background: transparent;
white-space: pre;
overflow: hidden;
pointer-events: none;
}
.djvu-link {
position: absolute;
display: block;
}
</style>
</head>
<body>
"#,
);
html.push_str(&format!(
r#"<div class="djvu-page" style="width:{w}px; height:{h}px;">"#
));
html.push_str(&format!(
r#"<img src="../images/{img_name}" alt="page" width="{w}" height="{h}"/>"#
));
for (x, y, ww, hh, text) in text_overlay {
html.push_str(&format!(
r#"<span class="djvu-text" aria-hidden="true" style="left:{x:.3}%;top:{y:.3}%;width:{ww:.3}%;height:{hh:.3}%;">{text}</span>"#
));
}
for ma in hyperlinks {
if let Some((x, y, ww, hh)) = map_area_to_css(ma, pw, ph) {
let href = resolve_link_href(&ma.url);
let title = xml_escape(&ma.description);
html.push_str(&format!(
r#"<a class="djvu-link" href="{href}" title="{title}" style="left:{x:.3}%;top:{y:.3}%;width:{ww:.3}%;height:{hh:.3}%;"></a>"#
));
}
}
html.push_str("</div>\n");
if !reflowable.is_empty() {
html.push_str(r#"<section class="djvu-reflowable">"#);
html.push('\n');
for para in reflowable {
html.push_str(" <p>");
html.push_str(&xml_escape(para));
html.push_str("</p>\n");
}
html.push_str("</section>\n");
}
html.push_str("</body>\n</html>\n");
html
}
fn map_area_to_css(ma: &MapArea, pw: u32, ph: u32) -> Option<(f32, f32, f32, f32)> {
if pw == 0 || ph == 0 {
return None;
}
let rect = crate::export_common::shape_bbox(&ma.shape)?;
let x = (rect.x as f32 / pw as f32) * 100.0;
let y =
(crate::export_common::flip_y_bottom(ph, rect.y, rect.height) as f32 / ph as f32) * 100.0;
let ww = (rect.width as f32 / pw as f32) * 100.0;
let hh = (rect.height as f32 / ph as f32) * 100.0;
Some((x, y, ww, hh))
}
fn resolve_link_href(url: &str) -> String {
bookmark_href(url)
}
fn build_opf(opts: &EpubOptions, page_count: usize, image_names: &[String]) -> String {
let title = xml_escape(&opts.title);
let author = xml_escape(&opts.author);
let language = xml_escape(&opts.language);
let modified = opts
.modified
.as_deref()
.map(str::to_owned)
.unwrap_or_else(current_timestamp);
let mut manifest_items = String::new();
let mut spine_items = String::new();
manifest_items.push_str(
r#" <item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
"#,
);
let media_type = |name: &str| {
if name.ends_with(".jpg") {
"image/jpeg"
} else {
"image/png"
}
};
let img = |i: usize| -> String {
image_names
.get(i - 1)
.cloned()
.unwrap_or_else(|| format!("page_{i:04}.png"))
};
if page_count > 0 {
let name = img(1);
manifest_items.push_str(&format!(
" <item id=\"cover-image\" href=\"images/{name}\" media-type=\"{}\" properties=\"cover-image\"/>\n",
media_type(&name)
));
}
for i in 1..=page_count {
let pid = format!("page_{i:04}");
if i > 1 {
let name = img(i);
manifest_items.push_str(&format!(
" <item id=\"img_{pid}\" href=\"images/{name}\" media-type=\"{}\"/>\n",
media_type(&name)
));
}
manifest_items.push_str(&format!(
" <item id=\"{pid}\" href=\"pages/page_{i:04}.xhtml\" media-type=\"application/xhtml+xml\"/>\n"
));
spine_items.push_str(&format!(" <itemref idref=\"{pid}\"/>\n"));
}
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" epub:type="book"
xmlns:epub="http://www.idpf.org/2007/ops" unique-identifier="uid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>{title}</dc:title>
<dc:creator>{author}</dc:creator>
<dc:language>{language}</dc:language>
<dc:identifier id="uid">djvu-rs-export</dc:identifier>
<meta property="dcterms:modified">{modified}</meta>
</metadata>
<manifest>
{manifest_items} </manifest>
<spine>
{spine_items} </spine>
</package>
"#
)
}
fn current_timestamp() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let (y, mo, d, hh, mm, ss) = unix_secs_to_parts(secs);
format!("{y:04}-{mo:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
}
fn unix_secs_to_parts(secs: u64) -> (u32, u32, u32, u32, u32, u32) {
let ss = (secs % 60) as u32;
let mins = secs / 60;
let mm = (mins % 60) as u32;
let hours = mins / 60;
let hh = (hours % 24) as u32;
let days = (hours / 24) as u32;
let z = days + 719468;
let era = z / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if mo <= 2 { y + 1 } else { y };
(y, mo, d, hh, mm, ss)
}
fn build_nav(bookmarks: &[DjVuBookmark], page_count: usize) -> String {
let toc_items = if bookmarks.is_empty() {
build_default_nav_items(page_count)
} else {
build_bookmark_nav_items(bookmarks)
};
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head><meta charset="UTF-8"/><title>Navigation</title></head>
<body>
<nav epub:type="toc" id="toc">
<h1>Contents</h1>
<ol>
{toc_items} </ol>
</nav>
</body>
</html>
"#
)
}
fn build_default_nav_items(page_count: usize) -> String {
let mut s = String::new();
for i in 1..=page_count {
s.push_str(&format!(
" <li><a href=\"pages/page_{i:04}.xhtml\">Page {i}</a></li>\n"
));
}
s
}
fn build_bookmark_nav_items(bookmarks: &[DjVuBookmark]) -> String {
let mut s = String::new();
for bm in bookmarks {
let title = xml_escape(&bm.title);
let href = bookmark_href(&bm.url);
s.push_str(&format!(" <li><a href=\"{href}\">{title}</a>"));
if !bm.children.is_empty() {
s.push_str("\n <ol>\n");
s.push_str(&build_bookmark_nav_items_inner(&bm.children, 2));
s.push_str(" </ol>");
}
s.push_str("</li>\n");
}
s
}
fn build_bookmark_nav_items_inner(bookmarks: &[DjVuBookmark], depth: usize) -> String {
let indent = " ".repeat(depth + 1);
let mut s = String::new();
for bm in bookmarks {
let title = xml_escape(&bm.title);
let href = bookmark_href(&bm.url);
s.push_str(&format!("{indent}<li><a href=\"{href}\">{title}</a>"));
if !bm.children.is_empty() {
s.push_str(&format!("\n{indent}<ol>\n"));
s.push_str(&build_bookmark_nav_items_inner(&bm.children, depth + 1));
s.push_str(&format!("{indent}</ol>"));
}
s.push_str("</li>\n");
}
s
}
fn bookmark_href(url: &str) -> String {
if let Some(idx) = crate::export_common::bookmark_page_index(url) {
let page_num = idx + 1;
return format!("pages/page_{page_num:04}.xhtml");
}
if url.starts_with('#') {
return format!("pages/page_0001.xhtml{}", xml_escape(url));
}
xml_escape(url)
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
const CONTAINER_XML: &str = r#"<?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>
"#;
#[cfg(test)]
mod tests {
use super::*;
#[derive(Default)]
struct RecordingObserver {
progress: Vec<(usize, usize)>,
cancel_after: Option<usize>,
}
impl ExportObserver for RecordingObserver {
fn on_progress(&mut self, done: usize, total: usize) {
self.progress.push((done, total));
}
fn cancelled(&self) -> bool {
self.cancel_after
.is_some_and(|after| self.progress.len() >= after)
}
}
fn load_doc(name: &str) -> DjVuDocument {
let data = std::fs::read(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name),
)
.unwrap();
DjVuDocument::parse(&data).unwrap()
}
#[test]
fn epub_writer_observer_reports_each_page_in_order() {
let doc = load_doc("vega.djvu");
let total = doc.page_count();
let opts = EpubOptions {
modified: Some("2026-01-01T00:00:00Z".to_owned()),
..EpubOptions::default()
};
let mut observer = RecordingObserver::default();
djvu_to_epub_writer_with_observer(
&doc,
&opts,
std::io::Cursor::new(Vec::new()),
&mut observer,
)
.expect("observer export must succeed");
assert_eq!(
observer.progress,
(1..=total).map(|done| (done, total)).collect::<Vec<_>>()
);
}
#[test]
fn epub_writer_cancellation_leaves_only_completed_pages() {
let doc = load_doc("vega.djvu");
assert!(doc.page_count() > 1, "fixture must contain multiple pages");
let opts = EpubOptions {
modified: Some("2026-01-01T00:00:00Z".to_owned()),
..EpubOptions::default()
};
let mut observer = RecordingObserver {
cancel_after: Some(1),
..RecordingObserver::default()
};
let mut cursor = std::io::Cursor::new(Vec::new());
let error = djvu_to_epub_writer_with_observer(&doc, &opts, &mut cursor, &mut observer)
.expect_err("observer must cancel the export");
assert!(matches!(error, EpubError::Cancelled));
assert_eq!(observer.progress.len(), 1);
let archive = zip::ZipArchive::new(std::io::Cursor::new(cursor.into_inner()))
.expect("partial archive must remain readable");
let page_images = archive
.file_names()
.filter(|name| name.starts_with("OEBPS/images/"))
.count();
assert!(page_images <= 1, "no additional page may be written");
}
#[test]
fn epub_default_writer_delegates_to_noop_observer() {
let doc = load_doc("vega.djvu");
let opts = EpubOptions {
modified: Some("2026-01-01T00:00:00Z".to_owned()),
..EpubOptions::default()
};
let mut default_cursor = std::io::Cursor::new(Vec::new());
djvu_to_epub_writer(&doc, &opts, &mut default_cursor).unwrap();
let mut observed_cursor = std::io::Cursor::new(Vec::new());
let mut observer = NoOpObserver;
djvu_to_epub_writer_with_observer(&doc, &opts, &mut observed_cursor, &mut observer)
.unwrap();
assert_eq!(observed_cursor.into_inner(), default_cursor.into_inner());
}
#[test]
fn epub_writer_failing_sink_returns_io_error() {
let doc = load_doc("chicken.djvu");
let opts = EpubOptions {
modified: Some("2026-01-01T00:00:00Z".to_owned()),
..EpubOptions::default()
};
let error = djvu_to_epub_writer(
&doc,
&opts,
crate::export_test_support::FailingWriter::after(2),
)
.expect_err("injected sink failure must be returned");
assert!(
matches!(
error,
EpubError::Io(ref error) if error.kind() == std::io::ErrorKind::Other
) || matches!(error, EpubError::Zip(zip::result::ZipError::Io(_)))
);
}
#[test]
fn xml_escape_basic() {
assert_eq!(
xml_escape("a&b<c>d\"e'f"),
"a&b<c>d"e'f"
);
}
#[test]
fn bookmark_href_page_number() {
assert_eq!(bookmark_href("#page=3"), "pages/page_0003.xhtml");
assert_eq!(bookmark_href("#page=1"), "pages/page_0001.xhtml");
}
#[test]
fn bookmark_href_external() {
assert_eq!(bookmark_href("https://example.com"), "https://example.com");
}
#[test]
fn nav_has_toc_for_empty_bookmarks() {
let nav = build_nav(&[], 2);
assert!(nav.contains("epub:type=\"toc\""));
assert!(nav.contains("page_0001.xhtml"));
assert!(nav.contains("page_0002.xhtml"));
}
#[test]
fn current_timestamp_looks_like_iso8601() {
let ts = current_timestamp();
assert_eq!(ts.len(), 20);
assert!(ts.ends_with('Z'));
assert_eq!(&ts[4..5], "-");
assert_eq!(&ts[7..8], "-");
assert_eq!(&ts[10..11], "T");
}
#[test]
fn unix_secs_epoch() {
let (y, mo, d, hh, mm, ss) = unix_secs_to_parts(0);
assert_eq!((y, mo, d, hh, mm, ss), (1970, 1, 1, 0, 0, 0));
}
#[test]
fn unix_secs_known_date() {
let (y, mo, d, hh, mm, ss) = unix_secs_to_parts(1_776_124_800);
assert_eq!((y, mo, d, hh, mm, ss), (2026, 4, 14, 0, 0, 0));
}
#[test]
fn epub_options_default_language_is_en() {
assert_eq!(EpubOptions::default().language, "en");
}
#[test]
fn epub_options_default_modified_is_none() {
assert!(EpubOptions::default().modified.is_none());
}
#[test]
fn epub_options_default_reflowable_text_is_off() {
assert!(!EpubOptions::default().reflowable_text);
}
#[test]
fn build_page_xhtml_omits_reflowable_when_empty() {
let html = build_page_xhtml("p_0001.png", 800, 1000, 800, 1000, &[], &[], &[]);
assert!(!html.contains("djvu-reflowable"));
}
#[test]
fn build_page_xhtml_emits_reflowable_paragraphs() {
let paras = vec!["First paragraph.".to_string(), "Second & last.".to_string()];
let html = build_page_xhtml("p_0001.png", 800, 1000, 800, 1000, &[], &[], ¶s);
assert!(html.contains(r#"<section class="djvu-reflowable">"#));
assert!(html.contains("<p>First paragraph.</p>"));
assert!(html.contains("<p>Second & last.</p>"));
}
#[test]
fn opf_contains_cover_image_for_nonempty_doc() {
let opf = build_opf(&EpubOptions::default(), 3, &[]);
assert!(opf.contains("cover-image"));
assert!(opf.contains("properties=\"cover-image\""));
}
#[test]
fn opf_no_cover_image_for_empty_doc() {
let opf = build_opf(&EpubOptions::default(), 0, &[]);
assert!(!opf.contains("cover-image"));
}
#[test]
fn opf_uses_custom_language() {
let opts = EpubOptions {
language: "ru".to_owned(),
..Default::default()
};
let opf = build_opf(&opts, 1, &[]);
assert!(opf.contains("<dc:language>ru</dc:language>"));
}
#[test]
fn opf_uses_custom_modified() {
let opts = EpubOptions {
modified: Some("2025-01-01T00:00:00Z".to_owned()),
..Default::default()
};
let opf = build_opf(&opts, 1, &[]);
assert!(opf.contains("2025-01-01T00:00:00Z"));
}
}