diff --git a/crates/epub/src/chapters.rs b/crates/epub/src/chapters.rs new file mode 100644 index 000000000..ede96f390 --- /dev/null +++ b/crates/epub/src/chapters.rs @@ -0,0 +1,154 @@ +use super::css::{export_mapping, para_classes}; +use super::html::Block; +use super::markup::xml_text; +use super::*; + +/// Whether an EPUB chapter file starts before a paragraph of `style` (Split Document): never +/// when it's off, before each paragraph of the one style it names, or before each paragraph of +/// a style whose Export Tagging has Split Document on. +pub(crate) fn starts_chapter(split: &EpubSplit, st: &Styles, style: &str) -> bool { + match split { + EpubSplit::Off => false, + EpubSplit::Style(name) => name == style, + EpubSplit::ExportTags => st.export_tag(style, false).is_some_and(|e| e.split), + } +} + +/// Where the EPUB body splits into chapter files (see [`starts_chapter`]). +pub(crate) 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. +pub(crate) 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. +pub(crate) 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`, …. +pub(crate) 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). +pub(crate) 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). +pub(crate) 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. +pub(crate) 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 +} diff --git a/crates/epub/src/css.rs b/crates/epub/src/css.rs new file mode 100644 index 000000000..526b10a40 --- /dev/null +++ b/crates/epub/src/css.rs @@ -0,0 +1,226 @@ +use super::fonts::{EmbeddedFont, css_weight_style, font_file, generic_family}; +use super::markup::{css_string, slug, valid_xml_char}; +use super::*; + +/// 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 +} + +pub(crate) fn para_classes(st: &Styles) -> BTreeMap<&str, String> { + class_names(st.paragraph.iter().map(|s| s.name.as_str())) +} + +pub(crate) 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}")) +} + +pub(crate) 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; ") + } + _ => {} + } + // One declaration: a later `text-decoration` would replace the earlier one. + match (c.underline, c.strikethrough) { + (true, true) => out.push_str("text-decoration: underline line-through; "), + (true, false) => out.push_str("text-decoration: underline; "), + (false, true) => out.push_str("text-decoration: line-through; "), + (false, false) => {} + } + 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). +pub(crate) 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 +} + +pub(crate) 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. +pub(crate) 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) +} diff --git a/crates/epub/src/fonts.rs b/crates/epub/src/fonts.rs new file mode 100644 index 000000000..ce601d070 --- /dev/null +++ b/crates/epub/src/fonts.rs @@ -0,0 +1,83 @@ +use super::*; + +/// 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. +pub(crate) 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). +pub(crate) 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}`). +pub(crate) 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) +} diff --git a/crates/epub/src/html.rs b/crates/epub/src/html.rs new file mode 100644 index 000000000..ce9d7176c --- /dev/null +++ b/crates/epub/src/html.rs @@ -0,0 +1,212 @@ +use super::chapters::toc_entries; +use super::css::stylesheet_with; +use super::markup::{language_tag, xml_attr, xml_text}; +use super::story_writer::StoryWriter; +use super::*; + +/// 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. +pub(crate) 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. +pub(crate) struct Body<'a> { + pub(crate) html: String, + /// Images, video and sound by package name: (media type, bytes). + pub(crate) images: BTreeMap, + /// Table of contents: (element id, label, level). + pub(crate) 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). +pub(crate) fn body_html<'a>(doc: &'a Document, epub: bool, split: Option<&EpubSplit>, 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)); + if let Some(split) = split { + writer.split = split.clone(); + } + 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, None, &|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.metadata_title().to_string()); + let language = opts.language.as_deref().unwrap_or("en"); + let lang = language_tag(language); + let body = body_html(doc, false, None, &|_, 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, &[]) + ) +} + +pub(crate) 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 +} diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 025cdbf3e..c102a54fe 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -19,6 +19,31 @@ use designcraft_doc::{ ParaProps, SpreadRef, Story, StoryId, Styles, Table, story, }; +mod chapters; +mod css; +mod fonts; +mod html; +mod markup; +mod package; +mod story_writer; + +#[cfg(test)] +use chapters::{CHAPTER_MARK, link_across_chapters, nav_list, split_chapters}; +#[cfg(test)] +use css::char_css; +pub use css::stylesheet; +pub use fonts::{EmbeddedFont, stylesheet_fonts}; +#[cfg(test)] +use fonts::{css_weight_style, generic_family}; +#[cfg(test)] +use html::base64; +pub use html::{Block, HtmlOptions, export_html, images_without_alt, reading_order}; +pub use markup::{EpubError, slug}; +#[cfg(test)] +use package::iso8601; +pub use package::{FixedPage, contents_title, export_epub, export_fixed_epub}; +pub use story_writer::story_html; + #[derive(Clone, Debug)] pub struct EpubOptions { pub title: Option, @@ -42,242 +67,6 @@ pub struct EpubOptions { pub split: 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`. -/// A time outside chrono's year range still converts: the calendar repeats every 400 years, so the -/// instant is moved into range by whole cycles and only the year is carried (matches the old -/// `civil_from_days`), rather than resetting to the epoch. -fn iso8601(t: i64) -> String { - let (y, dt) = designcraft_doc::time::utc_parts(t, 0); - // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. - format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%SZ")) -} - -/// 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 title = opts.title.clone().unwrap_or_else(|| doc.metadata_title().to_string()); - 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 rendition = format!( - "\npre-paginated\nauto\n" - ); - let head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref().or(doc.metadata_author()), modified: &modified }; - let opf = package_opf(&head, " prefix=\"rendition: http://www.idpf.org/vocab/rendition/#\"", &rendition, &manifest, &spine); - let entries = files.iter().map(|(name, data)| (name.clone(), data.as_slice(), name.ends_with(".png"))); - write_package(&opf, &nav, entries) -} - -/// The package metadata both EPUB flavours share. -struct PackageHead<'a> { - lang: &'a str, - id: &'a str, - title: &'a str, - author: Option<&'a str>, - modified: &'a str, -} - -/// The OPF package document. `prefix` goes on `` (with its leading space), `meta` after -/// `dcterms:modified` (with its leading newline); `manifest` and `spine` are the item lines. -fn package_opf(h: &PackageHead, prefix: &str, meta: &str, manifest: &str, spine: &str) -> String { - let PackageHead { lang, id, title, author, modified } = h; - let author = author.map(|a| format!("{}", xml_text(a))).unwrap_or_default(); - format!( - "\n\n\n{}\n{}\n{lang}{author}\n{modified}{meta}\n\n\n\n{manifest}\n\n{spine}\n\n", - xml_text(id), - xml_text(title) - ) -} - -/// Zips an EPUB: the stored `mimetype`, `container.xml`, the OPF and nav, then `entries` in order -/// as (path, bytes, stored uncompressed). -fn write_package<'a>(opf: &str, nav: &str, entries: impl IntoIterator) -> Result, EpubError> { - let io = |e: std::io::Error| EpubError(e.to_string()); - let zerr = |e: zip::result::ZipError| EpubError(e.to_string()); - 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, store) in entries { - z.start_file(name, if store { stored } else { deflate }).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 { @@ -295,9 +84,6 @@ impl Default for EpubOptions { } } -#[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) @@ -305,1203 +91,5 @@ impl std::fmt::Display for EpubError { } 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; ") - } - _ => {} - } - // One declaration: a later `text-decoration` would replace the earlier one. - match (c.underline, c.strikethrough) { - (true, true) => out.push_str("text-decoration: underline line-through; "), - (true, false) => out.push_str("text-decoration: underline; "), - (false, true) => out.push_str("text-decoration: line-through; "), - (false, false) => {} - } - 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, - /// Where EPUB content files start (see [`starts_chapter`]). - split: EpubSplit, -} - -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, - split: doc.settings.epub_split.clone(), - } - } - - 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; - // Chapter files start only at top-level paragraphs, after any open list or - // preformatted block is closed, so each file stays balanced. - if depth == 0 && self.heading_ids.is_some() && starts_chapter(&self.split, &doc.styles, &pf.style) { - close_blocks(&mut list, &mut pre, out); - out.push_str(CHAPTER_MARK); - } - 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; - } - 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 markup for one run's text `raw` (starting at byte `a` of the story): anchored objects - /// and notes where their marks are. - fn run_markup(&mut self, st: &Story, raw: &str, a: usize, depth: usize, after: &mut String) -> String { - let doc = self.doc; - 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())); - t - } - - /// 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 t = self.run_markup(st, raw, a, depth, after); - 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 - } - - /// One table cell with its row and column spans, as `th` or `td`. - fn table_cell(&mut self, text: &Story, cell_tag: &str, (rs, cs): (usize, usize), depth: usize, out: &mut String) { - 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(text, depth, "", out); - let _ = writeln!(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; - } - } - } - self.table_cell(&cell.text, cell_tag, (rs, cs), depth, 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) -} - -/// Whether an EPUB chapter file starts before a paragraph of `style` (Split Document): never -/// when it's off, before each paragraph of the one style it names, or before each paragraph of -/// a style whose Export Tagging has Split Document on. -fn starts_chapter(split: &EpubSplit, st: &Styles, style: &str) -> bool { - match split { - EpubSplit::Off => false, - EpubSplit::Style(name) => name == style, - EpubSplit::ExportTags => st.export_tag(style, false).is_some_and(|e| e.split), - } -} - -/// Where the EPUB body splits into chapter files (see [`starts_chapter`]). -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, split: Option<&EpubSplit>, 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)); - if let Some(split) = split { - writer.split = split.clone(); - } - 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, None, &|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.metadata_title().to_string()); - let language = opts.language.as_deref().unwrap_or("en"); - let lang = language_tag(language); - let body = body_html(doc, false, None, &|_, 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 title = opts.title.clone().unwrap_or_else(|| doc.metadata_title().to_string()); - let id = identifier(doc, opts, &title); - let modified = modified(doc, opts); - let Body { html: body, images, toc, .. } = body_html(doc, true, opts.split.as_ref(), &|name, _, _| name.to_string()); - let lang = language_tag(&opts.language); - // One file per chapter (see `starts_chapter`); one file in all when Split Document is off. - 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 head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref().or(doc.metadata_author()), modified: &modified }; - let opf = package_opf(&head, "", "", &manifest, &format!("{cover_spine}{spine}")); - let css = stylesheet_with(doc, &opts.generic_families, &opts.fonts); - let mut entries: Vec<(String, &[u8], bool)> = vec![("OEBPS/style.css".into(), css.as_bytes(), false)]; - entries.extend(chapters.iter().enumerate().map(|(k, c)| (format!("OEBPS/{}", chapter_file(k)), c.as_bytes(), false))); - entries.extend(images.iter().map(|(name, (_, data))| (format!("OEBPS/{name}"), *data, true))); - entries.extend(opts.fonts.iter().enumerate().map(|(k, f)| (format!("OEBPS/{}", font_file(k, f).0), f.data.as_slice(), false))); - let cover_xhtml = opts.cover.as_ref().map(|c| { - format!( - "\n\n\n{}\n\"{}\"/\n\n", - xml_text(&title), - cover_file(c).0, - xml_attr(&title) - ) - }); - if let (Some(c), Some(xhtml)) = (&opts.cover, &cover_xhtml) { - entries.push((format!("OEBPS/{}", cover_file(c).0), c.as_slice(), true)); - entries.push(("OEBPS/cover.xhtml".into(), xhtml.as_bytes(), false)); - } - write_package(&opf, &nav, entries) -} - #[cfg(test)] mod tests; diff --git a/crates/epub/src/markup.rs b/crates/epub/src/markup.rs new file mode 100644 index 000000000..3697c458f --- /dev/null +++ b/crates/epub/src/markup.rs @@ -0,0 +1,104 @@ +use std::fmt::Write as _; + +#[derive(Debug)] +pub struct EpubError(pub String); + +pub(crate) 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 +} + +pub(crate) fn xml_text(s: &str) -> String { + escape_markup(s, false, false) +} + +pub(crate) 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. +pub(crate) fn story_markup(s: &str) -> String { + escape_markup(s, false, true) +} + +/// A conservative BCP 47 subset suitable for both HTML and EPUB language attributes. +pub(crate) 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. +pub(crate) 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. +pub(crate) 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 + } +} diff --git a/crates/epub/src/package.rs b/crates/epub/src/package.rs new file mode 100644 index 000000000..fff4fd6fe --- /dev/null +++ b/crates/epub/src/package.rs @@ -0,0 +1,244 @@ +use super::chapters::{chapter_file, link_across_chapters, nav_list, split_chapters}; +use super::css::stylesheet_with; +use super::fonts::font_file; +use super::html::{Body, body_html}; +use super::markup::{EpubError, language_tag, story_markup, xml_attr, xml_text}; +use super::*; + +/// An ISO 8601 UTC timestamp (`2026-10-13T09:30:00Z`) for Unix seconds `t`. +/// A time outside chrono's year range still converts: the calendar repeats every 400 years, so the +/// instant is moved into range by whole cycles and only the year is carried (matches the old +/// `civil_from_days`), rather than resetting to the epoch. +pub(crate) fn iso8601(t: i64) -> String { + let (y, dt) = designcraft_doc::time::utc_parts(t, 0); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%SZ")) +} + +/// 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 title = opts.title.clone().unwrap_or_else(|| doc.metadata_title().to_string()); + 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 rendition = format!( + "\npre-paginated\nauto\n" + ); + let head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref().or(doc.metadata_author()), modified: &modified }; + let opf = package_opf(&head, " prefix=\"rendition: http://www.idpf.org/vocab/rendition/#\"", &rendition, &manifest, &spine); + let entries = files.iter().map(|(name, data)| (name.clone(), data.as_slice(), name.ends_with(".png"))); + write_package(&opf, &nav, entries) +} + +/// The package metadata both EPUB flavours share. +struct PackageHead<'a> { + lang: &'a str, + id: &'a str, + title: &'a str, + author: Option<&'a str>, + modified: &'a str, +} + +/// The OPF package document. `prefix` goes on `` (with its leading space), `meta` after +/// `dcterms:modified` (with its leading newline); `manifest` and `spine` are the item lines. +fn package_opf(h: &PackageHead, prefix: &str, meta: &str, manifest: &str, spine: &str) -> String { + let PackageHead { lang, id, title, author, modified } = h; + let author = author.map(|a| format!("{}", xml_text(a))).unwrap_or_default(); + format!( + "\n\n\n{}\n{}\n{lang}{author}\n{modified}{meta}\n\n\n\n{manifest}\n\n{spine}\n\n", + xml_text(id), + xml_text(title) + ) +} + +/// Zips an EPUB: the stored `mimetype`, `container.xml`, the OPF and nav, then `entries` in order +/// as (path, bytes, stored uncompressed). +fn write_package<'a>(opf: &str, nav: &str, entries: impl IntoIterator) -> Result, EpubError> { + let io = |e: std::io::Error| EpubError(e.to_string()); + let zerr = |e: zip::result::ZipError| EpubError(e.to_string()); + 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, store) in entries { + z.start_file(name, if store { stored } else { deflate }).map_err(zerr)?; + z.write_all(data).map_err(io)?; + } + z.finish().map_err(zerr)?; + } + Ok(buf.into_inner()) +} + +/// Export the document as a reflowable EPUB 3. +pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubError> { + let title = opts.title.clone().unwrap_or_else(|| doc.metadata_title().to_string()); + let id = identifier(doc, opts, &title); + let modified = modified(doc, opts); + let Body { html: body, images, toc, .. } = body_html(doc, true, opts.split.as_ref(), &|name, _, _| name.to_string()); + let lang = language_tag(&opts.language); + // One file per chapter (see `starts_chapter`); one file in all when Split Document is off. + 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 head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref().or(doc.metadata_author()), modified: &modified }; + let opf = package_opf(&head, "", "", &manifest, &format!("{cover_spine}{spine}")); + let css = stylesheet_with(doc, &opts.generic_families, &opts.fonts); + let mut entries: Vec<(String, &[u8], bool)> = vec![("OEBPS/style.css".into(), css.as_bytes(), false)]; + entries.extend(chapters.iter().enumerate().map(|(k, c)| (format!("OEBPS/{}", chapter_file(k)), c.as_bytes(), false))); + entries.extend(images.iter().map(|(name, (_, data))| (format!("OEBPS/{name}"), *data, true))); + entries.extend(opts.fonts.iter().enumerate().map(|(k, f)| (format!("OEBPS/{}", font_file(k, f).0), f.data.as_slice(), false))); + let cover_xhtml = opts.cover.as_ref().map(|c| { + format!( + "\n\n\n{}\n\"{}\"/\n\n", + xml_text(&title), + cover_file(c).0, + xml_attr(&title) + ) + }); + if let (Some(c), Some(xhtml)) = (&opts.cover, &cover_xhtml) { + entries.push((format!("OEBPS/{}", cover_file(c).0), c.as_slice(), true)); + entries.push(("OEBPS/cover.xhtml".into(), xhtml.as_bytes(), false)); + } + write_package(&opf, &nav, entries) +} diff --git a/crates/epub/src/story_writer.rs b/crates/epub/src/story_writer.rs new file mode 100644 index 000000000..aed443d0b --- /dev/null +++ b/crates/epub/src/story_writer.rs @@ -0,0 +1,439 @@ +use super::chapters::{CHAPTER_MARK, heading_level, starts_chapter}; +use super::css::{char_classes, export_mapping, override_css, para_classes}; +use super::html::asset_path; +use super::markup::{safe_mime, story_markup, xml_attr, xml_text}; +use super::*; + +/// 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. +pub(crate) 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. + pub(crate) images: BTreeMap, + /// Asset names of the images written without alt text (decorative). + pub(crate) 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, + /// Where EPUB content files start (see [`starts_chapter`]). + pub(crate) split: EpubSplit, +} + +impl<'a, 'f> StoryWriter<'a, 'f> { + pub(crate) 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, + split: doc.settings.epub_split.clone(), + } + } + + 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. + pub(crate) 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; + // Chapter files start only at top-level paragraphs, after any open list or + // preformatted block is closed, so each file stays balanced. + if depth == 0 && self.heading_ids.is_some() && starts_chapter(&self.split, &doc.styles, &pf.style) { + close_blocks(&mut list, &mut pre, out); + out.push_str(CHAPTER_MARK); + } + 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; + } + 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 markup for one run's text `raw` (starting at byte `a` of the story): anchored objects + /// and notes where their marks are. + fn run_markup(&mut self, st: &Story, raw: &str, a: usize, depth: usize, after: &mut String) -> String { + let doc = self.doc; + 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())); + t + } + + /// 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 t = self.run_markup(st, raw, a, depth, after); + 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. + pub(crate) 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). + pub(crate) 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 + } + + /// One table cell with its row and column spans, as `th` or `td`. + fn table_cell(&mut self, text: &Story, cell_tag: &str, (rs, cs): (usize, usize), depth: usize, out: &mut String) { + 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(text, depth, "", out); + let _ = writeln!(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; + } + } + } + self.table_cell(&cell.text, cell_tag, (rs, cs), depth, 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) +}