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; if it.hidden { continue; } let children = it.children(); if !children.is_empty() { stack.push((children.iter(), xf)); } 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); // `Document::item` searches every spread: one pre-order index for all the placed graphics. let mut items: std::collections::HashMap = std::collections::HashMap::new(); if order.iter().any(|b| matches!(b, Block::Image(_))) { for sp in doc.spreads.iter().map(|s| &**s).chain(doc.parents.iter().map(|s| &**s)) { for it in &sp.items { it.walk(&mut |i| { items.entry(i.id).or_insert(i); }); } } } 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) = items.get(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, } /// Bytes of data URIs one HTML export may write (a document can place one large asset many times). const MAX_DATA_URI_BYTES: usize = 512 * 1024 * 1024; /// 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); // Each placement embeds its own copy of the asset; past the budget a placement gets no source. let spent = std::cell::Cell::new(0usize); let body = body_html(doc, false, None, &|_, mime, data| { let n = data.len().div_ceil(3).saturating_mul(4).saturating_add(mime.len() + 13); if n > MAX_DATA_URI_BYTES - spent.get() { return String::new(); } spent.set(spent.get() + n); 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 }