//! 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. //! Tables become `` elements, footnotes linked `aside`s after the paragraph that refers to //! them, and endnotes a linked section at the end. #![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::geom::{Affine, Rect}; use designcraft_doc::{ Align, AnchorPosition, AnchoredObject, CharAttrs, CharProps, Content, Document, ENDNOTE_REF, FOOTNOTE_REF, Item, ItemId, OBJECT_MARK, ParaProps, SpreadRef, Story, StoryId, Styles, Table, 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 or JPEG), shown first and marked as the book's cover. pub cover: Option>, /// Generic CSS family (`serif`, `sans-serif`, `monospace`, `cursive`, `fantasy`) per font /// family, from the fonts' classification; families not in it are guessed from their name. pub generic_families: BTreeMap, /// Font files packaged with the book and declared with `@font-face`. pub fonts: Vec, /// `dcterms:modified` (Unix seconds, UTC); the document's last save, else its creation, when /// absent. pub modified: Option, /// Title of the navigation (table of contents); from `language` when absent. pub toc_title: Option, } /// A font file packaged with an EPUB: the face of `family` in `style` (`Bold Italic`…). #[derive(Clone, Debug)] pub struct EmbeddedFont { pub family: String, pub style: String, pub data: Vec, } /// The `(family, style)` pairs the stylesheet names, one each, in style order: the fonts an /// export can embed. pub fn stylesheet_fonts(doc: &Document) -> Vec<(String, String)> { let st: &Styles = &doc.styles; let mut out: Vec<(String, String)> = Vec::new(); for ps in &st.paragraph { if ps.name == designcraft_doc::NO_PARA_STYLE { continue; } let (_, cp) = st.resolve_para_style(&ps.name); let f = (cp.font_family.clone(), cp.font_style.clone()); if !f.0.is_empty() && !out.contains(&f) { out.push(f); } } out } /// The CSS generic family for `family`: the known one, else a guess from the name /// (`… Mono`/`Courier`/`… Code` → monospace, `… Sans`/`Gothic`/`Grotesk`/`Helvetica`/`Arial`/ /// `Verdana` → sans-serif), else serif. fn generic_family<'a>(family: &str, known: &'a BTreeMap) -> &'a str { const GENERIC: [&str; 6] = ["serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui"]; if let Some(g) = known.get(family).and_then(|g| GENERIC.iter().find(|x| **x == g.as_str())) { return g; } let f = family.to_lowercase(); let has = |words: &[&str]| words.iter().any(|w| f.contains(w)); if has(&["mono", "courier", " code", "consol"]) { "monospace" } else if has(&["sans", "gothic", "grotesk", "grotesque", "helvetica", "arial", "verdana"]) { "sans-serif" } else { "serif" } } /// CSS weight and style of a style name (`Semibold Italic` → 600, italic). fn css_weight_style(style: &str) -> (u16, &'static str) { let s = style.to_lowercase().replace([' ', '-'], ""); let weight = [ ("thin", 100), ("extralight", 200), ("ultralight", 200), ("light", 300), ("medium", 500), ("semibold", 600), ("demibold", 600), ("extrabold", 800), ("ultrabold", 800), ("bold", 700), ("black", 900), ("heavy", 900), ] .iter() .find(|(n, _)| s.contains(n)) .map_or(400, |(_, w)| *w); (weight, if s.contains("italic") || s.contains("oblique") { "italic" } else { "normal" }) } /// Package path and media type of an embedded font (`fonts/{k}-{family}-{style}.{ext}`). fn font_file(k: usize, f: &EmbeddedFont) -> (String, &'static str) { let (ext, mime) = match f.data.get(..4) { Some(b"OTTO") => ("otf", "font/otf"), Some(b"wOFF") => ("woff", "font/woff"), Some(b"wOF2") => ("woff2", "font/woff2"), _ => ("ttf", "font/ttf"), }; let name: String = format!("{}-{}", f.family, f.style).chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect(); let name = name.split('-').filter(|p| !p.is_empty()).collect::>().join("-"); (format!("fonts/{k}-{name}.{ext}"), mime) } /// An ISO 8601 UTC timestamp (`2026-10-13T09:30:00Z`) for Unix seconds `t`. fn iso8601(t: i64) -> String { let (days, secs) = (t.div_euclid(86_400), t.rem_euclid(86_400)); // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm). let z = days + 719_468; let era = z.div_euclid(146_097); let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let day = doy - (153 * mp + 2) / 5 + 1; let month = if mp < 10 { mp + 3 } else { mp - 9 }; let year = yoe + era * 400 + i64::from(month <= 2); format!("{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", secs / 3600, secs % 3600 / 60, secs % 60) } /// The package's `dcterms:modified`: the given time, else the document's last save or creation. fn modified(doc: &Document, opts: &EpubOptions) -> String { let t = opts.modified.or((doc.modified > 0).then_some(doc.modified)).or((doc.created > 0).then_some(doc.created)); t.map_or_else(|| "2026-01-01T00:00:00Z".to_string(), iso8601) } /// The book's identifier: the given one, else a `urn:uuid` derived from the title and the /// document's creation time, so a book keeps its identifier from export to export and two books /// with the same title don't share one. fn identifier(doc: &Document, opts: &EpubOptions, title: &str) -> String { if let Some(id) = opts.identifier.clone().filter(|i| !i.trim().is_empty()) { return id; } // Two FNV-1a 64-bit hashes (different offsets) as 128 bits, shaped as a version-5-style UUID. let seed = format!("{title}\n{}", doc.created); let fnv = |offset: u64| seed.bytes().fold(offset, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3)); let (a, b) = (fnv(0xcbf2_9ce4_8422_2325), fnv(0x84222325_cbf29ce4)); let hi = (a & 0xffff_ffff_ffff_0fff) | 0x5000; let lo = (b & 0x3fff_ffff_ffff_ffff) | 0x8000_0000_0000_0000; format!("urn:uuid:{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", hi >> 32, (hi >> 16) & 0xffff, hi & 0xffff, lo >> 48, lo & 0xffff_ffff_ffff) } /// Package name and media type of a cover image: JPEG by its signature, else PNG. fn cover_file(data: &[u8]) -> (&'static str, &'static str) { if data.starts_with(&[0xFF, 0xD8, 0xFF]) { ("images/cover.jpg", "image/jpeg") } else { ("images/cover.png", "image/png") } } /// The table of contents' title in `language` (a BCP 47 tag or a language name's code), English /// for languages without one here. pub fn contents_title(language: &str) -> &'static str { let code = language.split(['-', '_']).next().unwrap_or("").to_ascii_lowercase(); match code.as_str() { "nl" => "Inhoud", "de" => "Inhalt", "fr" => "Table des matières", "es" | "pt" => "Índice", "it" => "Indice", "ja" => "目次", "zh" => "目录", _ => "Contents", } } /// 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 = language_tag(&opts.language); let id = identifier(doc, opts, &title); let modified = modified(doc, opts); 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", xml_text(&title), story_markup(&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", xml_text(&title) ); let author = opts.author.as_deref().map(|a| format!("{}", xml_text(a))).unwrap_or_default(); let opf = format!( "\n\n\n{}\n{}\n{lang}{author}\n{modified}\npre-paginated\nauto\n\n\n\n\n{manifest}\n\n{spine}\n\n", xml_text(&id), xml_text(&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, generic_families: BTreeMap::new(), fonts: Vec::new(), modified: None, toc_title: 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 valid_xml_char(c: char) -> bool { matches!(c, '\u{9}' | '\u{A}' | '\u{D}' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}') } fn escape_markup(s: &str, attr: bool, story_breaks: bool) -> 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("""), '\'' if attr => o.push_str("'"), '\u{E000}'..='\u{E1FF}' => {} '\u{2028}' if story_breaks => o.push_str("
    "), '\u{AD}' => o.push_str("­"), c if valid_xml_char(c) => o.push(c), _ => {} } } o } fn xml_text(s: &str) -> String { escape_markup(s, false, false) } fn xml_attr(s: &str) -> String { escape_markup(s, true, false) } /// Story content permits the document's discretionary line-separator marker to become markup. /// Other contexts always serialize it as ordinary text. fn story_markup(s: &str) -> String { escape_markup(s, false, true) } /// A conservative BCP 47 subset suitable for both HTML and EPUB language attributes. fn language_tag(s: &str) -> &str { if s.len() > 63 { return "und"; } let mut parts = s.split('-'); let Some(first) = parts.next() else { return "und" }; if !(2..=8).contains(&first.len()) || !first.bytes().all(|b| b.is_ascii_alphabetic()) { return "und"; } if parts.any(|p| p.is_empty() || p.len() > 8 || !p.bytes().all(|b| b.is_ascii_alphanumeric())) { return "und"; } s } /// Return a MIME type that is safe to place in an attribute and a data URL. Parameters are omitted /// because exported assets are always represented as their original binary bytes. fn safe_mime(mime: &str) -> &str { fn token(s: &str) -> bool { !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'+' | b'-')) } let mime = mime.split_once(';').map_or(mime, |(essence, _)| essence).trim(); if mime.len() <= 127 && let Some((kind, subtype)) = mime.split_once('/') && !subtype.contains('/') && token(kind) && token(subtype) { mime } else { "image/png" } } /// Quote arbitrary document metadata as one CSS string token. Escaping `<` is important because /// HTML parses the contents of a `style` element before CSS parsing sees it. fn css_string(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { if matches!(c, '"' | '\\' | '<' | '>' | '&') || c.is_control() { let _ = write!(out, "\\{:x} ", c as u32); } else { out.push(c); } } out } /// 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, generics: &BTreeMap, out: &mut String) { let _ = write!(out, "font-family: \"{}\", {}; ", css_string(&c.font_family), generic_family(&c.font_family, generics)); // The face's own weight, so an embedded Semibold or Light face is the one chosen. let (weight, style) = css_weight_style(&c.font_style); let _ = write!(out, "font-weight: {weight}; font-style: {style}; "); 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 { stylesheet_with(doc, &BTreeMap::new(), &[]) } /// [`stylesheet`] with the generic families to fall back on and `@font-face` rules for the /// embedded fonts (packaged as [`font_file`] names them). fn stylesheet_with(doc: &Document, generics: &BTreeMap, fonts: &[EmbeddedFont]) -> String { let st: &Styles = &doc.styles; let mut faces = String::new(); for (k, f) in fonts.iter().enumerate() { let (path, _) = font_file(k, f); let (weight, style) = css_weight_style(&f.style); let _ = writeln!( faces, "@font-face {{ font-family: \"{}\"; src: url(\"{path}\"); font-weight: {weight}; font-style: {style}; }}", css_string(&f.family) ); } let (_, basic) = st.resolve_para_style(story::BASIC_PARAGRAPH); let base = basic.size.max(1.0); let mut css = faces; css.push_str( "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, generics, &mut rule); let (tag, class) = export_mapping(st, &ps.name, false, &pc); if let Some(selector) = class_selector(tag, &class) { let _ = writeln!(css, "{selector} {{ {rule}}}"); } } 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 (tag, class) = export_mapping(st, &cs.name, true, &cc); if let Some(selector) = class_selector(tag, &class) { let _ = writeln!(css, "{selector} {{ {rule}}}"); } } 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") } } /// Resolve Export Tagging once for both markup and its stylesheet. Keep the generated, /// collision-free class when no custom class was supplied, and the existing tag fallback. fn export_mapping<'a>(st: &'a Styles, name: &str, character: bool, classes: &BTreeMap<&str, String>) -> (&'a str, String) { let et = st.export_tag(name, character); let tag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, character)).unwrap_or(if character { "span" } else { "p" }); let class = et.map(|e| e.class.clone()).filter(|c| !c.is_empty()).or_else(|| classes.get(name).cloned()).unwrap_or_else(|| slug(name)); // Match the characters xml_attr actually emits, before escaping either output context. let class = class.chars().filter(|c| valid_xml_char(*c) && !('\u{E000}'..='\u{E1FF}').contains(c)).collect(); (tag, class) } /// Each class token must match on the same element. Escape CSS identifier punctuation and /// leading digits without changing the class written into XHTML. fn class_selector(tag: &str, class: &str) -> Option { let mut tokens = class.split_ascii_whitespace().peekable(); // A nonempty but whitespace-only custom class has no token to select. Never emit a bare // element selector, which would apply this style to unrelated paragraphs or character runs. tokens.peek()?; let mut selector = tag.to_string(); for token in tokens { selector.push('.'); for (i, c) in token.chars().enumerate() { let leading_digit = c.is_ascii_digit() && (i == 0 || i == 1 && token.starts_with('-')); if !leading_digit && token != "-" && (c.is_ascii_alphanumeric() || matches!(c, '-' | '_') || !c.is_ascii()) { selector.push(c); } else { let _ = write!(selector, "\\{:x} ", c as u32); } } } Some(selector) } /// Closes the open list, if any. fn close_list(list: &mut Option<&str>, out: &mut String) { if let Some(l) = list.take() { let _ = writeln!(out, "", l.split(' ').next().unwrap_or(l)); } } /// Closes the open preformatted block and list, if any. fn close_blocks(list: &mut Option<&str>, pre: &mut Option<(String, bool)>, out: &mut String) { if pre.take().is_some() { out.push_str("\n"); } close_list(list, out); } /// Nesting limit for stories inside stories (table cells, footnotes, tables in cells), so a deeply /// nested or cyclic document can't recurse without bound. const MAX_DEPTH: usize = 8; /// Writes stories with their tables and notes, numbering notes across the stories of one export. struct StoryWriter<'a, 'f> { doc: &'a Document, pc: BTreeMap<&'a str, String>, cc: BTreeMap<&'a str, String>, /// EPUB structural semantics (`epub:type`); plain HTML doesn't declare the namespace. epub: bool, /// Notes written so far, for unique element ids. footnote_ids: u32, endnote_ids: u32, /// Number shown on the next footnote and endnote of the current story. next_footnote: u32, next_endnote: u32, /// Endnotes met so far: (number, list item markup). endnotes: Vec<(u32, String)>, /// Names each packaged file (a package path, or a data URI for single-file HTML). Without /// it, anchored objects are left out. img_src: Option<&'f dyn Fn(&str, &str, &[u8]) -> String>, /// Images, video and sound met so far, by package name. images: BTreeMap, /// Asset names of the images written without alt text (decorative). no_alt: Vec, /// Prefix of the `id`s the current story's headings get (`{prefix}-p{paragraph index}`, the /// targets of the table of contents); only top-level paragraphs get one. heading_ids: Option, } impl<'a, 'f> StoryWriter<'a, 'f> { fn new(doc: &'a Document, epub: bool, img_src: Option<&'f dyn Fn(&str, &str, &[u8]) -> String>) -> Self { StoryWriter { doc, pc: para_classes(&doc.styles), cc: char_classes(&doc.styles), epub, footnote_ids: 0, endnote_ids: 0, next_footnote: 1, next_endnote: 1, endnotes: Vec::new(), img_src, images: BTreeMap::new(), no_alt: Vec::new(), heading_ids: None, } } fn epub_type(&self, t: &str) -> String { if self.epub { format!(" epub:type=\"{t}\"") } else { String::new() } } /// One document story: paragraphs, tables, and each footnote after the paragraph that refers /// to it. fn story(&mut self, sid: StoryId, heading_ids: Option<&str>) -> String { let Some(st) = self.doc.story(sid) else { return String::new() }; self.heading_ids = heading_ids.map(str::to_string); self.next_footnote = self.doc.footnote_start(sid); self.next_endnote = self.doc.endnote_start(sid); let mut out = String::new(); self.paragraphs(st, 0, "", &mut out); out } /// `st`'s paragraphs. `lead` (a note's number) starts the first paragraph. /// /// Consecutive `li` paragraphs share one list (`ol` for a numbered style, `ul` for a bulleted /// one, an unmarked `ul` when the style has no list type of its own, e.g. typed bullets), and /// consecutive `pre` paragraphs of one style one block, their lines separated by newlines. fn paragraphs(&mut self, st: &Story, depth: usize, lead: &str, out: &mut String) { let doc = self.doc; let mut lead = lead; let mut list: Option<&str> = None; let mut pre: Option<(String, bool)> = None; for (pi, (r, pf)) in st.para_ranges().into_iter().zip(&st.paras).enumerate() { let Some(text) = st.text.get(r.clone()) else { continue }; let table = pf.table.and_then(|id| st.tables.get(&id)).filter(|_| text.contains(story::TABLE_ANCHOR)); let only_table = table.is_some() && text.chars().all(|c| c == story::TABLE_ANCHOR || c.is_whitespace()); if !only_table || !lead.is_empty() { // Export Tagging: the style's element and class. let (tag, class) = export_mapping(&doc.styles, &pf.style, false, &self.pc); let props = doc.styles.resolve_para(pf).0; let rtl = props.direction == designcraft_doc::TextDirection::RightToLeft; let continues_pre = tag == "pre" && pre.as_ref() == Some(&(class.clone(), rtl)); if !continues_pre && pre.take().is_some() { out.push_str("\n"); } let list_tag = (tag == "li").then_some(match props.list_type { designcraft_doc::ListType::Numbers => "ol", designcraft_doc::ListType::Bullets => "ul", designcraft_doc::ListType::None => "ul style=\"list-style:none\"", }); if list != list_tag { close_list(&mut list, out); if let Some(l) = list_tag { let _ = writeln!(out, "<{l}>"); } list = list_tag; } // An EPUB chapter file starts before each top-level h1, outside any open list or // preformatted block. if depth == 0 && self.heading_ids.is_some() && heading_level(tag) == Some(1) { out.push_str(CHAPTER_MARK); } if continues_pre { out.push('\n'); } else { let id = match &self.heading_ids { Some(prefix) if depth == 0 && heading_level(tag).is_some() => format!(" id=\"{prefix}-p{pi}\""), _ => String::new(), }; let _ = write!(out, "<{tag}{id} class=\"{}\"{}>", xml_attr(&class), if rtl { " dir=\"rtl\"" } else { "" }); } out.push_str(lead); if lead.is_empty() && text.trim().is_empty() { out.push_str(" "); } lead = ""; let mut after = String::new(); if !only_table { self.runs(st, r, depth, out, &mut after); } if tag == "pre" { // Closed when the next paragraph isn't a line of the same block. pre = Some((class, rtl)); } else { let _ = writeln!(out, ""); } // Footnotes follow the paragraph outside any list or preformatted block. if !after.is_empty() { close_blocks(&mut list, &mut pre, out); out.push_str(&after); } } if let Some(t) = table && depth < MAX_DEPTH { close_blocks(&mut list, &mut pre, out); self.table(t, depth + 1, out); } } close_blocks(&mut list, &mut pre, out); } /// The runs of paragraph `para` of `st`; footnotes go to `after`. fn runs(&mut self, st: &Story, para: std::ops::Range, depth: usize, out: &mut String, after: &mut String) { let doc = self.doc; for (rr, f) in st.runs() { let a = rr.start.max(para.start); let b = rr.end.min(para.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 Some(raw) = st.text.get(a..b) else { continue }; let mut t = String::new(); let mut seg_start = 0; // Index of the next anchored object: marks count from the story start, as // `Story::object_at` counts them (counted once, at the run's first mark). let mut mark: Option = None; for (i, c) in raw.char_indices() { if c != FOOTNOTE_REF && c != ENDNOTE_REF && c != OBJECT_MARK { continue; } t.push_str(&run_text(doc, raw.get(seg_start..i).unwrap_or_default())); seg_start = i + c.len_utf8(); if c == OBJECT_MARK { let k = mark.unwrap_or_else(|| st.text.get(..a).map_or(0, |t| t.matches(OBJECT_MARK).count())); mark = Some(k.saturating_add(1)); if let Some(o) = st.objects.get(k) { t.push_str(&self.object(o)); } } else if depth < MAX_DEPTH { if c == FOOTNOTE_REF { self.footnote(st, a + i, depth, &mut t, after); } else { self.endnote(st, a + i, depth, &mut t); } } } t.push_str(&run_text(doc, raw.get(seg_start..).unwrap_or_default())); let (ctag, cls) = if f.style != story::NO_CHAR_STYLE { let (tag, class) = export_mapping(&doc.styles, &f.style, true, &self.cc); (tag, format!(" class=\"{}\"", xml_attr(&class))) } else { ("span", String::new()) }; let style = override_css(doc, &f.over); let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&style)) }; if cls.is_empty() && style_attr.is_empty() { out.push_str(&t); } else { let _ = write!(out, "<{ctag}{cls}{style_attr}>{t}"); } } } /// An anchored object where it is anchored: its graphics in the text, an above-line object as /// a block of its own. Anchored text frames are left out. fn object(&mut self, o: &AnchoredObject) -> String { let mut out = String::new(); o.item.walk(&mut |it| { if let Some((markup, _)) = self.graphic(it) { out.push_str(&markup); } }); match &o.position { AnchorPosition::AboveLine { align, .. } if !out.is_empty() => { let align = match align { designcraft_doc::anchored::AnchorAlign::Left => "left", designcraft_doc::anchored::AnchorAlign::Center => "center", designcraft_doc::anchored::AnchorAlign::Right => "right", }; format!("{out}") } _ => out, } } /// The markup for one graphic item: an ``, or an HTML5 player for video and sound (with /// `true`), its file registered in `images`. `None` for an item that isn't a graphic, whose /// asset has no data, or when the writer names no files. fn graphic(&mut self, it: &Item) -> Option<(String, bool)> { let img_src = self.img_src?; let doc = self.doc; let Content::Graphic(g) = &it.content else { return None }; let a = doc.assets.get(&g.asset)?; if a.data.is_empty() { return None; } let mime = safe_mime(&a.mime); // Video and sound: an HTML5 player (with the poster, when there is one). if let Some(kind) = designcraft_doc::media_kind(mime) { let name = asset_path("media", a, mime); self.images.insert(name.clone(), (mime, a.data.as_slice())); let m = it.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 poster_mime = safe_mime(&pa.mime); let pn = asset_path("images", pa, poster_mime); self.images.insert(pn.clone(), (poster_mime, pa.data.as_slice())); if kind == "video" { attrs.push_str(&format!(" poster=\"{}\"", xml_attr(&img_src(&pn, poster_mime, &pa.data)))); } } let markup = format!("<{kind} src=\"{}\"{attrs}>{}", xml_attr(&img_src(&name, mime, &a.data)), xml_text(&a.name)); return Some((markup, true)); } let name = asset_path("images", a, mime); self.images.insert(name.clone(), (mime, a.data.as_slice())); // Alt text from Object Export Options; without one the image counts as decorative // (`alt=""`): a file name read aloud helps nobody. if it.alt_text.trim().is_empty() { self.no_alt.push(a.name.clone()); } Some((format!("\"{}\"/", xml_attr(&img_src(&name, mime, &a.data)), xml_attr(&it.alt_text)), false)) } /// The footnote whose reference is at byte `pos` of `st`: a note reference in `out`, the note /// itself in `after`. fn footnote(&mut self, st: &Story, pos: usize, depth: usize, out: &mut String, after: &mut String) { let Some(note) = st.notes.get(st.notes_before(pos)) else { return }; let doc = self.doc; let o = &doc.footnote_options; let n = self.next_footnote; self.next_footnote = n.saturating_add(1); self.footnote_ids = self.footnote_ids.saturating_add(1); let id = self.footnote_ids; let _ = write!( out, "{}", self.epub_type("noteref"), xml_text(&o.label(n, false)) ); let lead = format!("{} ", xml_text(&o.label(n, true))); let mut body = String::new(); self.paragraphs(¬e.text, depth + 1, &lead, &mut body); let _ = write!(after, "\n{body}\n", self.epub_type("footnote")); } /// The endnote whose reference is at byte `pos` of `st`: a note reference in `out`; the note /// joins the endnotes section. fn endnote(&mut self, st: &Story, pos: usize, depth: usize, out: &mut String) { let Some(note) = st.endnotes.get(st.endnotes_before(pos)) else { return }; let n = self.next_endnote; self.next_endnote = n.saturating_add(1); self.endnote_ids = self.endnote_ids.saturating_add(1); let id = self.endnote_ids; let label = xml_text(&self.doc.endnote_options.label(n)); let _ = write!(out, "{label}", self.epub_type("noteref")); let mut body = String::new(); self.paragraphs(¬e.text, depth + 1, &format!("{label} "), &mut body); let item = format!("\n{body}\n", self.epub_type("endnote")); self.endnotes.push((n, item)); } /// The endnotes met so far, in number order, as one section (empty when there are none). fn endnotes_section(&mut self) -> String { if self.endnotes.is_empty() { return String::new(); } self.endnotes.sort_by_key(|(n, _)| *n); let doc = self.doc; let o = &doc.endnote_options; let mut out = format!("\n", self.epub_type("endnotes")); if !o.heading.trim().is_empty() { let (tag, class) = export_mapping(&doc.styles, &o.heading_style, false, &self.pc); let _ = writeln!(out, "<{tag} class=\"{}\">{}", xml_attr(&class), xml_text(&o.heading)); } out.push_str("
      \n"); for (_, item) in &self.endnotes { out.push_str(item); } out.push_str("
    \n\n"); out } /// A table: header rows as `thead`, footer rows as `tfoot`, merged cells as row and column /// spans (kept within their row group). fn table(&mut self, t: &Table, depth: usize, out: &mut String) { let (nr, nc) = (t.nrows(), t.ncols()); if nr.checked_mul(nc) != Some(t.cells.len()) { return; } let (h, f) = (t.header_rows(), t.footer_rows()); let body_end = nr.saturating_sub(f).max(h); let mut covered = vec![false; t.cells.len()]; out.push_str("
    \n"); for (rows, group, cell_tag) in [(0..h, "thead", "th"), (h..body_end, "tbody", "td"), (body_end..nr, "tfoot", "td")] { if rows.is_empty() { continue; } let _ = writeln!(out, "<{group}>"); for r in rows.clone() { out.push_str(""); for c in 0..nc { let i = r * nc + c; if covered.get(i).copied().unwrap_or(true) { continue; } let Some(cell) = t.cells.get(i) else { continue }; let rs = (cell.row_span.max(1) as usize).min(rows.end - r); let cs = (cell.col_span.max(1) as usize).min(nc - c); for rr in r..r + rs { for cc in c..c + cs { if let Some(slot) = covered.get_mut(rr * nc + cc) { *slot = true; } } } let _ = write!(out, "<{cell_tag}"); if rs > 1 { let _ = write!(out, " rowspan=\"{rs}\""); } if cs > 1 { let _ = write!(out, " colspan=\"{cs}\""); } out.push('>'); self.paragraphs(&cell.text, depth, "", out); let _ = writeln!(out, ""); } out.push_str("\n"); } let _ = writeln!(out, ""); } out.push_str("
    \n"); } } /// Run text with variables replaced by their values. fn run_text(doc: &Document, raw: &str) -> String { 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(); story_markup(&sub) } else { story_markup(raw) } } /// One story as XHTML: paragraphs, tables, and each footnote after the paragraph that refers to /// it. Endnote references link to the endnotes section that the full export ends with. pub fn story_html(doc: &Document, sid: StoryId) -> String { StoryWriter::new(doc, true, None).story(sid, None) } /// Where the EPUB body splits into chapter files: before each `h1`. const CHAPTER_MARK: &str = ""; /// The body split at its [`CHAPTER_MARK`]s into chapter bodies, each with its sections closed /// and the one a split goes through reopened in the next; a part with nothing but the start of a /// section (a story that opens with its `h1`) moves that start into the next part. fn split_chapters(body: &str) -> Vec { let mut out: Vec = Vec::new(); let mut carry: Option = None; for piece in body.split(CHAPTER_MARK) { let mut part = carry.take().unwrap_or_default(); part.push_str(piece); if let Some(open) = part.rfind("")) { let tag_end = part[open..].find('>').map_or(part.len(), |e| open + e + 1); if part[tag_end..].trim().is_empty() { carry = Some(part[open..].to_string()); part.truncate(open); } else { part.push_str("\n"); carry = Some("
    \n".to_string()); } } if !part.trim().is_empty() { out.push(part); } } if let Some(rest) = carry.filter(|c| !c.trim().is_empty()) && let Some(last) = out.last_mut() { // Only an opening tag can be left over; close it where it stands. last.push_str(&rest); last.push_str("
    \n"); } if out.is_empty() { out.push(String::new()); } out } /// `chapter` (the file `own`) with each in-document link (`href="#id"`) to an element in another /// chapter file pointed at that file. fn link_across_chapters(chapter: &str, own: &str, files: &BTreeMap) -> String { const HREF: &str = "href=\"#"; let mut out = String::with_capacity(chapter.len()); let mut rest = chapter; while let Some(i) = rest.find(HREF) { out.push_str(rest.get(..i).unwrap_or_default()); let tail = rest.get(i + HREF.len()..).unwrap_or_default(); let id = tail.split('"').next().unwrap_or_default(); match files.get(id) { Some(file) if file != own => { let _ = write!(out, "href=\"{file}#"); } _ => out.push_str(HREF), } rest = tail; } out.push_str(rest); out } /// The package file of chapter `k` (0-based): `content.xhtml`, then `content2.xhtml`, …. fn chapter_file(k: usize) -> String { if k == 0 { "content.xhtml".to_string() } else { format!("content{}.xhtml", k + 1) } } /// The level of a heading element (`h1` → 1 … `h6` → 6). fn heading_level(tag: &str) -> Option { tag.strip_prefix('h').and_then(|n| n.parse::().ok()).filter(|n| (1..=6).contains(n)) } /// Table of contents entries (target, label, level): the `h1`–`h3` paragraphs of the stories in /// reading order, or, in a document without headings, each story's first paragraph (level 1). fn toc_entries(doc: &Document, order: &[Block]) -> Vec<(String, String, u8)> { let pc = para_classes(&doc.styles); let label = |t: &str| -> String { t.chars().filter(|c| !('\u{E000}'..='\u{E0FF}').contains(c)).take(80).collect::().trim().to_string() }; let mut headings = Vec::new(); let mut firsts = Vec::new(); for (k, b) in order.iter().enumerate() { let Block::Story(sid) = b else { continue }; // The endnote story isn't written (the endnotes section stands in for it). if doc.endnote_story == Some(*sid) { continue; } let Some(st) = doc.story(*sid) else { continue }; let first = label(st.text.split('\n').next().unwrap_or("")); if !first.is_empty() { firsts.push((format!("s{k}"), first, 1)); } for (pi, r) in st.para_ranges().iter().enumerate() { let Some(pf) = st.paras.get(pi) else { continue }; let (tag, _) = export_mapping(&doc.styles, &pf.style, false, &pc); let Some(level) = heading_level(tag).filter(|l| *l <= 3) else { continue }; let text = label(st.text.get(r.clone()).unwrap_or("")); if !text.is_empty() { headings.push((format!("s{k}-p{pi}"), text, level)); } } } if headings.is_empty() { firsts } else { headings } } /// The table of contents as nested `li`/`ol` items (the content of the navigation's outer /// `ol`): a deeper level goes into the previous item, skipped levels count as one. fn nav_list(entries: &[(String, String, u8)], files: &BTreeMap) -> String { let mut out = String::new(); let mut open: Vec = Vec::new(); for (target, label, level) in entries { let file = files.get(target).map_or("content.xhtml", String::as_str); let item = format!("
  • {}", xml_text(label)); match open.last().copied() { None => open.push(*level), Some(top) if *level > top => { out.push_str("
      "); open.push(*level); } Some(_) => { // Close the levels whose parent is as deep as this entry or deeper. while open.len() > 1 && open.len().checked_sub(2).and_then(|i| open.get(i)).is_some_and(|parent| *parent >= *level) { open.pop(); out.push_str("
    "); } out.push_str("
  • "); if let Some(t) = open.last_mut() { *t = (*t).min(*level); } } } out.push_str(&item); } for i in (0..open.len()).rev() { out.push_str(""); if i > 0 { out.push_str(""); } } 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); // Preserve traversal order while carrying each group's transform to spread space. // The iterator stack grows with nesting depth, without repeated document lookups. let mut stack = vec![(sp.items.iter(), Affine::IDENTITY)]; while let Some((items, parent)) = stack.last_mut() { let Some(it) = items.next() else { stack.pop(); continue; }; let xf = *parent * it.xf; let children = it.children(); if !children.is_empty() { stack.push((children.iter(), xf)); } if it.hidden { continue; } let block = 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() { continue; } seen_stories.push(tf.story); Block::Story(tf.story) } Content::Graphic(_) => Block::Image(it.id), _ => continue, }; // Transform the path once: rotating a local axis-aligned box again would // enlarge it and give a different top-left from the actual spread geometry. let b = it.path.transformed(xf).bounds().unwrap_or(Rect::ZERO); 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); keyed.push((key, block)); } } let _ = SpreadRef::Doc(0); keyed.sort_by_key(|(k, _)| *k); keyed.into_iter().map(|(_, b)| b).collect() } /// Package path of an asset: `{dir}/{name}-{id}.{ext}`, the name from its original file name /// (lowercase ASCII, so the package stays readable) and the id keeping two assets of the same /// name apart. fn asset_path(dir: &str, a: &designcraft_doc::Asset, mime: &str) -> String { let stem = a.name.rsplit(['/', '\\']).next().unwrap_or(""); let stem = stem.rsplit_once('.').map_or(stem, |(s, _)| s); let name: String = stem.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect(); let name: String = name.split('-').filter(|p| !p.is_empty()).collect::>().join("-").chars().take(40).collect(); if name.is_empty() { format!("{dir}/{}.{}", a.id.0, ext(mime)) } else { format!("{dir}/{name}-{}.{}", a.id.0, ext(mime)) } } 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 document content as HTML, with the files it names. struct Body<'a> { html: String, /// Images, video and sound by package name: (media type, bytes). images: BTreeMap, /// Table of contents: (element id, label, level). toc: Vec<(String, String, u8)>, /// Asset names of the images written without alt text. no_alt: Vec, } /// 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). fn body_html<'a>(doc: &'a Document, epub: bool, img_src: &dyn Fn(&str, &str, &[u8]) -> String) -> Body<'a> { let order = reading_order(doc); let mut writer = StoryWriter::new(doc, epub, Some(img_src)); let mut body = String::new(); let mut toc = toc_entries(doc, &order); for (k, b) in order.iter().enumerate() { match b { // The endnote frame's text is generated from the endnotes, which end the body as a // linked section instead. Block::Story(sid) if doc.endnote_story == Some(*sid) => {} Block::Story(sid) => { let anchor = format!("s{k}"); let _ = write!(body, "
    \n{}
    \n", writer.story(*sid, epub.then_some(anchor.as_str()))); } Block::Image(iid) => { let Some(it) = doc.item(*iid) else { continue }; let Some((markup, media)) = writer.graphic(it) else { continue }; if media { let _ = writeln!(body, "
    {markup}
    "); continue; } // Object Export Options: alignment and a page break before. let eo = &it.export_options; 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=\"{}\"", xml_attr(&css)) }; let _ = writeln!(body, "{markup}
    "); } } } let endnotes = writer.endnotes_section(); if !endnotes.is_empty() { let heading = &doc.endnote_options.heading; if !heading.trim().is_empty() { toc.push(("endnotes".into(), heading.clone(), 1)); } body.push_str(&endnotes); } Body { html: body, images: writer.images, toc, no_alt: writer.no_alt } } /// Placed and anchored images (not video or sound) without alt text, by asset name: they are exported as /// decorative (`alt=""`), which an export can report. pub fn images_without_alt(doc: &Document) -> Vec { body_html(doc, true, &|name, _, _| name.to_string()).no_alt } /// Options for [`export_html`]. #[derive(Clone, Debug, Default)] pub struct HtmlOptions { pub title: Option, pub language: Option, /// Generic CSS family per font family (see [`EpubOptions::generic_families`]). pub generic_families: BTreeMap, } /// 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 language = opts.language.as_deref().unwrap_or("en"); let lang = language_tag(language); let body = body_html(doc, false, &|_, mime, data| format!("data:{mime};base64,{}", base64(data))).html; format!( "\n\n\n\n\n\n{}\n\n\n\n{body}\n\n", xml_text(&title), stylesheet_with(doc, &opts.generic_families, &[]) ) } 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 = identifier(doc, opts, &title); let modified = modified(doc, opts); let Body { html: body, images, toc, .. } = body_html(doc, true, &|name, _, _| name.to_string()); let lang = language_tag(&opts.language); // One file per chapter (split before each h1), so e-readers needn't load the whole book. let chapters: Vec = split_chapters(&body) .into_iter() .map(|part| { format!( "\n\n\n{}\n\n{part}\n\n", xml_text(&title) ) }) .collect(); // The file each navigation target (an element id) ended up in. let mut files: BTreeMap = BTreeMap::new(); for (k, c) in chapters.iter().enumerate() { for rest in c.split(" id=\"").skip(1) { if let Some(id) = rest.split('"').next() { files.entry(id.to_string()).or_insert_with(|| chapter_file(k)); } } } // Note links whose target ended up in another file (endnotes end the book) point at it. let chapters: Vec = chapters.iter().enumerate().map(|(k, c)| link_across_chapters(c, &chapter_file(k), &files)).collect(); let mut nav_items = nav_list(&toc, &files); if nav_items.is_empty() { nav_items = format!("
  • {}
  • ", xml_text(&title)); } let nav = format!( "\n\n\n{}\n\n\n", xml_text(&title), xml_text(opts.toc_title.as_deref().filter(|t| !t.trim().is_empty()).unwrap_or(contents_title(&opts.language))) ); let mut manifest = String::from( "\n\n", ); let mut spine = String::new(); for k in 0..chapters.len() { let item = if k == 0 { "content".to_string() } else { format!("content{}", k + 1) }; let _ = writeln!(manifest, "", chapter_file(k)); let _ = writeln!(spine, ""); } for (k, (name, (mime, _))) in images.iter().enumerate() { let _ = writeln!(manifest, "", xml_attr(name), xml_attr(mime)); } for (k, f) in opts.fonts.iter().enumerate() { let (name, mime) = font_file(k, f); let _ = writeln!(manifest, "", xml_attr(&name)); } // Cover: an image page first in reading order. if let Some(c) = &opts.cover { let (name, mime) = cover_file(c); let _ = writeln!( manifest, "\n" ); } let cover_spine = if opts.cover.is_some() { "\n" } else { "" }; let author = opts.author.as_deref().map(|a| format!("{}", xml_text(a))).unwrap_or_default(); let opf = format!( "\n\n\n{}\n{}\n{lang}{author}\n{modified}\n\n\n\n{manifest}\n\n{cover_spine}{spine}\n\n", xml_text(&id), xml_text(&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_with(doc, &opts.generic_families, &opts.fonts).as_bytes()), ] { z.start_file(name, deflate).map_err(zerr)?; z.write_all(data).map_err(io)?; } for (k, c) in chapters.iter().enumerate() { z.start_file(format!("OEBPS/{}", chapter_file(k)), deflate).map_err(zerr)?; z.write_all(c.as_bytes()).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)?; } for (k, f) in opts.fonts.iter().enumerate() { z.start_file(format!("OEBPS/{}", font_file(k, f).0), deflate).map_err(zerr)?; z.write_all(&f.data).map_err(io)?; } if let Some(c) = &opts.cover { z.start_file(format!("OEBPS/{}", cover_file(c).0), stored).map_err(zerr)?; z.write_all(c).map_err(io)?; let xhtml = format!( "\n\n\n{}\n\"{}\"/\n\n", xml_text(&title), cover_file(c).0, xml_attr(&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::{Affine, Rect, shapes}; use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef, Story, StoryId, Table}; use std::io::Read; use std::sync::Arc; #[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()); } /// #766: anchored graphics are exported where they are anchored (inline in the text, an /// above-line object as a block of its own), and their images go into the package. #[test] fn anchored_objects_are_exported_in_place() { let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 200.0), lid, "AB\nCD", ParaFormat::default()).unwrap(); let aid = AssetId(d.alloc()); d.assets.insert( aid, Arc::new(Asset { page: 0, id: aid, name: "dot.png".into(), mime: "image/png".into(), link: None, data: Arc::new(vec![7]), pixels: Some((1, 1)), ..Default::default() }), ); let graphic = |d: &mut Document, alt: &str| { let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); item.alt_text = alt.into(); item.content = Content::Graphic(Graphic { asset: aid, size: (1.0, 1.0), xf: Affine::IDENTITY, auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], clip: None, wrap: Default::default(), fill: Default::default(), }); item }; let inline = designcraft_doc::AnchoredObject::new(graphic(&mut d, "inline"), designcraft_doc::AnchorPosition::Inline { y_offset: 0.0 }); let above = designcraft_doc::AnchoredObject::new( graphic(&mut d, "above"), designcraft_doc::AnchorPosition::AboveLine { align: designcraft_doc::anchored::AnchorAlign::Center, space_before: 0.0, space_after: 0.0 }, ); let st = d.story_mut(sid).unwrap(); st.insert_object(1, inline); // Byte offsets: "A" (1), the mark (3), "B\nC" (3): the second object goes between C and D. st.insert_object(7, above); // An anchored image without alt text is decorative and reported, like a placed one. let bare = designcraft_doc::AnchoredObject::new(graphic(&mut d, ""), designcraft_doc::AnchorPosition::Inline { y_offset: 0.0 }); let end = d.story(sid).unwrap().text.len(); d.story_mut(sid).unwrap().insert_object(end, bare); assert_eq!(images_without_alt(&d), vec!["dot.png".to_string()]); 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 img = |alt: &str| format!("\"{alt}\"/", aid.0); assert!(html.contains(&format!("A{}B

    ", img("inline"))), "{html}"); assert!(html.contains(&format!("C{}D", img("above"))), "{html}"); assert!(z.by_name(&format!("OEBPS/images/dot-{}.png", aid.0)).is_ok(), "the image is in the package"); let page = export_html(&d, &HtmlOptions::default()); assert!(page.contains("alt=\"inline\"") && page.contains("alt=\"above\""), "{page}"); // story_html on its own still leaves objects out. assert!(!story_html(&d, sid).contains(" = z.file_names().map(str::to_string).collect(); (s, names) }; let id_of = |opf: &str| opf.split("").nth(1).and_then(|r| r.split('<').next()).unwrap_or_default().to_string(); let mut a = Document::new(&NewDocument::default()); a.title = "Same".into(); a.created = 1_700_000_000; let mut b = a.clone(); b.created = 1_700_000_001; let (oa, _) = opf(&a, &EpubOptions::default()); let (oa2, _) = opf(&a, &EpubOptions::default()); let (ob, _) = opf(&b, &EpubOptions::default()); assert!(id_of(&oa).starts_with("urn:uuid:") && id_of(&oa).len() == 45, "{}", id_of(&oa)); assert_eq!(id_of(&oa), id_of(&oa2), "stable from export to export"); assert_ne!(id_of(&oa), id_of(&ob), "not shared by two books with the same title"); // Modified: the given time, else the document's save (or creation) time. assert!(oa.contains("2023-11-14T22:13:20Z"), "{oa}"); let at = EpubOptions { modified: Some(1_791_891_000), ..Default::default() }; assert!(opf(&a, &at).0.contains("2026-10-13T11:30:00Z")); // A JPEG cover is packaged as such. let jpeg = EpubOptions { cover: Some(vec![0xFF, 0xD8, 0xFF, 0xE0, 1, 2]), ..Default::default() }; let (o, names) = opf(&a, &jpeg); assert!(o.contains("href=\"images/cover.jpg\" media-type=\"image/jpeg\" properties=\"cover-image\""), "{o}"); assert!(names.contains(&"OEBPS/images/cover.jpg".to_string()), "{names:?}"); } fn st_typed(d: &mut Document, sid: StoryId) { if let Some(st) = d.story_mut(sid) { st.paras[8].style = "Typed".into(); } } /// #767: consecutive `li` paragraphs share one list (`ol` for a numbered style), and /// consecutive `pre` paragraphs of one style one block with their lines on separate lines. #[test] fn lists_and_preformatted_blocks_are_grouped() { let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); for (name, tag, list) in [ ("Bullet", "li", Some(designcraft_doc::ListType::Bullets)), ("Number", "li", Some(designcraft_doc::ListType::Numbers)), ("Typed", "li", None), ("Code", "pre", None), ] { let para = designcraft_doc::ParaAttrs { list_type: list, ..Default::default() }; d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { name: name.into(), based_on: None, next_style: None, para, chars: CharAttrs::default(), shortcut: String::new(), }); d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new() }); } let text = "Intro\nfirst\nsecond\none\ntwo\nx = 1\ny = 2\nEnd\n• typed"; let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, text, ParaFormat::default()).unwrap(); let st = d.story_mut(sid).unwrap(); for (i, style) in ["Bullet", "Bullet", "Number", "Number", "Code", "Code"].into_iter().enumerate() { st.paras[i + 1].style = style.into(); } let html = story_html(&d, sid); let li = |c: &str, t: &str| format!("
  • {t}
  • "); assert!( html.contains(&format!( "
      \n{}\n{}\n
    \n
      \n{}\n{}\n
    \n", li("bullet", "first"), li("bullet", "second"), li("number", "one"), li("number", "two") )), "{html}" ); assert!(html.contains("
    x = 1\ny = 2
    \n

    End

    "), "{html}"); assert_eq!(html.matches("\n
  • • typed
  • \n\n"), "{html}"); } /// #769, #770: the navigation lists the h1–h3 headings (nested by level) and links to them, /// with its title in the book's language; without headings, each story's first paragraph. #[test] fn navigation_follows_the_headings() { let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() }); let lid = d.default_layer(); for (name, tag) in [("Chapter", "h1"), ("Section", "h2")] { d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { name: name.into(), based_on: None, next_style: None, para: Default::default(), chars: CharAttrs::default(), shortcut: String::new(), }); d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new() }); } let text = "One\nBody\nOne point one\nMore\nTwo\nText"; let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, text, ParaFormat::default()).unwrap(); let st = d.story_mut(sid).unwrap(); for (i, style) in [(0, "Chapter"), (2, "Section"), (4, "Chapter")] { st.paras[i].style = style.into(); } // A story that starts with body text adds nothing to the table of contents. d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 400.0, 300.0, 500.0), lid, "Text after the figure.", ParaFormat::default()).unwrap(); let read = |d: &Document, opts: &EpubOptions, path: &str| { let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap(); let mut s = String::new(); z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); s }; let nl = EpubOptions { language: "nl".into(), ..Default::default() }; let nav = read(&d, &nl, "OEBPS/nav.xhtml"); let link = |f: &str, t: &str, l: &str| format!("{l}"); // Each h1 starts a chapter file (#768). let want = format!( "

    Inhoud

    1. {}
      1. {}
    2. {}
    ", link("content.xhtml", "s0-p0", "One"), link("content.xhtml", "s0-p2", "One point one"), link("content2.xhtml", "s0-p4", "Two") ); assert!(nav.contains(&want), "{nav}"); assert!(!nav.contains("Text after the figure"), "{nav}"); let content = read(&d, &nl, "OEBPS/content.xhtml"); assert!(content.contains("

    One

    ") && content.contains("

    In dit boek

    ")); // Without headings: one entry per story, its first paragraph. let mut plain = Document::new(&NewDocument::default()); let lid = plain.default_layer(); plain.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Opening\nrest", ParaFormat::default()).unwrap(); let nav = read(&plain, &EpubOptions::default(), "OEBPS/nav.xhtml"); assert!(nav.contains(&format!("

    Contents

    1. {}
    ", link("content.xhtml", "s0", "Opening"))), "{nav}"); // A heading shallower than the one before it but deeper than its parent stays under the // parent: h1 A, h3 B, h2 C nest B and C under A. let files: BTreeMap = "ABCD".chars().map(|c| (c.to_string(), c.to_string())).collect(); let e = |t: &str, l: u8| (t.to_string(), t.to_string(), l); assert_eq!( nav_list(&[e("A", 1), e("B", 3), e("C", 2), e("D", 1)], &files), format!( "
  • {}
    1. {}
    2. {}
  • {}
  • ", link("A", "A", "A"), link("B", "B", "B"), link("C", "C", "C"), link("D", "D", "D") ) ); assert_eq!( nav_list(&[e("A", 1), e("B", 2), e("C", 3), e("D", 1)], &files), format!( "
  • {}
    1. {}
      1. {}
  • {}
  • ", link("A", "A", "A"), link("B", "B", "B"), link("C", "C", "C"), link("D", "D", "D") ) ); } /// #768: the book is split into one file per chapter, before each h1, with well-formed /// sections; the spine and manifest list the files in order. #[test] fn chapters_are_split_at_h1() { let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); let lid = d.default_layer(); d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { name: "Chapter".into(), based_on: None, next_style: None, para: Default::default(), chars: CharAttrs::default(), shortcut: String::new(), }); d.styles_mut().export_tags.insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new() }); // A title page without a heading, then a story that opens with a chapter and has a // second one in the middle. d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Title page", ParaFormat::default()).unwrap(); let (_, sid) = d .add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 200.0, 300.0, 400.0), lid, "One\nFirst text\nTwo\nSecond text", ParaFormat::default()) .unwrap(); let st = d.story_mut(sid).unwrap(); st.paras[0].style = "Chapter".into(); st.paras[2].style = "Chapter".into(); let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); let mut read = |path: &str| { let mut s = String::new(); z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); s }; let (c1, c2, c3) = (read("OEBPS/content.xhtml"), read("OEBPS/content2.xhtml"), read("OEBPS/content3.xhtml")); assert!(c1.contains("Title page") && !c1.contains("One"), "{c1}"); assert!(c2.contains("
    \n

    \n

    ").count(), "{c}"); assert!(!c.contains(CHAPTER_MARK)); } let opf = read("OEBPS/content.opf"); assert!(opf.contains("\n\n\n"), "{opf}"); assert!(opf.contains(""), "{last}"); } #[test] fn split_chapters_keeps_sections_well_formed() { let m = CHAPTER_MARK; assert_eq!(split_chapters("
    \n

    a

    \n
    \n"), vec!["
    \n

    a

    \n
    \n"]); assert_eq!( split_chapters(&format!("
    \n{m}

    A

    \n
    \n")), vec!["
    \n

    A

    \n
    \n"] ); assert_eq!( split_chapters(&format!("
    \n

    a

    \n{m}

    B

    \n
    \n")), vec!["
    \n

    a

    \n
    \n", "
    \n

    B

    \n
    \n"] ); assert_eq!(split_chapters(""), vec![String::new()]); } #[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, ..Default::default() }); 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=="); } /// #774: an image without alt text is decorative (`alt=""`) and reported, and package names /// come from the original file names. #[test] fn images_get_readable_names_and_no_file_name_alt() { let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); let place = |d: &mut Document, name: &str, mime: &str, alt: &str, y: f64| { let aid = AssetId(d.alloc()); d.assets.insert( aid, Arc::new(Asset { page: 0, id: aid, name: name.into(), mime: mime.into(), link: None, data: Arc::new(vec![1]), pixels: Some((1, 1)), ..Default::default() }), ); let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, y, 72.0, y + 36.0))); item.alt_text = alt.into(); item.content = Content::Graphic(Graphic { asset: aid, size: (1.0, 1.0), xf: Affine::IDENTITY, auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], clip: None, wrap: Default::default(), fill: Default::default(), }); d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); aid.0 }; let a = place(&mut d, "/tmp/Anim Frame (1).GIF", "image/gif", "", 36.0); let b = place(&mut d, "photo.jpg", "image/jpeg", "A heron", 120.0); let c = place(&mut d, "???.png", "image/png", "Mark", 200.0); assert_eq!(images_without_alt(&d), vec!["/tmp/Anim Frame (1).GIF".to_string()]); let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); let mut x = String::new(); z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut x).unwrap(); assert!(x.contains(&format!("\"\"/")), "{x}"); assert!(x.contains(&format!("\"A")), "{x}"); assert!(x.contains(&format!("\"Mark\"/")), "{x}"); assert!(z.by_name(&format!("OEBPS/images/anim-frame-1-{a}.gif")).is_ok()); } /// #775: the generic fallback follows the font, and embedded fonts are packaged and declared. #[test] fn generic_fallbacks_and_embedded_fonts() { let none = BTreeMap::new(); for (family, want) in [ ("Source Serif 4", "serif"), ("Source Sans 3", "sans-serif"), ("Microsoft Sans Serif", "sans-serif"), ("JetBrains Mono", "monospace"), ("Courier New", "monospace"), ("Franklin Gothic", "sans-serif"), ("Garamond", "serif"), ] { assert_eq!(generic_family(family, &none), want, "{family}"); } let known: BTreeMap = [("Montserrat".to_string(), "sans-serif".to_string()), ("Odd".to_string(), "nonsense".to_string())].into(); assert_eq!(generic_family("Montserrat", &known), "sans-serif"); assert_eq!(generic_family("Odd", &known), "serif", "only CSS generic families are used"); assert_eq!(css_weight_style("Semibold Italic"), (600, "italic")); assert_eq!(css_weight_style("Bold"), (700, "normal")); assert_eq!(css_weight_style("Extra Bold"), (800, "normal")); assert_eq!(css_weight_style("Regular"), (400, "normal")); assert_eq!(css_weight_style("Light"), (300, "normal")); assert_eq!(css_weight_style("Thin Oblique"), (100, "italic")); assert_eq!(css_weight_style("Black"), (900, "normal")); 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(); if let Some(basic) = d.styles_mut().paragraph.iter_mut().find(|s| s.name == story::BASIC_PARAGRAPH) { basic.chars.font_family = Some("Montserrat".into()); basic.chars.font_style = Some("Bold Italic".into()); } assert_eq!(stylesheet_fonts(&d), vec![("Montserrat".to_string(), "Bold Italic".to_string())]); let opts = EpubOptions { generic_families: known, fonts: vec![EmbeddedFont { family: "Montserrat".into(), style: "Bold Italic".into(), data: b"OTTO font".to_vec() }], ..Default::default() }; let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &opts).unwrap())).unwrap(); let mut read = |path: &str| { let mut s = String::new(); z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); s }; let css = read("OEBPS/style.css"); assert!( css.starts_with( "@font-face { font-family: \"Montserrat\"; src: url(\"fonts/0-montserrat-bold-italic.otf\"); font-weight: 700; font-style: italic; }" ), "{css}" ); assert!(css.contains("font-family: \"Montserrat\", sans-serif;"), "{css}"); let opf = read("OEBPS/content.opf"); assert!(opf.contains(""), "{opf}"); assert_eq!(read("OEBPS/fonts/0-montserrat-bold-italic.otf"), "OTTO font"); // HTML takes the generic families too, and embeds nothing. let html = export_html(&d, &HtmlOptions { generic_families: opts.generic_families.clone(), ..Default::default() }); assert!(html.contains("\"Montserrat\", sans-serif;") && !html.contains("@font-face"), "{html}"); } #[test] fn metadata_is_serialized_for_its_output_context() { let mut d = Document::new(&NewDocument::default()); d.title = "Field Notes & ".into(); let lid = d.default_layer(); let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "A&B \"D\"", ParaFormat::default()).unwrap(); d.styles_mut() .export_tags .insert(format!("p:{}", story::BASIC_PARAGRAPH), designcraft_doc::ExportTag { tag: "h2".into(), class: "Primary \"Copy\"".into() }); if let Some(basic) = d.styles_mut().paragraph.iter_mut().find(|s| s.name == story::BASIC_PARAGRAPH) { basic.chars.font_family = Some("Quoted \"Serif\" ".into()); } let aid = AssetId(d.alloc()); d.assets.insert( aid, Arc::new(Asset { id: aid, name: "Cover & Notes".into(), mime: "image/png; profile=screen".into(), data: Arc::new(vec![1]), pixels: Some((1, 1)), ..Default::default() }), ); let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, 120.0, 72.0, 156.0))); item.alt_text = "Cover \"A\" & B".into(); item.content = Content::Graphic(Graphic { asset: aid, size: (1.0, 1.0), xf: Affine::IDENTITY, clip: None, auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], wrap: Default::default(), fill: Default::default(), }); d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); let story = story_html(&d, sid); assert!(story.contains("

    A&B <C> "D"

    ")); let html = export_html(&d, &HtmlOptions { title: None, language: Some("English (US)".into()), ..Default::default() }); assert!(html.contains("")); assert!(html.contains("Field Notes & <Edition>")); assert!(html.contains("alt=\"Cover "A" & B\"")); assert!(html.contains("src=\"data:image/png;base64,AQ==\"")); assert!(!html.contains("image/png; profile=screen")); assert!(!stylesheet(&d).contains("")); assert!(stylesheet(&d).contains("\\3c Alt\\3e ")); let bytes = export_epub( &d, &EpubOptions { title: None, author: Some("A & B".into()), language: "English (US)".into(), identifier: Some("notes&edition".into()), cover: None, ..Default::default() }, ) .unwrap(); let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); let mut opf = String::new(); z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap(); assert!(opf.contains("xml:lang=\"und\"")); assert!(opf.contains("notes&edition")); assert!(opf.contains("A & B")); assert!(opf.contains("media-type=\"image/png\"")); } /// "Body¹ text", a 2×2 table, then "Closing" with an endnote. fn tables_and_notes() -> Document { let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 400.0), lid, "Body text", ParaFormat::default()).unwrap(); let st = d.story_mut(sid).unwrap(); st.insert_note(4, "Foot note text", ParaFormat::default()); let mut t = Table::new(1, 2, 2, 0, 0, 200.0); for (r, c, s) in [(0, 0, "Cell A1"), (0, 1, "Cell B1"), (1, 0, "Cell A2"), (1, 1, "Cell B2")] { t.cell_mut(r, c).unwrap().text = Story::with_text(StoryId(0), s, ParaFormat::default()); } let end = st.len(); st.insert_table(end, t); let end = st.len(); st.insert(end, "Closing"); let end = st.len(); st.insert_endnote(end, "End note text", ParaFormat::default()); d } #[test] fn tables_footnotes_and_endnotes_are_exported() { let d = tables_and_notes(); 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 (t0, t1) = (html.find("").unwrap(), html.find("
    ").unwrap()); for cell in ["Cell A1", "Cell B1", "Cell A2", "Cell B2"] { let at = html.find(&format!("

    {cell}

    \n")).unwrap_or_else(|| panic!("{cell} in a cell:\n{html}")); assert!(t0 < at && at < t1); } assert_eq!(html.matches("").count(), 2); let noteref = "Body1 text"; assert!(html.contains(noteref), "footnote reference:\n{html}"); let aside = html.find("").unwrap(); assert!(html[aside..aside_end].contains("1 Foot note text")); assert!(aside < t0, "the footnote follows its paragraph"); assert!(html.contains("Closing1")); let section = html.find("
    ").unwrap(); assert!(section > html.find("Closing").unwrap()); assert!(html[section..].contains("
  • ")); assert!(html[section..].contains("1 End note text")); let mut nav = String::new(); z.by_name("OEBPS/nav.xhtml").unwrap().read_to_string(&mut nav).unwrap(); assert!(nav.contains("href=\"content.xhtml#endnotes\"")); let page = export_html(&d, &HtmlOptions::default()); assert!(page.contains("