Files
designcraft/crates/epub/src/lib.rs
T
aiongg 6e4f122b8c Merge PR #1100: Placed greyscale and bitmap images take the swatch applied to them (IDML, screen, PDF)
Graphic literals (cli perf, doc assets/item, engine datamerge/export/file/links/mathexpr/media/table/sample, epub, format, pdf, pptx, render, tools, ui-egui properties, and tests): name the branch's clip and wrap (#561, #381) and the PR's fill.
crates/doc/src/item.rs: Graphic keeps wrap and gains fill.
crates/idml/src/import.rs: read the image swatch, then the branch's styled FrameFittingOption lookup; the graphic keeps its wrap.
crates/idml/src/tests/assets.rs: keep both new tests.
crates/render/src/lib.rs: keep #362's crop-box sizing of placed PDFs and colour the resulting pixmap.
crates/render/tests/colorize.rs, crates/epub/tests/grouped_reading_order.rs, crates/compose/src/tests.rs: literals name the branch's Asset and Graphic fields.
crates/pdf/src/export.rs: the soft mask interpolates by the standard's raster mode (the branch has no per-exporter interpolate switch).
docs/gaps.md, docs/target-app-parity.md: status lines list both sides' changes; gap text keeps the clipping-path items and adds the image swatch.
2026-10-16 04:07:57 +00:00

2231 lines
106 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Reflowable EPUB 3 export (File → Export → EPUB (Reflowable)).
//!
//! Stories become XHTML in reading order (page, then top-to-bottom, left-to-right of their first
//! frame); paragraph styles become CSS classes (`p.<slug>`), character styles become `span`
//! classes, local overrides become inline styles. Placed graphics become `<figure>` elements at
//! their position in the reading order. Parent-page items (folios, running heads) are skipped.
//! Tables become `<table>` elements, footnotes linked `aside`s after the paragraph that refers to
//! them, and endnotes a linked section at the end.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::io::Write;
use designcraft_doc::geom::{Affine, Rect};
use designcraft_doc::{
Align, AnchorPosition, AnchoredObject, CharAttrs, CharProps, Content, Document, ENDNOTE_REF, FOOTNOTE_REF, Item, ItemId, OBJECT_MARK, ParaProps,
SpreadRef, Story, StoryId, Styles, Table, story,
};
#[derive(Clone, Debug)]
pub struct EpubOptions {
pub title: Option<String>,
pub author: Option<String>,
pub language: String,
/// Stable identifier (urn:uuid:…); derived from the title when absent.
pub identifier: Option<String>,
/// Cover image (PNG or JPEG), shown first and marked as the book's cover.
pub cover: Option<Vec<u8>>,
/// Generic CSS family (`serif`, `sans-serif`, `monospace`, `cursive`, `fantasy`) per font
/// family, from the fonts' classification; families not in it are guessed from their name.
pub generic_families: BTreeMap<String, String>,
/// Font files packaged with the book and declared with `@font-face`.
pub fonts: Vec<EmbeddedFont>,
/// `dcterms:modified` (Unix seconds, UTC); the document's last save, else its creation, when
/// absent.
pub modified: Option<i64>,
/// Title of the navigation (table of contents); from `language` when absent.
pub toc_title: Option<String>,
}
/// 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<u8>,
}
/// 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<String, String>) -> &'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::<Vec<_>>().join("-");
(format!("fonts/{k}-{name}.{ext}"), mime)
}
/// An ISO 8601 UTC timestamp (`2026-10-13T09:30:00Z`) for Unix seconds `t`.
fn iso8601(t: i64) -> String {
let (days, secs) = (t.div_euclid(86_400), t.rem_euclid(86_400));
// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!("{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", secs / 3600, secs % 3600 / 60, secs % 60)
}
/// The package's `dcterms:modified`: the given time, else the document's last save or creation.
fn modified(doc: &Document, opts: &EpubOptions) -> String {
let t = opts.modified.or((doc.modified > 0).then_some(doc.modified)).or((doc.created > 0).then_some(doc.created));
t.map_or_else(|| "2026-01-01T00:00:00Z".to_string(), iso8601)
}
/// The book's identifier: the given one, else a `urn:uuid` derived from the title and the
/// document's creation time, so a book keeps its identifier from export to export and two books
/// with the same title don't share one.
fn identifier(doc: &Document, opts: &EpubOptions, title: &str) -> String {
if let Some(id) = opts.identifier.clone().filter(|i| !i.trim().is_empty()) {
return id;
}
// Two FNV-1a 64-bit hashes (different offsets) as 128 bits, shaped as a version-5-style UUID.
let seed = format!("{title}\n{}", doc.created);
let fnv = |offset: u64| seed.bytes().fold(offset, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3));
let (a, b) = (fnv(0xcbf2_9ce4_8422_2325), fnv(0x84222325_cbf29ce4));
let hi = (a & 0xffff_ffff_ffff_0fff) | 0x5000;
let lo = (b & 0x3fff_ffff_ffff_ffff) | 0x8000_0000_0000_0000;
format!("urn:uuid:{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", hi >> 32, (hi >> 16) & 0xffff, hi & 0xffff, lo >> 48, lo & 0xffff_ffff_ffff)
}
/// Package name and media type of a cover image: JPEG by its signature, else PNG.
fn cover_file(data: &[u8]) -> (&'static str, &'static str) {
if data.starts_with(&[0xFF, 0xD8, 0xFF]) { ("images/cover.jpg", "image/jpeg") } else { ("images/cover.png", "image/png") }
}
/// The table of contents' title in `language` (a BCP 47 tag or a language name's code), English
/// for languages without one here.
pub fn contents_title(language: &str) -> &'static str {
let code = language.split(['-', '_']).next().unwrap_or("").to_ascii_lowercase();
match code.as_str() {
"nl" => "Inhoud",
"de" => "Inhalt",
"fr" => "Table des matières",
"es" | "pt" => "Índice",
"it" => "Indice",
"ja" => "目次",
"zh" => "目录",
_ => "Contents",
}
}
/// A page of a fixed-layout EPUB: its image (PNG), size in points and text (kept for search,
/// read-aloud and accessibility).
#[derive(Clone, Debug)]
pub struct FixedPage {
pub png: Vec<u8>,
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<Vec<u8>, EpubError> {
let io = |e: std::io::Error| EpubError(e.to_string());
let zerr = |e: zip::result::ZipError| EpubError(e.to_string());
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let lang = language_tag(&opts.language);
let id = identifier(doc, opts, &title);
let modified = modified(doc, opts);
let mut manifest = String::from("<item id=\"nav\" href=\"nav.xhtml\" media-type=\"application/xhtml+xml\" properties=\"nav\"/>\n");
let mut spine = String::new();
let mut files: Vec<(String, Vec<u8>)> = 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!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xml:lang=\"{lang}\" lang=\"{lang}\">\n<head><meta charset=\"utf-8\"/><meta name=\"viewport\" content=\"width={w}, height={h}\"/><title>{} — {n}</title>\n<style>body{{margin:0;width:{w}px;height:{h}px;position:relative}} img{{position:absolute;left:0;top:0;width:{w}px;height:{h}px}} .t{{position:absolute;left:0;top:0;width:{w}px;color:transparent;font-size:1px;overflow:hidden}}</style></head>\n<body><img src=\"pages/p{n}.png\" alt=\"\"/><div class=\"t\">{}</div></body>\n</html>\n",
xml_text(&title),
story_markup(&p.text).replace('\n', "<br/>")
);
let _ = writeln!(
manifest,
"<item id=\"page{n}\" href=\"page{n}.xhtml\" media-type=\"application/xhtml+xml\"/>\n<item id=\"pimg{n}\" href=\"pages/p{n}.png\" media-type=\"image/png\"{}/>",
if n == 1 { " properties=\"cover-image\"" } else { "" }
);
let _ = writeln!(spine, "<itemref idref=\"page{n}\"/>");
let _ = write!(nav_items, "<li><a href=\"page{n}.xhtml\">{n}</a></li>");
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!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\" xml:lang=\"{lang}\">\n<head><meta charset=\"utf-8\"/><title>{}</title></head>\n<body><nav epub:type=\"page-list\" id=\"toc\"><h1>Pages</h1><ol>{nav_items}</ol></nav></body>\n</html>\n",
xml_text(&title)
);
let author = opts.author.as_deref().map(|a| format!("<dc:creator>{}</dc:creator>", xml_text(a))).unwrap_or_default();
let opf = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"bookid\" xml:lang=\"{lang}\" prefix=\"rendition: http://www.idpf.org/vocab/rendition/#\">\n<metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n<dc:identifier id=\"bookid\">{}</dc:identifier>\n<dc:title>{}</dc:title>\n<dc:language>{lang}</dc:language>{author}\n<meta property=\"dcterms:modified\">{modified}</meta>\n<meta property=\"rendition:layout\">pre-paginated</meta>\n<meta property=\"rendition:spread\">auto</meta>\n<meta name=\"original-resolution\" content=\"{vw}x{vh}\"/>\n<meta name=\"generator\" content=\"DesignCraft\"/>\n</metadata>\n<manifest>\n{manifest}</manifest>\n<spine>\n{spine}</spine>\n</package>\n",
xml_text(&id),
xml_text(&title)
);
let container = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\"><rootfiles><rootfile full-path=\"OEBPS/content.opf\" media-type=\"application/oebps-package+xml\"/></rootfiles></container>\n";
let mut buf = std::io::Cursor::new(Vec::new());
{
let mut z = zip::ZipWriter::new(&mut buf);
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
z.start_file("mimetype", stored).map_err(zerr)?;
z.write_all(b"application/epub+zip").map_err(io)?;
for (name, data) in
[("META-INF/container.xml", container.as_bytes()), ("OEBPS/content.opf", opf.as_bytes()), ("OEBPS/nav.xhtml", nav.as_bytes())]
{
z.start_file(name, deflate).map_err(zerr)?;
z.write_all(data).map_err(io)?;
}
for (name, data) in &files {
let o = if name.ends_with(".png") { stored } else { deflate };
z.start_file(name.as_str(), o).map_err(zerr)?;
z.write_all(data).map_err(io)?;
}
z.finish().map_err(zerr)?;
}
Ok(buf.into_inner())
}
impl Default for EpubOptions {
fn default() -> Self {
EpubOptions {
title: None,
author: None,
language: "en".into(),
identifier: None,
cover: None,
generic_families: BTreeMap::new(),
fonts: Vec::new(),
modified: None,
toc_title: None,
}
}
}
#[derive(Debug)]
pub struct EpubError(pub String);
impl std::fmt::Display for EpubError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for EpubError {}
fn valid_xml_char(c: char) -> bool {
matches!(c, '\u{9}' | '\u{A}' | '\u{D}' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}')
}
fn escape_markup(s: &str, attr: bool, story_breaks: bool) -> String {
let mut o = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => o.push_str("&amp;"),
'<' => o.push_str("&lt;"),
'>' => o.push_str("&gt;"),
'"' => o.push_str("&quot;"),
'\'' if attr => o.push_str("&apos;"),
'\u{E000}'..='\u{E1FF}' => {}
'\u{2028}' if story_breaks => o.push_str("<br/>"),
'\u{AD}' => o.push_str("&#173;"),
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<Item = &'a str>) -> 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<String> {
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<String, String>, out: &mut String) {
let _ = write!(out, "font-family: \"{}\", {}; ", css_string(&c.font_family), generic_family(&c.font_family, generics));
// The face's own weight, so an embedded Semibold or Light face is the one chosen.
let (weight, style) = css_weight_style(&c.font_style);
let _ = write!(out, "font-weight: {weight}; font-style: {style}; ");
let _ = write!(out, "font-size: {:.3}em; ", c.size / base_size);
if c.tracking != 0.0 {
let _ = write!(out, "letter-spacing: {:.3}em; ", c.tracking / 1000.0);
}
match c.capitalization {
designcraft_doc::Capitalization::AllCaps => out.push_str("text-transform: uppercase; "),
designcraft_doc::Capitalization::SmallCaps | designcraft_doc::Capitalization::OpenTypeAllSmallCaps => {
out.push_str("font-variant: small-caps; ")
}
_ => {}
}
if c.underline {
out.push_str("text-decoration: underline; ");
}
if c.strikethrough {
out.push_str("text-decoration: line-through; ");
}
if let Some(h) = hex(doc, &c.fill, c.fill_tint) {
let _ = write!(out, "color: {h}; ");
}
}
fn para_css(p: &ParaProps, base_size: f64, line_px: f64, out: &mut String) {
let align = match p.align {
Align::Left | Align::TowardsSpine => "left",
Align::Center => "center",
Align::Right | Align::AwayFromSpine => "right",
_ => "justify",
};
let _ = write!(out, "text-align: {align}; ");
let em = |v: f64| v / base_size;
let _ = write!(
out,
"margin: {:.3}em {:.3}em {:.3}em {:.3}em; text-indent: {:.3}em; line-height: {:.3}; ",
em(p.space_before),
em(p.right_indent),
em(p.space_after),
em(p.left_indent),
em(p.first_line_indent),
line_px
);
out.push_str(if p.hyphenate { "hyphens: auto; -webkit-hyphens: auto; " } else { "hyphens: manual; " });
}
/// The stylesheet for all paragraph and character styles.
pub fn stylesheet(doc: &Document) -> String {
stylesheet_with(doc, &BTreeMap::new(), &[])
}
/// [`stylesheet`] with the generic families to fall back on and `@font-face` rules for the
/// embedded fonts (packaged as [`font_file`] names them).
fn stylesheet_with(doc: &Document, generics: &BTreeMap<String, String>, 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<String> {
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("</pre>\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<String, (&'a str, &'a [u8])>,
/// Asset names of the images written without alt text (decorative).
no_alt: Vec<String>,
/// 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<String>,
}
impl<'a, 'f> StoryWriter<'a, 'f> {
fn new(doc: &'a Document, epub: bool, img_src: Option<&'f dyn Fn(&str, &str, &[u8]) -> String>) -> Self {
StoryWriter {
doc,
pc: para_classes(&doc.styles),
cc: char_classes(&doc.styles),
epub,
footnote_ids: 0,
endnote_ids: 0,
next_footnote: 1,
next_endnote: 1,
endnotes: Vec::new(),
img_src,
images: BTreeMap::new(),
no_alt: Vec::new(),
heading_ids: None,
}
}
fn epub_type(&self, t: &str) -> String {
if self.epub { format!(" epub:type=\"{t}\"") } else { String::new() }
}
/// One document story: paragraphs, tables, and each footnote after the paragraph that refers
/// to it.
fn story(&mut self, sid: StoryId, heading_ids: Option<&str>) -> String {
let Some(st) = self.doc.story(sid) else { return String::new() };
self.heading_ids = heading_ids.map(str::to_string);
self.next_footnote = self.doc.footnote_start(sid);
self.next_endnote = self.doc.endnote_start(sid);
let mut out = String::new();
self.paragraphs(st, 0, "", &mut out);
out
}
/// `st`'s paragraphs. `lead` (a note's number) starts the first paragraph.
///
/// Consecutive `li` paragraphs share one list (`ol` for a numbered style, `ul` for a bulleted
/// one, an unmarked `ul` when the style has no list type of its own, e.g. typed bullets), and
/// consecutive `pre` paragraphs of one style one block, their lines separated by newlines.
fn paragraphs(&mut self, st: &Story, depth: usize, lead: &str, out: &mut String) {
let doc = self.doc;
let mut lead = lead;
let mut list: Option<&str> = None;
let mut pre: Option<(String, bool)> = None;
for (pi, (r, pf)) in st.para_ranges().into_iter().zip(&st.paras).enumerate() {
let Some(text) = st.text.get(r.clone()) else { continue };
let table = pf.table.and_then(|id| st.tables.get(&id)).filter(|_| text.contains(story::TABLE_ANCHOR));
let only_table = table.is_some() && text.chars().all(|c| c == story::TABLE_ANCHOR || c.is_whitespace());
if !only_table || !lead.is_empty() {
// Export Tagging: the style's element and class.
let (tag, class) = export_mapping(&doc.styles, &pf.style, false, &self.pc);
let props = doc.styles.resolve_para(pf).0;
let rtl = props.direction == designcraft_doc::TextDirection::RightToLeft;
let continues_pre = tag == "pre" && pre.as_ref() == Some(&(class.clone(), rtl));
if !continues_pre && pre.take().is_some() {
out.push_str("</pre>\n");
}
let list_tag = (tag == "li").then_some(match props.list_type {
designcraft_doc::ListType::Numbers => "ol",
designcraft_doc::ListType::Bullets => "ul",
designcraft_doc::ListType::None => "ul style=\"list-style:none\"",
});
if list != list_tag {
close_list(&mut list, out);
if let Some(l) = list_tag {
let _ = writeln!(out, "<{l}>");
}
list = list_tag;
}
// An EPUB chapter file starts before each top-level h1, outside any open list or
// preformatted block.
if depth == 0 && self.heading_ids.is_some() && heading_level(tag) == Some(1) {
out.push_str(CHAPTER_MARK);
}
if continues_pre {
out.push('\n');
} else {
let id = match &self.heading_ids {
Some(prefix) if depth == 0 && heading_level(tag).is_some() => format!(" id=\"{prefix}-p{pi}\""),
_ => String::new(),
};
let _ = write!(out, "<{tag}{id} class=\"{}\"{}>", xml_attr(&class), if rtl { " dir=\"rtl\"" } else { "" });
}
out.push_str(lead);
if lead.is_empty() && text.trim().is_empty() {
out.push_str("&#160;");
}
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, "</{tag}>");
}
// Footnotes follow the paragraph outside any list or preformatted block.
if !after.is_empty() {
close_blocks(&mut list, &mut pre, out);
out.push_str(&after);
}
}
if let Some(t) = table
&& depth < MAX_DEPTH
{
close_blocks(&mut list, &mut pre, out);
self.table(t, depth + 1, out);
}
}
close_blocks(&mut list, &mut pre, out);
}
/// The runs of paragraph `para` of `st`; footnotes go to `after`.
fn runs(&mut self, st: &Story, para: std::ops::Range<usize>, depth: usize, out: &mut String, after: &mut String) {
let doc = self.doc;
for (rr, f) in st.runs() {
let a = rr.start.max(para.start);
let b = rr.end.min(para.end);
if a >= b {
continue;
}
// Hidden conditional text isn't exported.
if f.over.conditions.as_deref().is_some_and(|c| doc.conditions_hide(c)) || f.over.change == Some(designcraft_doc::ChangeMark::Deleted) {
continue;
}
let Some(raw) = st.text.get(a..b) else { continue };
let mut t = String::new();
let mut seg_start = 0;
// Index of the next anchored object: marks count from the story start, as
// `Story::object_at` counts them (counted once, at the run's first mark).
let mut mark: Option<usize> = None;
for (i, c) in raw.char_indices() {
if c != FOOTNOTE_REF && c != ENDNOTE_REF && c != OBJECT_MARK {
continue;
}
t.push_str(&run_text(doc, raw.get(seg_start..i).unwrap_or_default()));
seg_start = i + c.len_utf8();
if c == OBJECT_MARK {
let k = mark.unwrap_or_else(|| st.text.get(..a).map_or(0, |t| t.matches(OBJECT_MARK).count()));
mark = Some(k.saturating_add(1));
if let Some(o) = st.objects.get(k) {
t.push_str(&self.object(o));
}
} else if depth < MAX_DEPTH {
if c == FOOTNOTE_REF {
self.footnote(st, a + i, depth, &mut t, after);
} else {
self.endnote(st, a + i, depth, &mut t);
}
}
}
t.push_str(&run_text(doc, raw.get(seg_start..).unwrap_or_default()));
let (ctag, cls) = if f.style != story::NO_CHAR_STYLE {
let (tag, class) = export_mapping(&doc.styles, &f.style, true, &self.cc);
(tag, format!(" class=\"{}\"", xml_attr(&class)))
} else {
("span", String::new())
};
let style = override_css(doc, &f.over);
let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&style)) };
if cls.is_empty() && style_attr.is_empty() {
out.push_str(&t);
} else {
let _ = write!(out, "<{ctag}{cls}{style_attr}>{t}</{ctag}>");
}
}
}
/// 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!("<span class=\"anchored\" style=\"display:block;text-align:{align}\">{out}</span>")
}
_ => out,
}
}
/// The markup for one graphic item: an `<img>`, 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}>{}</{kind}>", 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!("<img src=\"{}\" alt=\"{}\"/>", 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,
"<sup><a{} role=\"doc-noteref\" id=\"fnref-{id}\" href=\"#fn-{id}\">{}</a></sup>",
self.epub_type("noteref"),
xml_text(&o.label(n, false))
);
let lead = format!("<a href=\"#fnref-{id}\">{}</a> ", xml_text(&o.label(n, true)));
let mut body = String::new();
self.paragraphs(&note.text, depth + 1, &lead, &mut body);
let _ = write!(after, "<aside{} role=\"doc-footnote\" id=\"fn-{id}\">\n{body}</aside>\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, "<sup><a{} role=\"doc-noteref\" id=\"enref-{id}\" href=\"#en-{id}\">{label}</a></sup>", self.epub_type("noteref"));
let mut body = String::new();
self.paragraphs(&note.text, depth + 1, &format!("<a href=\"#enref-{id}\">{label}</a> "), &mut body);
let item = format!("<li{} id=\"en-{id}\">\n{body}</li>\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!("<section{} role=\"doc-endnotes\" id=\"endnotes\">\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=\"{}\">{}</{tag}>", xml_attr(&class), xml_text(&o.heading));
}
out.push_str("<ol style=\"list-style: none; padding-left: 0\">\n");
for (_, item) in &self.endnotes {
out.push_str(item);
}
out.push_str("</ol>\n</section>\n");
out
}
/// A table: header rows as `thead`, footer rows as `tfoot`, merged cells as row and column
/// spans (kept within their row group).
fn table(&mut self, t: &Table, depth: usize, out: &mut String) {
let (nr, nc) = (t.nrows(), t.ncols());
if nr.checked_mul(nc) != Some(t.cells.len()) {
return;
}
let (h, f) = (t.header_rows(), t.footer_rows());
let body_end = nr.saturating_sub(f).max(h);
let mut covered = vec![false; t.cells.len()];
out.push_str("<table>\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("<tr>");
for c in 0..nc {
let i = r * nc + c;
if covered.get(i).copied().unwrap_or(true) {
continue;
}
let Some(cell) = t.cells.get(i) else { continue };
let rs = (cell.row_span.max(1) as usize).min(rows.end - r);
let cs = (cell.col_span.max(1) as usize).min(nc - c);
for rr in r..r + rs {
for cc in c..c + cs {
if let Some(slot) = covered.get_mut(rr * nc + cc) {
*slot = true;
}
}
}
let _ = write!(out, "<{cell_tag}");
if rs > 1 {
let _ = write!(out, " rowspan=\"{rs}\"");
}
if cs > 1 {
let _ = write!(out, " colspan=\"{cs}\"");
}
out.push('>');
self.paragraphs(&cell.text, depth, "", out);
let _ = writeln!(out, "</{cell_tag}>");
}
out.push_str("</tr>\n");
}
let _ = writeln!(out, "</{group}>");
}
out.push_str("</table>\n");
}
}
/// Run text with variables replaced by their values.
fn run_text(doc: &Document, raw: &str) -> String {
if raw.chars().any(|c| designcraft_doc::vars::var_index(c).is_some()) {
let sub: String = raw
.chars()
.map(|c| match designcraft_doc::vars::var_index(c) {
Some(i) => doc.variable_value(i, None).unwrap_or_default(),
None => c.to_string(),
})
.collect();
story_markup(&sub)
} else {
story_markup(raw)
}
}
/// One story as XHTML: paragraphs, tables, and each footnote after the paragraph that refers to
/// it. Endnote references link to the endnotes section that the full export ends with.
pub fn story_html(doc: &Document, sid: StoryId) -> String {
StoryWriter::new(doc, true, None).story(sid, None)
}
/// Where the EPUB body splits into chapter files: before each `h1`.
const CHAPTER_MARK: &str = "<!--dc:chapter-->";
/// 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<String> {
let mut out: Vec<String> = Vec::new();
let mut carry: Option<String> = 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("<section").filter(|i| !part[*i..].contains("</section>")) {
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("</section>\n");
carry = Some("<section>\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("</section>\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, String>) -> 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<u8> {
tag.strip_prefix('h').and_then(|n| n.parse::<u8>().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::<String>().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, String>) -> String {
let mut out = String::new();
let mut open: Vec<u8> = Vec::new();
for (target, label, level) in entries {
let file = files.get(target).map_or("content.xhtml", String::as_str);
let item = format!("<li><a href=\"{file}#{target}\">{}</a>", xml_text(label));
match open.last().copied() {
None => open.push(*level),
Some(top) if *level > top => {
out.push_str("<ol>");
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("</li></ol>");
}
out.push_str("</li>");
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("</li>");
if i > 0 {
out.push_str("</ol>");
}
}
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<Block> {
// 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::<Vec<_>>().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<String, (&'a str, &'a [u8])>,
/// Table of contents: (element id, label, level).
toc: Vec<(String, String, u8)>,
/// Asset names of the images written without alt text.
no_alt: Vec<String>,
}
/// The content in reading order as HTML sections and figures; `img_src` names each image
/// (a package path, or a data URI for single-file HTML).
fn body_html<'a>(doc: &'a Document, epub: bool, img_src: &dyn Fn(&str, &str, &[u8]) -> String) -> Body<'a> {
let order = reading_order(doc);
let mut writer = StoryWriter::new(doc, epub, Some(img_src));
let mut body = String::new();
let mut toc = toc_entries(doc, &order);
for (k, b) in order.iter().enumerate() {
match b {
// The endnote frame's text is generated from the endnotes, which end the body as a
// linked section instead.
Block::Story(sid) if doc.endnote_story == Some(*sid) => {}
Block::Story(sid) => {
let anchor = format!("s{k}");
let _ = write!(body, "<section id=\"{anchor}\">\n{}</section>\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, "<figure>{markup}</figure>");
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, "<figure{style}>{markup}</figure>");
}
}
}
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<String> {
body_html(doc, true, &|name, _, _| name.to_string()).no_alt
}
/// Options for [`export_html`].
#[derive(Clone, Debug, Default)]
pub struct HtmlOptions {
pub title: Option<String>,
pub language: Option<String>,
/// Generic CSS family per font family (see [`EpubOptions::generic_families`]).
pub generic_families: BTreeMap<String, String>,
}
/// File › Export › HTML: one self-contained page (styles inline, images as data URIs) with the
/// document's stories and graphics in reading order.
pub fn export_html(doc: &Document, opts: &HtmlOptions) -> String {
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let language = opts.language.as_deref().unwrap_or("en");
let lang = language_tag(language);
let body = body_html(doc, false, &|_, mime, data| format!("data:{mime};base64,{}", base64(data))).html;
format!(
"<!DOCTYPE html>\n<html lang=\"{lang}\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"generator\" content=\"DesignCraft\">\n<title>{}</title>\n<style>\nbody {{ max-width: 40em; margin: 2em auto; padding: 0 1em; }}\nfigure {{ margin: 1em 0; }} figure img {{ max-width: 100%; height: auto; }}\n{}</style>\n</head>\n<body>\n{body}</body>\n</html>\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<Vec<u8>, EpubError> {
let io = |e: std::io::Error| EpubError(e.to_string());
let zerr = |e: zip::result::ZipError| EpubError(e.to_string());
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let id = identifier(doc, opts, &title);
let modified = modified(doc, opts);
let Body { html: body, images, toc, .. } = body_html(doc, true, &|name, _, _| name.to_string());
let lang = language_tag(&opts.language);
// One file per chapter (split before each h1), so e-readers needn't load the whole book.
let chapters: Vec<String> = split_chapters(&body)
.into_iter()
.map(|part| {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\" xml:lang=\"{lang}\" lang=\"{lang}\">\n<head><meta charset=\"utf-8\"/><title>{}</title><link rel=\"stylesheet\" type=\"text/css\" href=\"style.css\"/></head>\n<body>\n{part}</body>\n</html>\n",
xml_text(&title)
)
})
.collect();
// The file each navigation target (an element id) ended up in.
let mut files: BTreeMap<String, String> = 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<String> = 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!("<li><a href=\"content.xhtml\">{}</a></li>", xml_text(&title));
}
let nav = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\" xml:lang=\"{lang}\">\n<head><meta charset=\"utf-8\"/><title>{}</title></head>\n<body><nav epub:type=\"toc\" id=\"toc\"><h1>{}</h1><ol>{nav_items}</ol></nav></body>\n</html>\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(
"<item id=\"nav\" href=\"nav.xhtml\" media-type=\"application/xhtml+xml\" properties=\"nav\"/>\n<item id=\"css\" href=\"style.css\" media-type=\"text/css\"/>\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, "<item id=\"{item}\" href=\"{}\" media-type=\"application/xhtml+xml\"/>", chapter_file(k));
let _ = writeln!(spine, "<itemref idref=\"{item}\"/>");
}
for (k, (name, (mime, _))) in images.iter().enumerate() {
let _ = writeln!(manifest, "<item id=\"img{k}\" href=\"{}\" media-type=\"{}\"/>", xml_attr(name), xml_attr(mime));
}
for (k, f) in opts.fonts.iter().enumerate() {
let (name, mime) = font_file(k, f);
let _ = writeln!(manifest, "<item id=\"font{k}\" href=\"{}\" media-type=\"{mime}\"/>", 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,
"<item id=\"cover-img\" href=\"{name}\" media-type=\"{mime}\" properties=\"cover-image\"/>\n<item id=\"cover\" href=\"cover.xhtml\" media-type=\"application/xhtml+xml\"/>"
);
}
let cover_spine = if opts.cover.is_some() { "<itemref idref=\"cover\"/>\n" } else { "" };
let author = opts.author.as_deref().map(|a| format!("<dc:creator>{}</dc:creator>", xml_text(a))).unwrap_or_default();
let opf = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"bookid\" xml:lang=\"{lang}\">\n<metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n<dc:identifier id=\"bookid\">{}</dc:identifier>\n<dc:title>{}</dc:title>\n<dc:language>{lang}</dc:language>{author}\n<meta property=\"dcterms:modified\">{modified}</meta>\n<meta name=\"generator\" content=\"DesignCraft\"/>\n</metadata>\n<manifest>\n{manifest}</manifest>\n<spine>\n{cover_spine}{spine}</spine>\n</package>\n",
xml_text(&id),
xml_text(&title)
);
let container = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\"><rootfiles><rootfile full-path=\"OEBPS/content.opf\" media-type=\"application/oebps-package+xml\"/></rootfiles></container>\n";
let mut buf = std::io::Cursor::new(Vec::new());
{
let mut z = zip::ZipWriter::new(&mut buf);
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
z.start_file("mimetype", stored).map_err(zerr)?;
z.write_all(b"application/epub+zip").map_err(io)?;
for (name, data) in [
("META-INF/container.xml", container.as_bytes()),
("OEBPS/content.opf", opf.as_bytes()),
("OEBPS/nav.xhtml", nav.as_bytes()),
("OEBPS/style.css", stylesheet_with(doc, &opts.generic_families, &opts.fonts).as_bytes()),
] {
z.start_file(name, deflate).map_err(zerr)?;
z.write_all(data).map_err(io)?;
}
for (k, c) in chapters.iter().enumerate() {
z.start_file(format!("OEBPS/{}", chapter_file(k)), deflate).map_err(zerr)?;
z.write_all(c.as_bytes()).map_err(io)?;
}
for (name, (_, data)) in &images {
z.start_file(format!("OEBPS/{name}"), stored).map_err(zerr)?;
z.write_all(data).map_err(io)?;
}
for (k, f) in opts.fonts.iter().enumerate() {
z.start_file(format!("OEBPS/{}", font_file(k, f).0), deflate).map_err(zerr)?;
z.write_all(&f.data).map_err(io)?;
}
if let Some(c) = &opts.cover {
z.start_file(format!("OEBPS/{}", cover_file(c).0), stored).map_err(zerr)?;
z.write_all(c).map_err(io)?;
let xhtml = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\" xml:lang=\"{lang}\">\n<head><meta charset=\"utf-8\"/><title>{}</title><style>body{{margin:0;text-align:center}} img{{max-width:100%;max-height:100vh}}</style></head>\n<body epub:type=\"cover\"><img src=\"{}\" alt=\"{}\"/></body>\n</html>\n",
xml_text(&title),
cover_file(c).0,
xml_attr(&title)
);
z.start_file("OEBPS/cover.xhtml", deflate).map_err(zerr)?;
z.write_all(xhtml.as_bytes()).map_err(io)?;
}
z.finish().map_err(zerr)?;
}
Ok(buf.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
use designcraft_doc::build::NewDocument;
use designcraft_doc::geom::{Affine, Rect, shapes};
use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef, Story, StoryId, Table};
use std::io::Read;
use std::sync::Arc;
#[test]
fn exports_valid_package_in_reading_order() {
let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() });
let lid = d.default_layer();
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 400.0, 300.0, 500.0), lid, "Second <b> & co", ParaFormat::default()).unwrap();
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "First\nline two", ParaFormat::default()).unwrap();
let bytes = export_epub(&d, &EpubOptions::default()).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
assert_eq!(z.by_index(0).unwrap().name(), "mimetype");
let mut html = String::new();
z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap();
let a = html.find("First").unwrap();
let b = html.find("Second &lt;b&gt; &amp; co").unwrap();
assert!(a < b, "reading order");
assert!(html.contains("<p class=\"basic-paragraph\">"));
let mut css = String::new();
z.by_name("OEBPS/style.css").unwrap().read_to_string(&mut css).unwrap();
assert!(css.contains("p.basic-paragraph"));
assert!(z.by_name("OEBPS/nav.xhtml").is_ok());
}
/// #766: anchored graphics are exported where they are anchored (inline in the text, an
/// above-line object as a block of its own), and their images go into the package.
#[test]
fn anchored_objects_are_exported_in_place() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 200.0), lid, "AB\nCD", ParaFormat::default()).unwrap();
let aid = AssetId(d.alloc());
d.assets.insert(
aid,
Arc::new(Asset {
page: 0,
id: aid,
name: "dot.png".into(),
mime: "image/png".into(),
link: None,
data: Arc::new(vec![7]),
pixels: Some((1, 1)),
..Default::default()
}),
);
let graphic = |d: &mut Document, alt: &str| {
let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));
item.alt_text = alt.into();
item.content = Content::Graphic(Graphic {
asset: aid,
size: (1.0, 1.0),
xf: Affine::IDENTITY,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
clip: None,
wrap: Default::default(),
fill: Default::default(),
});
item
};
let inline = designcraft_doc::AnchoredObject::new(graphic(&mut d, "inline"), designcraft_doc::AnchorPosition::Inline { y_offset: 0.0 });
let above = designcraft_doc::AnchoredObject::new(
graphic(&mut d, "above"),
designcraft_doc::AnchorPosition::AboveLine { align: designcraft_doc::anchored::AnchorAlign::Center, space_before: 0.0, space_after: 0.0 },
);
let st = d.story_mut(sid).unwrap();
st.insert_object(1, inline);
// Byte offsets: "A" (1), the mark (3), "B\nC" (3): the second object goes between C and D.
st.insert_object(7, above);
// An anchored image without alt text is decorative and reported, like a placed one.
let bare = designcraft_doc::AnchoredObject::new(graphic(&mut d, ""), designcraft_doc::AnchorPosition::Inline { y_offset: 0.0 });
let end = d.story(sid).unwrap().text.len();
d.story_mut(sid).unwrap().insert_object(end, bare);
assert_eq!(images_without_alt(&d), vec!["dot.png".to_string()]);
let bytes = export_epub(&d, &EpubOptions::default()).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut html = String::new();
z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap();
let img = |alt: &str| format!("<img src=\"images/dot-{}.png\" alt=\"{alt}\"/>", aid.0);
assert!(html.contains(&format!("A{}B</p>", img("inline"))), "{html}");
assert!(html.contains(&format!("C<span class=\"anchored\" style=\"display:block;text-align:center\">{}</span>D", img("above"))), "{html}");
assert!(z.by_name(&format!("OEBPS/images/dot-{}.png", aid.0)).is_ok(), "the image is in the package");
let page = export_html(&d, &HtmlOptions::default());
assert!(page.contains("alt=\"inline\"") && page.contains("alt=\"above\""), "{page}");
// story_html on its own still leaves objects out.
assert!(!story_html(&d, sid).contains("<img"));
}
#[test]
fn iso8601_timestamps() {
for (t, want) in [
(0, "1970-01-01T00:00:00Z"),
(951_782_400, "2000-02-29T00:00:00Z"),
(1_791_891_000, "2026-10-13T11:30:00Z"),
(-86_400, "1969-12-31T00:00:00Z"),
] {
assert_eq!(iso8601(t), want);
}
}
/// #771, #773: dcterms:modified is a real time, the default identifier is a stable urn:uuid
/// that differs between documents with the same title, and a JPEG cover keeps its type.
#[test]
fn package_metadata_and_cover_image() {
let opf = |d: &Document, opts: &EpubOptions| {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap();
let mut s = String::new();
z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut s).unwrap();
let names: Vec<String> = z.file_names().map(str::to_string).collect();
(s, names)
};
let id_of = |opf: &str| opf.split("<dc:identifier id=\"bookid\">").nth(1).and_then(|r| r.split('<').next()).unwrap_or_default().to_string();
let mut a = Document::new(&NewDocument::default());
a.title = "Same".into();
a.created = 1_700_000_000;
let mut b = a.clone();
b.created = 1_700_000_001;
let (oa, _) = opf(&a, &EpubOptions::default());
let (oa2, _) = opf(&a, &EpubOptions::default());
let (ob, _) = opf(&b, &EpubOptions::default());
assert!(id_of(&oa).starts_with("urn:uuid:") && id_of(&oa).len() == 45, "{}", id_of(&oa));
assert_eq!(id_of(&oa), id_of(&oa2), "stable from export to export");
assert_ne!(id_of(&oa), id_of(&ob), "not shared by two books with the same title");
// Modified: the given time, else the document's save (or creation) time.
assert!(oa.contains("<meta property=\"dcterms:modified\">2023-11-14T22:13:20Z</meta>"), "{oa}");
let at = EpubOptions { modified: Some(1_791_891_000), ..Default::default() };
assert!(opf(&a, &at).0.contains("2026-10-13T11:30:00Z"));
// A JPEG cover is packaged as such.
let jpeg = EpubOptions { cover: Some(vec![0xFF, 0xD8, 0xFF, 0xE0, 1, 2]), ..Default::default() };
let (o, names) = opf(&a, &jpeg);
assert!(o.contains("href=\"images/cover.jpg\" media-type=\"image/jpeg\" properties=\"cover-image\""), "{o}");
assert!(names.contains(&"OEBPS/images/cover.jpg".to_string()), "{names:?}");
}
fn st_typed(d: &mut Document, sid: StoryId) {
if let Some(st) = d.story_mut(sid) {
st.paras[8].style = "Typed".into();
}
}
/// #767: consecutive `li` paragraphs share one list (`ol` for a numbered style), and
/// consecutive `pre` paragraphs of one style one block with their lines on separate lines.
#[test]
fn lists_and_preformatted_blocks_are_grouped() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
for (name, tag, list) in [
("Bullet", "li", Some(designcraft_doc::ListType::Bullets)),
("Number", "li", Some(designcraft_doc::ListType::Numbers)),
("Typed", "li", None),
("Code", "pre", None),
] {
let para = designcraft_doc::ParaAttrs { list_type: list, ..Default::default() };
d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle {
name: name.into(),
based_on: None,
next_style: None,
para,
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new() });
}
let text = "Intro\nfirst\nsecond\none\ntwo\nx = 1\ny = 2\nEnd\n• typed";
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, text, ParaFormat::default()).unwrap();
let st = d.story_mut(sid).unwrap();
for (i, style) in ["Bullet", "Bullet", "Number", "Number", "Code", "Code"].into_iter().enumerate() {
st.paras[i + 1].style = style.into();
}
let html = story_html(&d, sid);
let li = |c: &str, t: &str| format!("<li class=\"{c}\">{t}</li>");
assert!(
html.contains(&format!(
"<ul>\n{}\n{}\n</ul>\n<ol>\n{}\n{}\n</ol>\n",
li("bullet", "first"),
li("bullet", "second"),
li("number", "one"),
li("number", "two")
)),
"{html}"
);
assert!(html.contains("<pre class=\"code\">x = 1\ny = 2</pre>\n<p class=\"basic-paragraph\">End</p>"), "{html}");
assert_eq!(html.matches("<pre").count(), 1, "{html}");
// A style without a list type of its own (typed bullets) gets no second marker.
st_typed(&mut d, sid);
let html = story_html(&d, sid);
assert!(html.contains("<ul style=\"list-style:none\">\n<li class=\"typed\">• typed</li>\n</ul>\n"), "{html}");
}
/// #769, #770: the navigation lists the h1–h3 headings (nested by level) and links to them,
/// with its title in the book's language; without headings, each story's first paragraph.
#[test]
fn navigation_follows_the_headings() {
let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() });
let lid = d.default_layer();
for (name, tag) in [("Chapter", "h1"), ("Section", "h2")] {
d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle {
name: name.into(),
based_on: None,
next_style: None,
para: Default::default(),
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new() });
}
let text = "One\nBody\nOne point one\nMore\nTwo\nText";
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, text, ParaFormat::default()).unwrap();
let st = d.story_mut(sid).unwrap();
for (i, style) in [(0, "Chapter"), (2, "Section"), (4, "Chapter")] {
st.paras[i].style = style.into();
}
// A story that starts with body text adds nothing to the table of contents.
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 400.0, 300.0, 500.0), lid, "Text after the figure.", ParaFormat::default()).unwrap();
let read = |d: &Document, opts: &EpubOptions, path: &str| {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap();
let mut s = String::new();
z.by_name(path).unwrap().read_to_string(&mut s).unwrap();
s
};
let nl = EpubOptions { language: "nl".into(), ..Default::default() };
let nav = read(&d, &nl, "OEBPS/nav.xhtml");
let link = |f: &str, t: &str, l: &str| format!("<a href=\"{f}#{t}\">{l}</a>");
// Each h1 starts a chapter file (#768).
let want = format!(
"<h1>Inhoud</h1><ol><li>{}<ol><li>{}</li></ol></li><li>{}</li></ol>",
link("content.xhtml", "s0-p0", "One"),
link("content.xhtml", "s0-p2", "One point one"),
link("content2.xhtml", "s0-p4", "Two")
);
assert!(nav.contains(&want), "{nav}");
assert!(!nav.contains("Text after the figure"), "{nav}");
let content = read(&d, &nl, "OEBPS/content.xhtml");
assert!(content.contains("<h1 id=\"s0-p0\" class=\"chapter\">One</h1>") && content.contains("<h2 id=\"s0-p2\""), "{content}");
let custom = EpubOptions { toc_title: Some("In dit boek".into()), ..Default::default() };
assert!(read(&d, &custom, "OEBPS/nav.xhtml").contains("<h1>In dit boek</h1>"));
// Without headings: one entry per story, its first paragraph.
let mut plain = Document::new(&NewDocument::default());
let lid = plain.default_layer();
plain.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Opening\nrest", ParaFormat::default()).unwrap();
let nav = read(&plain, &EpubOptions::default(), "OEBPS/nav.xhtml");
assert!(nav.contains(&format!("<h1>Contents</h1><ol><li>{}</li></ol>", link("content.xhtml", "s0", "Opening"))), "{nav}");
// A heading shallower than the one before it but deeper than its parent stays under the
// parent: h1 A, h3 B, h2 C nest B and C under A.
let files: BTreeMap<String, String> = "ABCD".chars().map(|c| (c.to_string(), c.to_string())).collect();
let e = |t: &str, l: u8| (t.to_string(), t.to_string(), l);
assert_eq!(
nav_list(&[e("A", 1), e("B", 3), e("C", 2), e("D", 1)], &files),
format!(
"<li>{}<ol><li>{}</li><li>{}</li></ol></li><li>{}</li>",
link("A", "A", "A"),
link("B", "B", "B"),
link("C", "C", "C"),
link("D", "D", "D")
)
);
assert_eq!(
nav_list(&[e("A", 1), e("B", 2), e("C", 3), e("D", 1)], &files),
format!(
"<li>{}<ol><li>{}<ol><li>{}</li></ol></li></ol></li><li>{}</li>",
link("A", "A", "A"),
link("B", "B", "B"),
link("C", "C", "C"),
link("D", "D", "D")
)
);
}
/// #768: the book is split into one file per chapter, before each h1, with well-formed
/// sections; the spine and manifest list the files in order.
#[test]
fn chapters_are_split_at_h1() {
let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() });
let lid = d.default_layer();
d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle {
name: "Chapter".into(),
based_on: None,
next_style: None,
para: Default::default(),
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut().export_tags.insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new() });
// A title page without a heading, then a story that opens with a chapter and has a
// second one in the middle.
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Title page", ParaFormat::default()).unwrap();
let (_, sid) = d
.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 200.0, 300.0, 400.0), lid, "One\nFirst text\nTwo\nSecond text", ParaFormat::default())
.unwrap();
let st = d.story_mut(sid).unwrap();
st.paras[0].style = "Chapter".into();
st.paras[2].style = "Chapter".into();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap();
let mut read = |path: &str| {
let mut s = String::new();
z.by_name(path).unwrap().read_to_string(&mut s).unwrap();
s
};
let (c1, c2, c3) = (read("OEBPS/content.xhtml"), read("OEBPS/content2.xhtml"), read("OEBPS/content3.xhtml"));
assert!(c1.contains("Title page") && !c1.contains("One"), "{c1}");
assert!(c2.contains("<section id=\"s1\">\n<h1 id=\"s1-p0\"") && c2.contains("First text") && !c2.contains("Two"), "{c2}");
assert!(c3.contains("<section>\n<h1 id=\"s1-p2\""), "{c3}");
for c in [&c1, &c2, &c3] {
assert_eq!(c.matches("<section").count(), c.matches("</section>").count(), "{c}");
assert!(!c.contains(CHAPTER_MARK));
}
let opf = read("OEBPS/content.opf");
assert!(opf.contains("<itemref idref=\"content\"/>\n<itemref idref=\"content2\"/>\n<itemref idref=\"content3\"/>\n</spine>"), "{opf}");
assert!(opf.contains("<item id=\"content3\" href=\"content3.xhtml\""), "{opf}");
let nav = read("OEBPS/nav.xhtml");
assert!(nav.contains("href=\"content2.xhtml#s1-p0\"") && nav.contains("href=\"content3.xhtml#s1-p2\""), "{nav}");
assert!(z.by_name("OEBPS/content4.xhtml").is_err());
// HTML stays one page, without the marks.
let html = export_html(&d, &HtmlOptions::default());
assert!(!html.contains(CHAPTER_MARK) && html.contains("Second text"));
}
/// Note links stay working across chapter files: endnotes end the book, in the last file.
#[test]
fn note_links_point_at_the_chapter_file_of_their_target() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle {
name: "Chapter".into(),
based_on: None,
next_style: None,
para: Default::default(),
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut().export_tags.insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new() });
let (_, sid) = d
.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 400.0), lid, "One\nFirst text\nTwo\nSecond text", ParaFormat::default())
.unwrap();
let st = d.story_mut(sid).unwrap();
st.paras[0].style = "Chapter".into();
st.paras[2].style = "Chapter".into();
// After "First": an endnote and a footnote.
st.insert_endnote(9, "End note", ParaFormat::default());
st.insert_note(9, "Foot note", ParaFormat::default());
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap();
let mut read = |path: &str| {
let mut s = String::new();
z.by_name(path).unwrap().read_to_string(&mut s).unwrap();
s
};
let (first, last) = (read("OEBPS/content.xhtml"), read("OEBPS/content2.xhtml"));
assert!(first.contains("id=\"enref-1\" href=\"content2.xhtml#en-1\""), "{first}");
assert!(first.contains("href=\"#fn-1\"") && first.contains("href=\"#fnref-1\""), "a footnote stays in its chapter: {first}");
assert!(last.contains("Second text") && last.contains("<a href=\"content.xhtml#enref-1\">"), "{last}");
}
#[test]
fn split_chapters_keeps_sections_well_formed() {
let m = CHAPTER_MARK;
assert_eq!(split_chapters("<section id=\"s0\">\n<p>a</p>\n</section>\n"), vec!["<section id=\"s0\">\n<p>a</p>\n</section>\n"]);
assert_eq!(
split_chapters(&format!("<section id=\"s0\">\n{m}<h1>A</h1>\n</section>\n")),
vec!["<section id=\"s0\">\n<h1>A</h1>\n</section>\n"]
);
assert_eq!(
split_chapters(&format!("<section id=\"s0\">\n<p>a</p>\n{m}<h1>B</h1>\n</section>\n")),
vec!["<section id=\"s0\">\n<p>a</p>\n</section>\n", "<section>\n<h1>B</h1>\n</section>\n"]
);
assert_eq!(split_chapters(""), vec![String::new()]);
}
#[test]
fn styles_whose_names_slug_alike_keep_their_own_classes() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
for (name, size) in [("見出し", 30.0), ("本文", 10.0), ("Body Text", 12.0), ("Body-Text", 20.0)] {
d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle {
name: name.into(),
based_on: None,
next_style: None,
para: Default::default(),
chars: CharAttrs { size: Some(size), ..Default::default() },
shortcut: String::new(),
});
}
for (k, name) in ["見出し", "本文", "Body Text", "Body-Text"].into_iter().enumerate() {
let y = 36.0 + 100.0 * k as f64;
let pf = ParaFormat { style: name.into(), ..Default::default() };
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, y, 300.0, y + 50.0), lid, &format!("P{k}"), pf).unwrap();
}
let bytes = export_epub(&d, &EpubOptions::default()).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut html = String::new();
z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap();
let mut css = String::new();
z.by_name("OEBPS/style.css").unwrap().read_to_string(&mut css).unwrap();
let class_of = |text: &str| {
let end = html.find(&format!("\">{text}</p>")).unwrap();
let start = html[..end].rfind("class=\"").unwrap() + 7;
html[start..end].to_string()
};
let classes: Vec<String> = (0..4).map(|k| class_of(&format!("P{k}"))).collect();
for (k, c) in classes.iter().enumerate() {
assert!(!classes[..k].contains(c), "class `{c}` shared: {classes:?}");
assert_eq!(css.matches(&format!("p.{c} {{")).count(), 1, "one rule for `{c}`:\n{css}");
}
}
#[test]
fn slugs() {
assert_eq!(slug("[Basic Paragraph]"), "basic-paragraph");
assert_eq!(slug("Body First"), "body-first");
assert_eq!(slug("1 Head"), "s-1-head");
}
#[test]
fn html_is_one_self_contained_page() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Hello <web>", ParaFormat::default()).unwrap();
let html = export_html(&d, &HtmlOptions { title: Some("T".into()), language: None, ..Default::default() });
assert!(html.starts_with("<!DOCTYPE html>") && html.contains("<title>T</title>") && html.contains("Hello &lt;web&gt;"));
assert!(html.contains("p.basic-paragraph"), "styles inline");
assert_eq!(base64(b"Man"), "TWFu");
assert_eq!(base64(b"Ma"), "TWE=");
assert_eq!(base64(b"M"), "TQ==");
}
/// #774: an image without alt text is decorative (`alt=""`) and reported, and package names
/// come from the original file names.
#[test]
fn images_get_readable_names_and_no_file_name_alt() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let place = |d: &mut Document, name: &str, mime: &str, alt: &str, y: f64| {
let aid = AssetId(d.alloc());
d.assets.insert(
aid,
Arc::new(Asset {
page: 0,
id: aid,
name: name.into(),
mime: mime.into(),
link: None,
data: Arc::new(vec![1]),
pixels: Some((1, 1)),
..Default::default()
}),
);
let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, y, 72.0, y + 36.0)));
item.alt_text = alt.into();
item.content = Content::Graphic(Graphic {
asset: aid,
size: (1.0, 1.0),
xf: Affine::IDENTITY,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
clip: None,
wrap: Default::default(),
fill: Default::default(),
});
d.insert_item(SpreadRef::Doc(0), item, None).unwrap();
aid.0
};
let a = place(&mut d, "/tmp/Anim Frame (1).GIF", "image/gif", "", 36.0);
let b = place(&mut d, "photo.jpg", "image/jpeg", "A heron", 120.0);
let c = place(&mut d, "???.png", "image/png", "Mark", 200.0);
assert_eq!(images_without_alt(&d), vec!["/tmp/Anim Frame (1).GIF".to_string()]);
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap();
let mut x = String::new();
z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut x).unwrap();
assert!(x.contains(&format!("<img src=\"images/anim-frame-1-{a}.gif\" alt=\"\"/>")), "{x}");
assert!(x.contains(&format!("<img src=\"images/photo-{b}.jpg\" alt=\"A heron\"/>")), "{x}");
assert!(x.contains(&format!("<img src=\"images/{c}.png\" alt=\"Mark\"/>")), "{x}");
assert!(z.by_name(&format!("OEBPS/images/anim-frame-1-{a}.gif")).is_ok());
}
/// #775: the generic fallback follows the font, and embedded fonts are packaged and declared.
#[test]
fn generic_fallbacks_and_embedded_fonts() {
let none = BTreeMap::new();
for (family, want) in [
("Source Serif 4", "serif"),
("Source Sans 3", "sans-serif"),
("Microsoft Sans Serif", "sans-serif"),
("JetBrains Mono", "monospace"),
("Courier New", "monospace"),
("Franklin Gothic", "sans-serif"),
("Garamond", "serif"),
] {
assert_eq!(generic_family(family, &none), want, "{family}");
}
let known: BTreeMap<String, String> =
[("Montserrat".to_string(), "sans-serif".to_string()), ("Odd".to_string(), "nonsense".to_string())].into();
assert_eq!(generic_family("Montserrat", &known), "sans-serif");
assert_eq!(generic_family("Odd", &known), "serif", "only CSS generic families are used");
assert_eq!(css_weight_style("Semibold Italic"), (600, "italic"));
assert_eq!(css_weight_style("Bold"), (700, "normal"));
assert_eq!(css_weight_style("Extra Bold"), (800, "normal"));
assert_eq!(css_weight_style("Regular"), (400, "normal"));
assert_eq!(css_weight_style("Light"), (300, "normal"));
assert_eq!(css_weight_style("Thin Oblique"), (100, "italic"));
assert_eq!(css_weight_style("Black"), (900, "normal"));
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap();
if let Some(basic) = d.styles_mut().paragraph.iter_mut().find(|s| s.name == story::BASIC_PARAGRAPH) {
basic.chars.font_family = Some("Montserrat".into());
basic.chars.font_style = Some("Bold Italic".into());
}
assert_eq!(stylesheet_fonts(&d), vec![("Montserrat".to_string(), "Bold Italic".to_string())]);
let opts = EpubOptions {
generic_families: known,
fonts: vec![EmbeddedFont { family: "Montserrat".into(), style: "Bold Italic".into(), data: b"OTTO font".to_vec() }],
..Default::default()
};
let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &opts).unwrap())).unwrap();
let mut read = |path: &str| {
let mut s = String::new();
z.by_name(path).unwrap().read_to_string(&mut s).unwrap();
s
};
let css = read("OEBPS/style.css");
assert!(
css.starts_with(
"@font-face { font-family: \"Montserrat\"; src: url(\"fonts/0-montserrat-bold-italic.otf\"); font-weight: 700; font-style: italic; }"
),
"{css}"
);
assert!(css.contains("font-family: \"Montserrat\", sans-serif;"), "{css}");
let opf = read("OEBPS/content.opf");
assert!(opf.contains("<item id=\"font0\" href=\"fonts/0-montserrat-bold-italic.otf\" media-type=\"font/otf\"/>"), "{opf}");
assert_eq!(read("OEBPS/fonts/0-montserrat-bold-italic.otf"), "OTTO font");
// HTML takes the generic families too, and embeds nothing.
let html = export_html(&d, &HtmlOptions { generic_families: opts.generic_families.clone(), ..Default::default() });
assert!(html.contains("\"Montserrat\", sans-serif;") && !html.contains("@font-face"), "{html}");
}
#[test]
fn metadata_is_serialized_for_its_output_context() {
let mut d = Document::new(&NewDocument::default());
d.title = "Field Notes & <Edition>".into();
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "A&B <C> \"D\"", ParaFormat::default()).unwrap();
d.styles_mut()
.export_tags
.insert(format!("p:{}", story::BASIC_PARAGRAPH), designcraft_doc::ExportTag { tag: "h2".into(), class: "Primary \"Copy\"".into() });
if let Some(basic) = d.styles_mut().paragraph.iter_mut().find(|s| s.name == story::BASIC_PARAGRAPH) {
basic.chars.font_family = Some("Quoted \"Serif\" <Alt>".into());
}
let aid = AssetId(d.alloc());
d.assets.insert(
aid,
Arc::new(Asset {
id: aid,
name: "Cover & Notes".into(),
mime: "image/png; profile=screen".into(),
data: Arc::new(vec![1]),
pixels: Some((1, 1)),
..Default::default()
}),
);
let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, 120.0, 72.0, 156.0)));
item.alt_text = "Cover \"A\" & B".into();
item.content = Content::Graphic(Graphic {
asset: aid,
size: (1.0, 1.0),
xf: Affine::IDENTITY,
clip: None,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
wrap: Default::default(),
fill: Default::default(),
});
d.insert_item(SpreadRef::Doc(0), item, None).unwrap();
let story = story_html(&d, sid);
assert!(story.contains("<h2 class=\"Primary &quot;Copy&quot;\">A&amp;B &lt;C&gt; &quot;D&quot;</h2>"));
let html = export_html(&d, &HtmlOptions { title: None, language: Some("English (US)".into()), ..Default::default() });
assert!(html.contains("<html lang=\"und\">"));
assert!(html.contains("<title>Field Notes &amp; &lt;Edition&gt;</title>"));
assert!(html.contains("alt=\"Cover &quot;A&quot; &amp; B\""));
assert!(html.contains("src=\"data:image/png;base64,AQ==\""));
assert!(!html.contains("image/png; profile=screen"));
assert!(!stylesheet(&d).contains("<Alt>"));
assert!(stylesheet(&d).contains("\\3c Alt\\3e "));
let bytes = export_epub(
&d,
&EpubOptions {
title: None,
author: Some("A & B".into()),
language: "English (US)".into(),
identifier: Some("notes&edition".into()),
cover: None,
..Default::default()
},
)
.unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut opf = String::new();
z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap();
assert!(opf.contains("xml:lang=\"und\""));
assert!(opf.contains("<dc:identifier id=\"bookid\">notes&amp;edition</dc:identifier>"));
assert!(opf.contains("<dc:creator>A &amp; B</dc:creator>"));
assert!(opf.contains("media-type=\"image/png\""));
}
/// "Body¹ text", a 2×2 table, then "Closing" with an endnote.
fn tables_and_notes() -> Document {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 400.0), lid, "Body text", ParaFormat::default()).unwrap();
let st = d.story_mut(sid).unwrap();
st.insert_note(4, "Foot note text", ParaFormat::default());
let mut t = Table::new(1, 2, 2, 0, 0, 200.0);
for (r, c, s) in [(0, 0, "Cell A1"), (0, 1, "Cell B1"), (1, 0, "Cell A2"), (1, 1, "Cell B2")] {
t.cell_mut(r, c).unwrap().text = Story::with_text(StoryId(0), s, ParaFormat::default());
}
let end = st.len();
st.insert_table(end, t);
let end = st.len();
st.insert(end, "Closing");
let end = st.len();
st.insert_endnote(end, "End note text", ParaFormat::default());
d
}
#[test]
fn tables_footnotes_and_endnotes_are_exported() {
let d = tables_and_notes();
let bytes = export_epub(&d, &EpubOptions::default()).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut html = String::new();
z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap();
let (t0, t1) = (html.find("<table>").unwrap(), html.find("</table>").unwrap());
for cell in ["Cell A1", "Cell B1", "Cell A2", "Cell B2"] {
let at = html.find(&format!("<td><p class=\"basic-paragraph\">{cell}</p>\n</td>")).unwrap_or_else(|| panic!("{cell} in a cell:\n{html}"));
assert!(t0 < at && at < t1);
}
assert_eq!(html.matches("<tr>").count(), 2);
let noteref = "Body<sup><a epub:type=\"noteref\" role=\"doc-noteref\" id=\"fnref-1\" href=\"#fn-1\">1</a></sup> text";
assert!(html.contains(noteref), "footnote reference:\n{html}");
let aside = html.find("<aside epub:type=\"footnote\" role=\"doc-footnote\" id=\"fn-1\">").unwrap();
let aside_end = aside + html[aside..].find("</aside>").unwrap();
assert!(html[aside..aside_end].contains("<a href=\"#fnref-1\">1</a> Foot note text"));
assert!(aside < t0, "the footnote follows its paragraph");
assert!(html.contains("Closing<sup><a epub:type=\"noteref\" role=\"doc-noteref\" id=\"enref-1\" href=\"#en-1\">1</a></sup>"));
let section = html.find("<section epub:type=\"endnotes\" role=\"doc-endnotes\" id=\"endnotes\">").unwrap();
assert!(section > html.find("Closing").unwrap());
assert!(html[section..].contains("<li epub:type=\"endnote\" id=\"en-1\">"));
assert!(html[section..].contains("<a href=\"#enref-1\">1</a> End note text"));
let mut nav = String::new();
z.by_name("OEBPS/nav.xhtml").unwrap().read_to_string(&mut nav).unwrap();
assert!(nav.contains("href=\"content.xhtml#endnotes\""));
let page = export_html(&d, &HtmlOptions::default());
assert!(page.contains("<aside role=\"doc-footnote\" id=\"fn-1\">") && page.contains("Cell B2") && page.contains("End note text"));
assert!(!page.contains("epub:type"), "plain HTML has no EPUB namespace");
}
/// A footnote or a table after a list paragraph or a preformatted line closes the list or
/// block first: `aside` and `table` can't sit inside `ul` or `pre`.
#[test]
fn notes_and_tables_close_an_open_list_or_pre_block() {
for (tag, list, open, close) in [("li", Some(designcraft_doc::ListType::Bullets), "<ul>", "</ul>"), ("pre", None, "<pre", "</pre>")] {
let mut d = tables_and_notes();
let para = designcraft_doc::ParaAttrs { list_type: list, ..Default::default() };
d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle {
name: "Grouped".into(),
based_on: None,
next_style: None,
para,
chars: CharAttrs::default(),
shortcut: String::new(),
});
d.styles_mut().export_tags.insert("p:Grouped".into(), designcraft_doc::ExportTag { tag: tag.into(), class: String::new() });
let sid = d.stories.keys().copied().find(|sid| d.story(*sid).is_some_and(|st| !st.notes.is_empty())).unwrap();
for p in &mut d.story_mut(sid).unwrap().paras {
p.style = "Grouped".into();
}
let html = story_html(&d, sid);
let mut rest = html.as_str();
while let Some(at) = rest.find(open) {
let block = &rest[at..];
let end = block.find(close).unwrap_or_else(|| panic!("{open} is closed:\n{html}"));
assert!(!block[..end].contains("<aside") && !block[..end].contains("<table"), "{html}");
rest = &block[end..];
}
assert!(html.contains("<aside") && html.contains("<table>"), "{html}");
}
}
#[test]
fn table_rows_groups_and_merged_cells() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 400.0), lid, "", ParaFormat::default()).unwrap();
// One header row, two body rows, one footer row; the header spans both columns and the
// first body cell spans both body rows and runs into the footer.
let mut t = Table::new(1, 2, 2, 1, 1, 200.0);
for (r, c, s) in [(0, 0, "Head"), (1, 0, "Tall"), (1, 1, "B1"), (2, 1, "B2"), (3, 0, "F1"), (3, 1, "F2")] {
t.cell_mut(r, c).unwrap().text = Story::with_text(StoryId(0), s, ParaFormat::default());
}
t.cell_mut(0, 0).unwrap().col_span = 2;
t.cell_mut(1, 0).unwrap().row_span = 3;
d.story_mut(sid).unwrap().insert_table(0, t);
let html = story_html(&d, sid);
let p = |s: &str| format!("<p class=\"basic-paragraph\">{s}</p>\n");
let expected = format!(
"<table>\n<thead>\n<tr><th colspan=\"2\">{}</th>\n</tr>\n</thead>\n<tbody>\n<tr><td rowspan=\"2\">{}</td>\n<td>{}</td>\n</tr>\n<tr><td>{}</td>\n</tr>\n</tbody>\n<tfoot>\n<tr><td>{}</td>\n<td>{}</td>\n</tr>\n</tfoot>\n</table>\n",
p("Head"),
p("Tall"),
p("B1"),
p("B2"),
p("F1"),
p("F2")
);
assert!(html.contains(&expected), "{html}");
}
/// Note links stay working across chapter files: a link whose target ended up in another
/// file (endnotes end the book) names that file; one in its own file stays as it is.
#[test]
fn note_links_name_the_chapter_file_of_their_target() {
let files: BTreeMap<String, String> =
[("enref-1", "content.xhtml"), ("fn-1", "content.xhtml"), ("fnref-1", "content.xhtml"), ("en-1", "content2.xhtml")]
.into_iter()
.map(|(id, f)| (id.to_string(), f.to_string()))
.collect();
let first = "<p>First<a id=\"enref-1\" href=\"#en-1\">1</a><a id=\"fnref-1\" href=\"#fn-1\">*</a></p><aside id=\"fn-1\"><a href=\"#fnref-1\">*</a></aside><a href=\"#gone\">x</a>";
assert_eq!(
link_across_chapters(first, "content.xhtml", &files),
"<p>First<a id=\"enref-1\" href=\"content2.xhtml#en-1\">1</a><a id=\"fnref-1\" href=\"#fn-1\">*</a></p><aside id=\"fn-1\"><a href=\"#fnref-1\">*</a></aside><a href=\"#gone\">x</a>"
);
let last = "<li id=\"en-1\"><a href=\"#enref-1\">1</a> End note</li>";
assert_eq!(link_across_chapters(last, "content2.xhtml", &files), "<li id=\"en-1\"><a href=\"content.xhtml#enref-1\">1</a> End note</li>");
}
}