//! Reflowable EPUB 3 export (File → Export → EPUB (Reflowable)). //! //! Stories become XHTML in reading order (page, then top-to-bottom, left-to-right of their first //! frame); paragraph styles become CSS classes (`p.`), character styles become `span` //! classes, local overrides become inline styles. Placed graphics become `
` elements at //! their position in the reading order. Parent-page items (folios, running heads) are skipped. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] use std::collections::BTreeMap; use std::fmt::Write as _; use std::io::Write; use designcraft_doc::{Align, CharAttrs, CharProps, Content, Document, Item, ItemId, ParaProps, SpreadRef, StoryId, Styles, story}; #[derive(Clone, Debug)] pub struct EpubOptions { pub title: Option, pub author: Option, pub language: String, /// Stable identifier (urn:uuid:…); derived from the title when absent. pub identifier: Option, /// Cover image (PNG), shown first and marked as the book's cover. pub cover: Option>, } /// A page of a fixed-layout EPUB: its image (PNG), size in points and text (kept for search, /// read-aloud and accessibility). #[derive(Clone, Debug)] pub struct FixedPage { pub png: Vec, pub width: f64, pub height: f64, pub text: String, } /// A pre-paginated (fixed-layout) EPUB: one XHTML page per document page. pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions) -> Result, EpubError> { let io = |e: std::io::Error| EpubError(e.to_string()); let zerr = |e: zip::result::ZipError| EpubError(e.to_string()); let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); let lang = &opts.language; let id = opts.identifier.clone().unwrap_or_else(|| format!("urn:designcraft:{}", slug(&title))); let mut manifest = String::from("\n"); let mut spine = String::new(); let mut files: Vec<(String, Vec)> = Vec::new(); let mut nav_items = String::new(); for (i, p) in pages.iter().enumerate() { let n = i + 1; let (w, h) = (p.width.round().max(1.0) as u32, p.height.round().max(1.0) as u32); let xhtml = format!( "\n\n\n{} — {n}\n\n\"\"/
{}
\n\n", esc(&title), esc(&p.text).replace('\n', "
") ); let _ = writeln!( manifest, "\n", if n == 1 { " properties=\"cover-image\"" } else { "" } ); let _ = writeln!(spine, ""); let _ = write!(nav_items, "
  • {n}
  • "); files.push((format!("OEBPS/page{n}.xhtml"), xhtml.into_bytes())); files.push((format!("OEBPS/pages/p{n}.png"), p.png.clone())); } let (vw, vh) = pages.first().map_or((612, 792), |p| (p.width.round() as u32, p.height.round() as u32)); let nav = format!( "\n\n\n{}\n\n\n", esc(&title) ); let author = opts.author.as_deref().map(|a| format!("{}", esc(a))).unwrap_or_default(); let opf = format!( "\n\n\n{}\n{}\n{lang}{author}\n2026-01-01T00:00:00Z\npre-paginated\nauto\n\n\n\n\n{manifest}\n\n{spine}\n\n", esc(&id), esc(&title) ); let container = "\n\n"; let mut buf = std::io::Cursor::new(Vec::new()); { let mut z = zip::ZipWriter::new(&mut buf); let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); z.start_file("mimetype", stored).map_err(zerr)?; z.write_all(b"application/epub+zip").map_err(io)?; for (name, data) in [("META-INF/container.xml", container.as_bytes()), ("OEBPS/content.opf", opf.as_bytes()), ("OEBPS/nav.xhtml", nav.as_bytes())] { z.start_file(name, deflate).map_err(zerr)?; z.write_all(data).map_err(io)?; } for (name, data) in &files { let o = if name.ends_with(".png") { stored } else { deflate }; z.start_file(name.as_str(), o).map_err(zerr)?; z.write_all(data).map_err(io)?; } z.finish().map_err(zerr)?; } Ok(buf.into_inner()) } impl Default for EpubOptions { fn default() -> Self { EpubOptions { title: None, author: None, language: "en".into(), identifier: None, cover: None } } } #[derive(Debug)] pub struct EpubError(pub String); impl std::fmt::Display for EpubError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) } } impl std::error::Error for EpubError {} fn esc(s: &str) -> String { let mut o = String::with_capacity(s.len()); for c in s.chars() { match c { '&' => o.push_str("&"), '<' => o.push_str("<"), '>' => o.push_str(">"), '"' => o.push_str("""), '\u{E000}'..='\u{E1FF}' => {} '\u{2028}' => o.push_str("
    "), '\u{AD}' => o.push_str("­"), c => o.push(c), } } o } /// CSS class name for a style name. pub fn slug(name: &str) -> String { let s: String = name.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect(); let s = s.trim_matches('-').to_string(); if s.is_empty() { "basic".into() } else if s.starts_with(|c: char| c.is_ascii_digit()) { format!("s-{s}") } else { s } } /// Class names for `names`, in order: [`slug`]s made unique with a `-2`, `-3`… suffix, so styles /// whose names slug alike ("Body Text" / "Body-Text", or non-ASCII names) keep their own rules. fn class_names<'a>(names: impl Iterator) -> BTreeMap<&'a str, String> { let mut out = BTreeMap::new(); let mut used = std::collections::BTreeSet::new(); for name in names { let base = slug(name); let mut class = base.clone(); let mut n = 1; while !used.insert(class.clone()) { n += 1; class = format!("{base}-{n}"); } out.insert(name, class); } out } fn para_classes(st: &Styles) -> BTreeMap<&str, String> { class_names(st.paragraph.iter().map(|s| s.name.as_str())) } fn char_classes(st: &Styles) -> BTreeMap<&str, String> { class_names(st.character.iter().map(|s| s.name.as_str())) } fn hex(doc: &Document, swatch: &str, tint: f32) -> Option { let c = doc.resolve_color(swatch, tint)?; let [r, g, b, _] = c.to_rgba8(1.0); Some(format!("#{r:02x}{g:02x}{b:02x}")) } fn char_css(doc: &Document, c: &CharProps, base_size: f64, out: &mut String) { let _ = write!(out, "font-family: \"{}\", serif; ", c.font_family); let st = c.font_style.to_lowercase(); if st.contains("bold") { out.push_str("font-weight: bold; "); } else if st.contains("semibold") { out.push_str("font-weight: 600; "); } if st.contains("italic") { out.push_str("font-style: italic; "); } let _ = write!(out, "font-size: {:.3}em; ", c.size / base_size); if c.tracking != 0.0 { let _ = write!(out, "letter-spacing: {:.3}em; ", c.tracking / 1000.0); } match c.capitalization { designcraft_doc::Capitalization::AllCaps => out.push_str("text-transform: uppercase; "), designcraft_doc::Capitalization::SmallCaps | designcraft_doc::Capitalization::OpenTypeAllSmallCaps => { out.push_str("font-variant: small-caps; ") } _ => {} } if c.underline { out.push_str("text-decoration: underline; "); } if c.strikethrough { out.push_str("text-decoration: line-through; "); } if let Some(h) = hex(doc, &c.fill, c.fill_tint) { let _ = write!(out, "color: {h}; "); } } fn para_css(p: &ParaProps, base_size: f64, line_px: f64, out: &mut String) { let align = match p.align { Align::Left | Align::TowardsSpine => "left", Align::Center => "center", Align::Right | Align::AwayFromSpine => "right", _ => "justify", }; let _ = write!(out, "text-align: {align}; "); let em = |v: f64| v / base_size; let _ = write!( out, "margin: {:.3}em {:.3}em {:.3}em {:.3}em; text-indent: {:.3}em; line-height: {:.3}; ", em(p.space_before), em(p.right_indent), em(p.space_after), em(p.left_indent), em(p.first_line_indent), line_px ); out.push_str(if p.hyphenate { "hyphens: auto; -webkit-hyphens: auto; " } else { "hyphens: manual; " }); } /// The stylesheet for all paragraph and character styles. pub fn stylesheet(doc: &Document) -> String { let st: &Styles = &doc.styles; let (_, basic) = st.resolve_para_style(story::BASIC_PARAGRAPH); let base = basic.size.max(1.0); let mut css = String::from( "body { margin: 0 5%; font-size: 1em; }\nfigure { margin: 1em 0; text-align: center; }\nfigure img { max-width: 100%; }\nh1, h2, h3 { font-size: inherit; margin: 0; }\n", ); let (pc, cc) = (para_classes(st), char_classes(st)); for ps in &st.paragraph { if ps.name == designcraft_doc::NO_PARA_STYLE { continue; } let (pp, cp) = st.resolve_para_style(&ps.name); let lead = match cp.leading { designcraft_doc::Leading::Auto => pp.auto_leading, designcraft_doc::Leading::Points(v) => v / cp.size.max(1.0), }; let mut rule = String::new(); para_css(&pp, base, lead, &mut rule); char_css(doc, &cp, base, &mut rule); let _ = writeln!(css, "p.{} {{ {rule}}}", pc[ps.name.as_str()]); } for cs in &st.character { if cs.name == story::NO_CHAR_STYLE { continue; } let mut rule = String::new(); let c = &cs.chars; if let Some(f) = &c.font_style { let f = f.to_lowercase(); if f.contains("bold") { rule.push_str("font-weight: bold; "); } if f.contains("italic") { rule.push_str("font-style: italic; "); } } if let Some(fill) = &c.fill && let Some(h) = hex(doc, fill, c.fill_tint.unwrap_or(1.0)) { let _ = write!(rule, "color: {h}; "); } if let Some(sz) = c.size { let _ = write!(rule, "font-size: {:.3}em; ", sz / base); } let _ = writeln!(css, "span.{} {{ {rule}}}", cc[cs.name.as_str()]); } css } fn override_css(doc: &Document, o: &CharAttrs) -> String { let mut s = String::new(); if let Some(f) = &o.font_style { let f = f.to_lowercase(); if f.contains("bold") { s.push_str("font-weight: bold; "); } if f.contains("italic") { s.push_str("font-style: italic; "); } } if let Some(fill) = &o.fill && let Some(h) = hex(doc, fill, o.fill_tint.unwrap_or(1.0)) { let _ = write!(s, "color: {h}; "); } if o.underline == Some(true) { s.push_str("text-decoration: underline; "); } if o.capitalization == Some(designcraft_doc::Capitalization::AllCaps) { s.push_str("text-transform: uppercase; "); } s.trim_end().to_string() } /// Elements Export Tagging may choose (paragraph or character level). fn is_tag(t: &str, character: bool) -> bool { if character { matches!(t, "span" | "em" | "strong" | "i" | "b" | "code" | "sup" | "sub" | "small" | "mark" | "cite" | "q") } else { matches!(t, "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote" | "pre" | "li" | "figcaption" | "aside" | "div") } } /// One story as XHTML paragraphs. pub fn story_html(doc: &Document, sid: StoryId) -> String { let Some(st) = doc.story(sid) else { return String::new() }; let (pc, cc) = (para_classes(&doc.styles), char_classes(&doc.styles)); let mut out = String::new(); for (pi, r) in st.para_ranges().iter().enumerate() { let pf = &st.paras[pi]; // Export Tagging: the style's element and class. let et = doc.styles.export_tag(&pf.style, false); let tag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, false)).unwrap_or("p"); let class = et.map(|e| e.class.clone()).filter(|c| !c.is_empty()).or_else(|| pc.get(pf.style.as_str()).cloned()).unwrap_or_else(|| slug(&pf.style)); let text_empty = st.text[r.clone()].trim().is_empty(); let rtl = doc.styles.resolve_para(pf).0.direction == designcraft_doc::TextDirection::RightToLeft; let _ = write!(out, "<{tag} class=\"{class}\"{}>", if rtl { " dir=\"rtl\"" } else { "" }); if text_empty { out.push_str(" "); } for (rr, f) in st.runs() { let a = rr.start.max(r.start); let b = rr.end.min(r.end); if a >= b { continue; } // Hidden conditional text isn't exported. if f.over.conditions.as_deref().is_some_and(|c| doc.conditions_hide(c)) || f.over.change == Some(designcraft_doc::ChangeMark::Deleted) { continue; } let raw = &st.text[a..b]; let t = if raw.chars().any(|c| designcraft_doc::vars::var_index(c).is_some()) { let sub: String = raw .chars() .map(|c| match designcraft_doc::vars::var_index(c) { Some(i) => doc.variable_value(i, None).unwrap_or_default(), None => c.to_string(), }) .collect(); esc(&sub) } else { esc(raw) }; let et = (f.style != story::NO_CHAR_STYLE).then(|| doc.styles.export_tag(&f.style, true)).flatten(); let ctag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, true)).unwrap_or("span"); let cls = if f.style != story::NO_CHAR_STYLE { let class = et .map(|e| e.class.clone()) .filter(|c| !c.is_empty()) .or_else(|| cc.get(f.style.as_str()).cloned()) .unwrap_or_else(|| slug(&f.style)); format!(" class=\"{class}\"") } else { String::new() }; let style = override_css(doc, &f.over); let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{style}\"") }; if cls.is_empty() && style_attr.is_empty() { out.push_str(&t); } else { let _ = write!(out, "<{ctag}{cls}{style_attr}>{t}"); } } let _ = writeln!(out, ""); } out } /// Reading order of document content: (page, y, x) of each story's first frame and of graphics. #[derive(Debug, Clone, PartialEq)] pub enum Block { Story(StoryId), Image(ItemId), } pub fn reading_order(doc: &Document) -> Vec { // Articles, when there are any to export, decide the order (and what's included). if doc.articles.iter().any(|a| a.export) { let mut out = Vec::new(); let mut seen = Vec::new(); for a in doc.articles.iter().filter(|a| a.export) { for id in &a.items { let Some(top) = doc.item(*id) else { continue }; let mut push = |it: &Item| match &it.content { Content::Text(tf) if !seen.contains(&tf.story) => { seen.push(tf.story); out.push(Block::Story(tf.story)); } Content::Graphic(_) => out.push(Block::Image(it.id)), _ => {} }; top.walk(&mut |i| push(i)); } } return out; } let mut keyed: Vec<((usize, i64, i64), Block)> = Vec::new(); let mut seen_stories = Vec::new(); for (si, sp) in doc.spreads.iter().enumerate() { let first = doc.first_page_of_spread(si); let mut items: Vec<&Item> = Vec::new(); for top in &sp.items { top.walk(&mut |i| items.push(i)); } for it in items { if it.hidden { continue; } let b = it.bounds(); let page = first + sp.page_at_x(b.center().x).unwrap_or(0); let key = (page, (b.y0 * 10.0) as i64, (b.x0 * 10.0) as i64); match &it.content { Content::Text(tf) => { let Some(story) = doc.story(tf.story) else { continue }; // A story is placed where its first frame is. if story.frames.first() == Some(&it.id) && !seen_stories.contains(&tf.story) && !story.text.trim().is_empty() { seen_stories.push(tf.story); keyed.push((key, Block::Story(tf.story))); } } Content::Graphic(_) => keyed.push((key, Block::Image(it.id))), _ => {} } } } let _ = SpreadRef::Doc(0); keyed.sort_by_key(|(k, _)| *k); keyed.into_iter().map(|(_, b)| b).collect() } fn ext(mime: &str) -> &'static str { match mime { "image/jpeg" => "jpg", "image/gif" => "gif", "image/webp" => "webp", "image/svg+xml" => "svg", "video/mp4" => "mp4", "video/quicktime" => "mov", "video/webm" => "webm", "video/ogg" => "ogv", "audio/mpeg" => "mp3", "audio/mp4" => "m4a", "audio/wav" => "wav", "audio/ogg" => "ogg", "audio/aac" => "aac", _ => "png", } } /// The content in reading order as HTML sections and figures; `img_src` names each image /// (a package path, or a data URI for single-file HTML). Returns (body, images by name, table of /// contents). #[allow(clippy::type_complexity)] fn body_html<'a>( doc: &'a Document, img_src: &dyn Fn(&str, &str, &[u8]) -> String, ) -> (String, BTreeMap, Vec<(String, String)>) { let order = reading_order(doc); let mut body = String::new(); let mut images: BTreeMap = BTreeMap::new(); let mut toc: Vec<(String, String)> = Vec::new(); for (k, b) in order.iter().enumerate() { match b { Block::Story(sid) => { let anchor = format!("s{k}"); // First paragraph text as the TOC label. if let Some(st) = doc.story(*sid) { let first: String = st.text.split('\n').next().unwrap_or("").chars().filter(|c| !('\u{E000}'..='\u{E0FF}').contains(c)).take(60).collect(); if !first.trim().is_empty() { toc.push((anchor.clone(), first)); } } let _ = write!(body, "
    \n{}
    \n", story_html(doc, *sid)); } Block::Image(iid) => { let Some(Content::Graphic(g)) = doc.item(*iid).map(|i| &i.content) else { continue }; let Some(a) = doc.assets.get(&g.asset) else { continue }; if a.data.is_empty() { continue; } // Video and sound: an HTML5 player (with the poster, when there is one). if let Some(kind) = designcraft_doc::media_kind(&a.mime) { let name = format!("media/{}.{}", g.asset.0, ext(&a.mime)); images.insert(name.clone(), (a.mime.as_str(), a.data.as_slice())); let m = doc.item(*iid).and_then(|i| i.media.clone()).unwrap_or_default(); let mut attrs = String::new(); for (on, attr) in [(m.controls, " controls=\"controls\""), (m.play_on_page_load, " autoplay=\"autoplay\""), (m.looping, " loop=\"loop\"")] { if on { attrs.push_str(attr); } } if let Some(pa) = m.poster.and_then(|p| doc.assets.get(&p)) { let pn = format!("images/{}.{}", pa.id.0, ext(&pa.mime)); images.insert(pn.clone(), (pa.mime.as_str(), pa.data.as_slice())); if kind == "video" { attrs.push_str(&format!(" poster=\"{}\"", img_src(&pn, &pa.mime, &pa.data))); } } let _ = writeln!(body, "
    <{kind} src=\"{}\"{attrs}>{}
    ", img_src(&name, &a.mime, &a.data), esc(&a.name)); continue; } let name = format!("images/{}.{}", g.asset.0, ext(&a.mime)); images.insert(name.clone(), (a.mime.as_str(), a.data.as_slice())); // Alt text from Object Export Options, else the file name. let alt = doc.item(*iid).map(|i| i.alt_text.as_str()).filter(|t| !t.is_empty()).unwrap_or(&a.name); // Object Export Options: alignment and a page break before. let eo = doc.item(*iid).map(|i| i.export_options.clone()).unwrap_or_default(); let mut css = String::new(); if matches!(eo.align.as_str(), "left" | "center" | "right") { css.push_str(&format!("text-align:{};", eo.align)); } if eo.page_break_before { css.push_str("page-break-before:always;break-before:page;"); } let style = if css.is_empty() { String::new() } else { format!(" style=\"{css}\"") }; let _ = writeln!(body, "\"{}\"/
    ", img_src(&name, &a.mime, &a.data), esc(alt)); } } } (body, images, toc) } /// Options for [`export_html`]. #[derive(Clone, Debug, Default)] pub struct HtmlOptions { pub title: Option, pub language: Option, } /// File › Export › HTML: one self-contained page (styles inline, images as data URIs) with the /// document's stories and graphics in reading order. pub fn export_html(doc: &Document, opts: &HtmlOptions) -> String { let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); let lang = opts.language.clone().unwrap_or_else(|| "en".into()); let (body, _, _) = body_html(doc, &|_, mime, data| format!("data:{mime};base64,{}", base64(data))); format!( "\n\n\n\n\n\n{}\n\n\n\n{body}\n\n", esc(&title), stylesheet(doc) ) } fn base64(data: &[u8]) -> String { const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut out = String::with_capacity(data.len().div_ceil(3) * 4); for c in data.chunks(3) { let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; for (i, shift) in [18, 12, 6, 0].into_iter().enumerate() { out.push(if i <= c.len() { T[(n >> shift & 63) as usize] as char } else { '=' }); } } out } /// Export the document as a reflowable EPUB 3. pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubError> { let io = |e: std::io::Error| EpubError(e.to_string()); let zerr = |e: zip::result::ZipError| EpubError(e.to_string()); let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); let id = opts.identifier.clone().unwrap_or_else(|| format!("urn:designcraft:{}", slug(&title))); let (body, images, toc) = body_html(doc, &|name, _, _| name.to_string()); let lang = &opts.language; let chapter = format!( "\n\n\n{}\n\n{body}\n\n", esc(&title) ); let mut nav_items = String::new(); for (a, label) in &toc { let _ = write!(nav_items, "
  • {}
  • ", esc(label)); } if nav_items.is_empty() { nav_items = format!("
  • {}
  • ", esc(&title)); } let nav = format!( "\n\n\n{}\n\n\n", esc(&title) ); let mut manifest = String::from( "\n\n\n", ); for (k, (name, (mime, _))) in images.iter().enumerate() { let _ = writeln!(manifest, ""); } // Cover: an image page first in reading order. if opts.cover.is_some() { manifest.push_str("\n\n"); } let cover_spine = if opts.cover.is_some() { "\n" } else { "" }; let author = opts.author.as_deref().map(|a| format!("{}", esc(a))).unwrap_or_default(); let opf = format!( "\n\n\n{}\n{}\n{lang}{author}\n2026-01-01T00:00:00Z\n\n\n\n{manifest}\n\n{cover_spine}\n\n\n", esc(&id), esc(&title) ); let container = "\n\n"; let mut buf = std::io::Cursor::new(Vec::new()); { let mut z = zip::ZipWriter::new(&mut buf); let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); z.start_file("mimetype", stored).map_err(zerr)?; z.write_all(b"application/epub+zip").map_err(io)?; for (name, data) in [ ("META-INF/container.xml", container.as_bytes()), ("OEBPS/content.opf", opf.as_bytes()), ("OEBPS/nav.xhtml", nav.as_bytes()), ("OEBPS/style.css", stylesheet(doc).as_bytes()), ("OEBPS/content.xhtml", chapter.as_bytes()), ] { z.start_file(name, deflate).map_err(zerr)?; z.write_all(data).map_err(io)?; } for (name, (_, data)) in &images { z.start_file(format!("OEBPS/{name}"), stored).map_err(zerr)?; z.write_all(data).map_err(io)?; } if let Some(c) = &opts.cover { z.start_file("OEBPS/images/cover.png", stored).map_err(zerr)?; z.write_all(c).map_err(io)?; let xhtml = format!( "\n\n\n{}\n\"{}\"/\n\n", esc(&title), esc(&title) ); z.start_file("OEBPS/cover.xhtml", deflate).map_err(zerr)?; z.write_all(xhtml.as_bytes()).map_err(io)?; } z.finish().map_err(zerr)?; } Ok(buf.into_inner()) } #[cfg(test)] mod tests { use super::*; use designcraft_doc::build::NewDocument; use designcraft_doc::geom::Rect; use designcraft_doc::{ParaFormat, SpreadRef}; use std::io::Read; #[test] fn exports_valid_package_in_reading_order() { let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() }); let lid = d.default_layer(); d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 400.0, 300.0, 500.0), lid, "Second & co", ParaFormat::default()).unwrap(); d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "First\nline two", ParaFormat::default()).unwrap(); let bytes = export_epub(&d, &EpubOptions::default()).unwrap(); let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); assert_eq!(z.by_index(0).unwrap().name(), "mimetype"); let mut html = String::new(); z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap(); let a = html.find("First").unwrap(); let b = html.find("Second <b> & co").unwrap(); assert!(a < b, "reading order"); assert!(html.contains("

    ")); let mut css = String::new(); z.by_name("OEBPS/style.css").unwrap().read_to_string(&mut css).unwrap(); assert!(css.contains("p.basic-paragraph")); assert!(z.by_name("OEBPS/nav.xhtml").is_ok()); } #[test] fn styles_whose_names_slug_alike_keep_their_own_classes() { let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); for (name, size) in [("見出し", 30.0), ("本文", 10.0), ("Body Text", 12.0), ("Body-Text", 20.0)] { d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { name: name.into(), based_on: None, next_style: None, para: Default::default(), chars: CharAttrs { size: Some(size), ..Default::default() }, shortcut: String::new(), }); } for (k, name) in ["見出し", "本文", "Body Text", "Body-Text"].into_iter().enumerate() { let y = 36.0 + 100.0 * k as f64; let pf = ParaFormat { style: name.into(), ..Default::default() }; d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, y, 300.0, y + 50.0), lid, &format!("P{k}"), pf).unwrap(); } let bytes = export_epub(&d, &EpubOptions::default()).unwrap(); let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); let mut html = String::new(); z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap(); let mut css = String::new(); z.by_name("OEBPS/style.css").unwrap().read_to_string(&mut css).unwrap(); let class_of = |text: &str| { let end = html.find(&format!("\">{text}

    ")).unwrap(); let start = html[..end].rfind("class=\"").unwrap() + 7; html[start..end].to_string() }; let classes: Vec = (0..4).map(|k| class_of(&format!("P{k}"))).collect(); for (k, c) in classes.iter().enumerate() { assert!(!classes[..k].contains(c), "class `{c}` shared: {classes:?}"); assert_eq!(css.matches(&format!("p.{c} {{")).count(), 1, "one rule for `{c}`:\n{css}"); } } #[test] fn slugs() { assert_eq!(slug("[Basic Paragraph]"), "basic-paragraph"); assert_eq!(slug("Body First"), "body-first"); assert_eq!(slug("1 Head"), "s-1-head"); } #[test] fn html_is_one_self_contained_page() { let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Hello ", ParaFormat::default()).unwrap(); let html = export_html(&d, &HtmlOptions { title: Some("T".into()), language: None }); assert!(html.starts_with("") && html.contains("T") && html.contains("Hello <web>")); assert!(html.contains("p.basic-paragraph"), "styles inline"); assert_eq!(base64(b"Man"), "TWFu"); assert_eq!(base64(b"Ma"), "TWE="); assert_eq!(base64(b"M"), "TQ=="); } }