From 1fc302f69524463e0a3963796fef69d39eabec60 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 16:26:32 +0200 Subject: [PATCH 1/4] EPUB: the navigation lists the headings, with a title in the book's language (#769, #770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nav.xhtml had one entry per placed story, labelled with its first paragraph (body text too), and always the title "Contents". - Headings (h1-h6) get an id in the EPUB, and the navigation lists the h1-h3 headings of the stories in reading order, nested by level and linked to them. A document without headings keeps one entry per story (its first paragraph). - The navigation's title follows the language (Inhoud, Inhalt, Table des matières, Índice, Indice, 目次, 目录; Contents otherwise), and file.exportEpub takes tocTitle to set it. Fixes #769 Fixes #770 --- crates/engine/src/cmd/export.rs | 3 +- crates/epub/src/lib.rs | 183 ++++++++++++++++++++++--- crates/epub/tests/export_tag_styles.rs | 15 +- 3 files changed, 178 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 8e0f04d69..c466a9701 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -12,7 +12,7 @@ pub fn specs() -> Vec { "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?} → {path, bytes, pages, warnings} (no path: {base64, …})", has_doc, export_pdf), cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None, - "{path?, title?, author?, language?: \"en\", cover?: bool (the first page as the cover image), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes} (no path: {base64, bytes})", + "{path?, title?, author?, language?: \"en\", tocTitle?: str (the table of contents' title; from the language by default), cover?: bool (the first page as the cover image), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes} (no path: {base64, bytes})", has_doc, export_epub), cmd!(noundo "file.exportFixedEpub", "Export EPUB (Fixed Layout)…", ["File"], None, "{path?, title?, author?, language?} — pre-paginated EPUB → {path, bytes} (no path: {base64, bytes})", @@ -137,6 +137,7 @@ fn export_epub(s: &mut Session, p: &Value) -> Result { language: p.get("language").and_then(Value::as_str).unwrap_or("en").to_string(), identifier: None, cover, + toc_title: p.get("tocTitle").and_then(Value::as_str).map(str::to_string), }; if fixed { let d = &st.doc; diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 77b509d0b..57e7c9d88 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -22,6 +22,24 @@ pub struct EpubOptions { pub identifier: Option, /// Cover image (PNG), shown first and marked as the book's cover. pub cover: Option>, + /// Title of the navigation (table of contents); from `language` when absent. + pub toc_title: Option, +} + +/// 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, @@ -100,7 +118,7 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions impl Default for EpubOptions { fn default() -> Self { - EpubOptions { title: None, author: None, language: "en".into(), identifier: None, cover: None } + EpubOptions { title: None, author: None, language: "en".into(), identifier: None, cover: None, toc_title: None } } } @@ -427,6 +445,12 @@ fn class_selector(tag: &str, class: &str) -> Option { /// One story as XHTML paragraphs. pub fn story_html(doc: &Document, sid: StoryId) -> String { + story_html_in(doc, sid, None) +} + +/// [`story_html`] with an `id` on every heading (`h1`–`h6`): `{prefix}-p{paragraph index}`, the +/// targets of the table of contents. +fn story_html_in(doc: &Document, sid: StoryId, ids: Option<&str>) -> String { let Some(st) = doc.story(sid) else { return String::new() }; let (pc, cc) = (para_classes(&doc.styles), char_classes(&doc.styles)); let mut out = String::new(); @@ -436,7 +460,11 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { let (tag, class) = export_mapping(&doc.styles, &pf.style, false, &pc); let text_empty = st.text[r.clone()].trim().is_empty(); let rtl = doc.styles.resolve_para(pf).0.direction == designcraft_doc::TextDirection::RightToLeft; - let _ = write!(out, "<{tag} class=\"{}\"{}>", xml_attr(&class), if rtl { " dir=\"rtl\"" } else { "" }); + let id = match ids { + Some(prefix) if 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 { "" }); if text_empty { out.push_str(" "); } @@ -482,6 +510,73 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { out } +/// The level of a heading element (`h1` → 1 … `h6` → 6). +fn heading_level(tag: &str) -> Option { + tag.strip_prefix('h').and_then(|n| n.parse::().ok()).filter(|n| (1..=6).contains(n)) +} + +/// Table of contents entries (target, label, level): the `h1`–`h3` paragraphs of the stories in +/// reading order, or, in a document without headings, each story's first paragraph (level 1). +fn toc_entries(doc: &Document, order: &[Block]) -> Vec<(String, String, u8)> { + let pc = para_classes(&doc.styles); + let label = |t: &str| -> String { t.chars().filter(|c| !('\u{E000}'..='\u{E0FF}').contains(c)).take(80).collect::().trim().to_string() }; + let mut headings = Vec::new(); + let mut firsts = Vec::new(); + for (k, b) in order.iter().enumerate() { + let Block::Story(sid) = b else { continue }; + 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)]) -> String { + let mut out = String::new(); + let mut open: Vec = Vec::new(); + for (target, label, level) in entries { + let item = format!("
  • {}", xml_text(label)); + match open.last().copied() { + None => open.push(*level), + Some(top) if *level > top => { + out.push_str("
      "); + open.push(*level); + } + Some(_) => { + while open.len() > 1 && open.last().is_some_and(|t| t > level) { + open.pop(); + out.push_str("
    "); + } + out.push_str("
  • "); + if let Some(t) = open.last_mut() { + *t = (*t).min(*level); + } + } + } + out.push_str(&item); + } + for i in (0..open.len()).rev() { + out.push_str(""); + if i > 0 { + out.push_str(""); + } + } + out +} + /// Reading order of document content: (page, y, x) of each story's first frame and of graphics. #[derive(Debug, Clone, PartialEq)] pub enum Block { @@ -570,24 +665,17 @@ fn ext(mime: &str) -> &'static str { fn body_html<'a>( doc: &'a Document, img_src: &dyn Fn(&str, &str, &[u8]) -> String, -) -> (String, BTreeMap, Vec<(String, String)>) { + heading_ids: bool, +) -> (String, BTreeMap, Vec<(String, String, u8)>) { let order = reading_order(doc); let mut body = String::new(); let mut images: BTreeMap = BTreeMap::new(); - let mut toc: Vec<(String, String)> = Vec::new(); + let toc = toc_entries(doc, &order); for (k, b) in order.iter().enumerate() { match b { Block::Story(sid) => { let anchor = format!("s{k}"); - // First paragraph text as the TOC label. - if let Some(st) = doc.story(*sid) { - let first: String = - st.text.split('\n').next().unwrap_or("").chars().filter(|c| !('\u{E000}'..='\u{E0FF}').contains(c)).take(60).collect(); - if !first.trim().is_empty() { - toc.push((anchor.clone(), first)); - } - } - let _ = write!(body, "
    \n{}
    \n", story_html(doc, *sid)); + let _ = write!(body, "
    \n{}
    \n", story_html_in(doc, *sid, heading_ids.then_some(anchor.as_str()))); } Block::Image(iid) => { let Some(Content::Graphic(g)) = doc.item(*iid).map(|i| &i.content) else { continue }; @@ -660,7 +748,7 @@ 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, &|_, mime, data| format!("data:{mime};base64,{}", base64(data))); + let (body, _, _) = body_html(doc, &|_, mime, data| format!("data:{mime};base64,{}", base64(data)), false); format!( "\n\n\n\n\n\n{}\n\n\n\n{body}\n\n", xml_text(&title), @@ -686,22 +774,20 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr let zerr = |e: zip::result::ZipError| EpubError(e.to_string()); let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); let id = opts.identifier.clone().unwrap_or_else(|| format!("urn:designcraft:{}", slug(&title))); - let (body, images, toc) = body_html(doc, &|name, _, _| name.to_string()); + let (body, images, toc) = body_html(doc, &|name, _, _| name.to_string(), true); let lang = language_tag(&opts.language); let chapter = format!( "\n\n\n{}\n\n{body}\n\n", xml_text(&title) ); - let mut nav_items = String::new(); - for (a, label) in &toc { - let _ = write!(nav_items, "
  • {}
  • ", xml_text(label)); - } + let mut nav_items = nav_list(&toc); if nav_items.is_empty() { nav_items = format!("
  • {}
  • ", xml_text(&title)); } let nav = format!( - "\n\n\n{}\n\n\n", - xml_text(&title) + "\n\n\n{}\n\n\n", + xml_text(&title), + xml_text(opts.toc_title.as_deref().filter(|t| !t.trim().is_empty()).unwrap_or(contents_title(&opts.language))) ); let mut manifest = String::from( "\n\n\n", @@ -788,6 +874,60 @@ mod tests { assert!(z.by_name("OEBPS/nav.xhtml").is_ok()); } + /// #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 = |t: &str, l: &str| format!("{l}"); + let want = format!( + "

    Inhoud

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

    One

    ") && content.contains("

    In dit boek

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

    Contents

    1. {}
    ", link("s0", "Opening"))), "{nav}"); + } + #[test] fn styles_whose_names_slug_alike_keep_their_own_classes() { let mut d = Document::new(&NewDocument::default()); @@ -902,6 +1042,7 @@ mod tests { language: "English (US)".into(), identifier: Some("notes&edition".into()), cover: None, + toc_title: None, }, ) .unwrap(); diff --git a/crates/epub/tests/export_tag_styles.rs b/crates/epub/tests/export_tag_styles.rs index 47485b532..b25d31c36 100644 --- a/crates/epub/tests/export_tag_styles.rs +++ b/crates/epub/tests/export_tag_styles.rs @@ -55,7 +55,20 @@ fn package(doc: &Document) -> (String, String) { let opf = read("OEBPS/content.opf"); assert!(xhtml.contains("")); assert!(opf.contains("href=\"style.css\" media-type=\"text/css\"")); - (xhtml, css) + (without_heading_ids(&xhtml), css) +} + +/// The markup without the `id`s headings get as table-of-contents targets (` id="s0-p0"`). +fn without_heading_ids(html: &str) -> String { + let mut out = String::with_capacity(html.len()); + let mut rest = html; + while let Some(i) = rest.find(" id=\"s") { + out.push_str(&rest[..i]); + let after = &rest[i + 6..]; + rest = after.find('"').map_or("", |j| &after[j + 1..]); + } + out.push_str(rest); + out } fn rule<'a>(css: &'a str, selector: &str) -> &'a str { From b0c364ee6001cba6e36e7bce572d7d861a402131 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 16:56:17 +0200 Subject: [PATCH 2/4] EPUB: one content file per chapter, split before each h1 (#768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole book was one content.xhtml, which is slow on e-readers and loses chapter starts. The body is now split before each h1 into content.xhtml, content2.xhtml, …: a section a split goes through is closed and reopened, and a story that opens with its h1 starts in the new file with its id. The manifest and spine list the files in order, and the navigation links each heading to the file it is in. HTML export stays one page. Fixes #768 --- crates/epub/src/lib.rs | 167 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 152 insertions(+), 15 deletions(-) diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 57e7c9d88..3f6dd820d 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -464,6 +464,9 @@ fn story_html_in(doc: &Document, sid: StoryId, ids: Option<&str>) -> String { Some(prefix) if heading_level(tag).is_some() => format!(" id=\"{prefix}-p{pi}\""), _ => String::new(), }; + if ids.is_some() && heading_level(tag) == Some(1) { + out.push_str(CHAPTER_MARK); + } let _ = write!(out, "<{tag}{id} class=\"{}\"{}>", xml_attr(&class), if rtl { " dir=\"rtl\"" } else { "" }); if text_empty { out.push_str(" "); @@ -510,6 +513,50 @@ fn story_html_in(doc: &Document, sid: StoryId, ids: Option<&str>) -> String { out } +/// Where the EPUB body splits into chapter files: before each `h1`. +const CHAPTER_MARK: &str = ""; + +/// The body split at its [`CHAPTER_MARK`]s into chapter bodies, each with its sections closed +/// and the one a split goes through reopened in the next; a part with nothing but the start of a +/// section (a story that opens with its `h1`) moves that start into the next part. +fn split_chapters(body: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut carry: Option = None; + for piece in body.split(CHAPTER_MARK) { + let mut part = carry.take().unwrap_or_default(); + part.push_str(piece); + if let Some(open) = part.rfind("")) { + let tag_end = part[open..].find('>').map_or(part.len(), |e| open + e + 1); + if part[tag_end..].trim().is_empty() { + carry = Some(part[open..].to_string()); + part.truncate(open); + } else { + part.push_str("\n"); + carry = Some("
    \n".to_string()); + } + } + if !part.trim().is_empty() { + out.push(part); + } + } + if let Some(rest) = carry.filter(|c| !c.trim().is_empty()) + && let Some(last) = out.last_mut() + { + // Only an opening tag can be left over; close it where it stands. + last.push_str(&rest); + last.push_str("
    \n"); + } + if out.is_empty() { + out.push(String::new()); + } + out +} + +/// The package file of chapter `k` (0-based): `content.xhtml`, then `content2.xhtml`, …. +fn chapter_file(k: usize) -> String { + if k == 0 { "content.xhtml".to_string() } else { format!("content{}.xhtml", k + 1) } +} + /// The level of a heading element (`h1` → 1 … `h6` → 6). fn heading_level(tag: &str) -> Option { tag.strip_prefix('h').and_then(|n| n.parse::().ok()).filter(|n| (1..=6).contains(n)) @@ -544,11 +591,12 @@ fn toc_entries(doc: &Document, order: &[Block]) -> Vec<(String, String, u8)> { /// 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)]) -> String { +fn nav_list(entries: &[(String, String, u8)], files: &BTreeMap) -> String { let mut out = String::new(); let mut open: Vec = Vec::new(); for (target, label, level) in entries { - let item = format!("
  • {}", xml_text(label)); + let file = files.get(target).map_or("content.xhtml", String::as_str); + let item = format!("
  • {}", xml_text(label)); match open.last().copied() { None => open.push(*level), Some(top) if *level > top => { @@ -776,11 +824,26 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr let id = opts.identifier.clone().unwrap_or_else(|| format!("urn:designcraft:{}", slug(&title))); let (body, images, toc) = body_html(doc, &|name, _, _| name.to_string(), true); let lang = language_tag(&opts.language); - let chapter = format!( - "\n\n\n{}\n\n{body}\n\n", - xml_text(&title) - ); - let mut nav_items = nav_list(&toc); + // One file per chapter (split before each h1), so e-readers needn't load the whole book. + let chapters: Vec = split_chapters(&body) + .into_iter() + .map(|part| { + format!( + "\n\n\n{}\n\n{part}\n\n", + xml_text(&title) + ) + }) + .collect(); + // The file each navigation target (an element id) ended up in. + let mut files: BTreeMap = BTreeMap::new(); + for (k, c) in chapters.iter().enumerate() { + for rest in c.split(" id=\"").skip(1) { + if let Some(id) = rest.split('"').next() { + files.entry(id.to_string()).or_insert_with(|| chapter_file(k)); + } + } + } + let mut nav_items = nav_list(&toc, &files); if nav_items.is_empty() { nav_items = format!("
  • {}
  • ", xml_text(&title)); } @@ -790,8 +853,14 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr xml_text(opts.toc_title.as_deref().filter(|t| !t.trim().is_empty()).unwrap_or(contents_title(&opts.language))) ); let mut manifest = String::from( - "\n\n\n", + "\n\n", ); + let mut spine = String::new(); + for k in 0..chapters.len() { + let item = if k == 0 { "content".to_string() } else { format!("content{}", k + 1) }; + let _ = writeln!(manifest, "", chapter_file(k)); + let _ = writeln!(spine, ""); + } for (k, (name, (mime, _))) in images.iter().enumerate() { let _ = writeln!(manifest, "", xml_attr(name), xml_attr(mime)); } @@ -802,7 +871,7 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr let cover_spine = if opts.cover.is_some() { "\n" } else { "" }; let author = opts.author.as_deref().map(|a| format!("{}", xml_text(a))).unwrap_or_default(); let opf = format!( - "\n\n\n{}\n{}\n{lang}{author}\n2026-01-01T00:00:00Z\n\n\n\n{manifest}\n\n{cover_spine}\n\n\n", + "\n\n\n{}\n{}\n{lang}{author}\n2026-01-01T00:00:00Z\n\n\n\n{manifest}\n\n{cover_spine}{spine}\n\n", xml_text(&id), xml_text(&title) ); @@ -819,11 +888,14 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr ("OEBPS/content.opf", opf.as_bytes()), ("OEBPS/nav.xhtml", nav.as_bytes()), ("OEBPS/style.css", stylesheet(doc).as_bytes()), - ("OEBPS/content.xhtml", chapter.as_bytes()), ] { z.start_file(name, deflate).map_err(zerr)?; z.write_all(data).map_err(io)?; } + for (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)?; @@ -907,12 +979,13 @@ mod tests { }; let nl = EpubOptions { language: "nl".into(), ..Default::default() }; let nav = read(&d, &nl, "OEBPS/nav.xhtml"); - let link = |t: &str, l: &str| format!("{l}"); + let link = |f: &str, t: &str, l: &str| format!("{l}"); + // Each h1 starts a chapter file (#768). let want = format!( "

    Inhoud

    1. {}
      1. {}
    2. {}
    ", - link("s0-p0", "One"), - link("s0-p2", "One point one"), - link("s0-p4", "Two") + 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}"); @@ -925,7 +998,71 @@ mod tests { let lid = plain.default_layer(); plain.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Opening\nrest", ParaFormat::default()).unwrap(); let nav = read(&plain, &EpubOptions::default(), "OEBPS/nav.xhtml"); - assert!(nav.contains(&format!("

    Contents

    1. {}
    ", link("s0", "Opening"))), "{nav}"); + assert!(nav.contains(&format!("

    Contents

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

    \n

    ").count(), "{c}"); + assert!(!c.contains(CHAPTER_MARK)); + } + let opf = read("OEBPS/content.opf"); + assert!(opf.contains("\n\n\n"), "{opf}"); + assert!(opf.contains("\n

    a

    \n

    \n"), vec!["
    \n

    a

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

    A

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

    A

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

    a

    \n{m}

    B

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

    a

    \n
    \n", "
    \n

    B

    \n
    \n"] + ); + assert_eq!(split_chapters(""), vec![String::new()]); } #[test] From b6a62dbd995e1e4ffda4314a36ab7bd95cc0af16 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 14 Oct 2026 10:56:27 +0200 Subject: [PATCH 3/4] EPUB nav: keep a heading under its parent when it is shallower than the one before it Headings h1, h3, h2 put the h2 at the top level: the loop closed every level deeper than the new entry. Close a level only while its parent is as deep as the new entry or deeper, as in aiongg's integration branch (#259, bc9b89fc), with both nesting cases as tests. --- crates/epub/src/lib.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 57e7c9d88..429560655 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -556,7 +556,8 @@ fn nav_list(entries: &[(String, String, u8)]) -> String { open.push(*level); } Some(_) => { - while open.len() > 1 && open.last().is_some_and(|t| t > level) { + // 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(""); } @@ -926,6 +927,17 @@ mod tests { plain.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Opening\nrest", ParaFormat::default()).unwrap(); let nav = read(&plain, &EpubOptions::default(), "OEBPS/nav.xhtml"); assert!(nav.contains(&format!("

    Contents

    1. {}
    ", link("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 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)]), + format!("
  • {}
    1. {}
    2. {}
  • {}
  • ", link("A", "A"), link("B", "B"), link("C", "C"), link("D", "D")) + ); + assert_eq!( + nav_list(&[e("A", 1), e("B", 2), e("C", 3), e("D", 1)]), + format!("
  • {}
    1. {}
      1. {}
  • {}
  • ", link("A", "A"), link("B", "B"), link("C", "C"), link("D", "D")) + ); } #[test] From 1ad6dd8e397a3e279dcc2643f11fccf04c965322 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 14 Oct 2026 11:02:52 +0200 Subject: [PATCH 4/4] EPUB chapters: point in-document links at the chapter file of their target After the split an href="#id" whose target ended up in another chapter file is dead; endnotes (#798) end the book, so their #en-N / #enref-N links cross files. Rewrite such links to file#id from the id-to-file map, as in aiongg's integration branch (#259, 0b353c6f). The test calls the rewrite directly, since note export comes with #798. --- crates/epub/src/lib.rs | 47 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index e7ab914f5..e9cb16cec 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -552,6 +552,28 @@ fn split_chapters(body: &str) -> Vec { out } +/// `chapter` (the file `own`) with each in-document link (`href="#id"`) to an element in another +/// chapter file pointed at that file. +fn link_across_chapters(chapter: &str, own: &str, files: &BTreeMap) -> String { + const HREF: &str = "href=\"#"; + let mut out = String::with_capacity(chapter.len()); + let mut rest = chapter; + while let Some(i) = rest.find(HREF) { + out.push_str(rest.get(..i).unwrap_or_default()); + let tail = rest.get(i + HREF.len()..).unwrap_or_default(); + let id = tail.split('"').next().unwrap_or_default(); + match files.get(id) { + Some(file) if file != own => { + let _ = write!(out, "href=\"{file}#"); + } + _ => out.push_str(HREF), + } + rest = tail; + } + out.push_str(rest); + out +} + /// The package file of chapter `k` (0-based): `content.xhtml`, then `content2.xhtml`, …. fn chapter_file(k: usize) -> String { if k == 0 { "content.xhtml".to_string() } else { format!("content{}.xhtml", k + 1) } @@ -844,6 +866,8 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr } } } + // Note links whose target ended up in another file (endnotes end the book) point at it. + let chapters: Vec = chapters.iter().enumerate().map(|(k, c)| link_across_chapters(c, &chapter_file(k), &files)).collect(); let mut nav_items = nav_list(&toc, &files); if nav_items.is_empty() { nav_items = format!("
  • {}
  • ", xml_text(&title)); @@ -1003,13 +1027,14 @@ mod tests { // 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 e = |t: &str, l: u8| (t.to_string(), t.to_string(), l); + let l = |t: &str| link("content.xhtml", t, t); assert_eq!( nav_list(&[e("A", 1), e("B", 3), e("C", 2), e("D", 1)], &BTreeMap::new()), - format!("
  • {}
    1. {}
    2. {}
  • {}
  • ", link("content.xhtml", "A", "A"), link("content.xhtml", "B", "B"), link("content.xhtml", "C", "C"), link("content.xhtml", "D", "D")) + format!("
  • {}
    1. {}
    2. {}
  • {}
  • ", l("A"), l("B"), l("C"), l("D")) ); assert_eq!( nav_list(&[e("A", 1), e("B", 2), e("C", 3), e("D", 1)], &BTreeMap::new()), - format!("
  • {}
    1. {}
      1. {}
  • {}
  • ", link("content.xhtml", "A", "A"), link("content.xhtml", "B", "B"), link("content.xhtml", "C", "C"), link("content.xhtml", "D", "D")) + format!("
  • {}
    1. {}
      1. {}
  • {}
  • ", l("A"), l("B"), l("C"), l("D")) ); } @@ -1062,6 +1087,24 @@ mod tests { assert!(!html.contains(CHAPTER_MARK) && html.contains("Second text")); } + /// 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_point_at_the_chapter_file_of_their_target() { + let files: BTreeMap = + [("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 = "

    First1*

    x"; + assert_eq!( + link_across_chapters(first, "content.xhtml", &files), + "

    First1*

    x" + ); + let last = "
  • 1 End note
  • "; + assert_eq!(link_across_chapters(last, "content2.xhtml", &files), "
  • 1 End note
  • "); + } + #[test] fn split_chapters_keeps_sections_well_formed() { let m = CHAPTER_MARK;