diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index a47cf2b62..4effa8bef 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 162686eb0..5cdf14184 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,12 +445,18 @@ fn class_selector(tag: &str, class: &str) -> Option { /// One story as XHTML paragraphs (anchored objects left out; see [`story_html_with`]). pub fn story_html(doc: &Document, sid: StoryId) -> String { - story_html_with(doc, sid, &mut |_| String::new()) + story_html_with(doc, sid, None, &mut |_| String::new()) } /// One story as XHTML paragraphs, each anchored object written where it is anchored as the -/// markup `object` returns for it. -fn story_html_with(doc: &Document, sid: StoryId, object: &mut dyn FnMut(&designcraft_doc::AnchoredObject) -> String) -> String { +/// markup `object` returns for it. With `ids`, every heading (`h1`–`h6`) gets an `id`, +/// `{prefix}-p{paragraph index}`: the targets of the table of contents. +fn story_html_with( + doc: &Document, + sid: StoryId, + ids: Option<&str>, + object: &mut dyn FnMut(&designcraft_doc::AnchoredObject) -> String, +) -> 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(); @@ -469,7 +493,14 @@ fn story_html_with(doc: &Document, sid: StoryId, object: &mut dyn FnMut(&designc if continues_pre { out.push('\n'); } else { - 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(), + }; + 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(" "); @@ -541,6 +572,141 @@ fn story_html_with(doc: &Document, sid: StoryId, object: &mut dyn FnMut(&designc 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 +} + +/// `chapter` (the file `own`) with each in-document link (`href="#id"`) to an element in another +/// chapter file pointed at that file. +fn link_across_chapters(chapter: &str, own: &str, files: &BTreeMap) -> String { + const HREF: &str = "href=\"#"; + let mut out = String::with_capacity(chapter.len()); + let mut rest = chapter; + while let Some(i) = rest.find(HREF) { + out.push_str(rest.get(..i).unwrap_or_default()); + let tail = rest.get(i + HREF.len()..).unwrap_or_default(); + let id = tail.split('"').next().unwrap_or_default(); + match files.get(id) { + Some(file) if file != own => { + let _ = write!(out, "href=\"{file}#"); + } + _ => out.push_str(HREF), + } + rest = tail; + } + out.push_str(rest); + out +} + +/// The package file of chapter `k` (0-based): `content.xhtml`, then `content2.xhtml`, …. +fn chapter_file(k: usize) -> String { + if k == 0 { "content.xhtml".to_string() } else { format!("content{}.xhtml", k + 1) } +} + +/// The level of a heading element (`h1` → 1 … `h6` → 6). +fn heading_level(tag: &str) -> Option { + tag.strip_prefix('h').and_then(|n| n.parse::().ok()).filter(|n| (1..=6).contains(n)) +} + +/// Table of contents entries (target, label, level): the `h1`–`h3` paragraphs of the stories in +/// reading order, or, in a document without headings, each story's first paragraph (level 1). +fn toc_entries(doc: &Document, order: &[Block]) -> Vec<(String, String, u8)> { + let pc = para_classes(&doc.styles); + let label = |t: &str| -> String { t.chars().filter(|c| !('\u{E000}'..='\u{E0FF}').contains(c)).take(80).collect::().trim().to_string() }; + let mut headings = Vec::new(); + let mut firsts = Vec::new(); + for (k, b) in order.iter().enumerate() { + let Block::Story(sid) = b else { continue }; + let Some(st) = doc.story(*sid) else { continue }; + let first = label(st.text.split('\n').next().unwrap_or("")); + if !first.is_empty() { + firsts.push((format!("s{k}"), first, 1)); + } + for (pi, r) in st.para_ranges().iter().enumerate() { + let Some(pf) = st.paras.get(pi) else { continue }; + let (tag, _) = export_mapping(&doc.styles, &pf.style, false, &pc); + let Some(level) = heading_level(tag).filter(|l| *l <= 3) else { continue }; + let text = label(st.text.get(r.clone()).unwrap_or("")); + if !text.is_empty() { + headings.push((format!("s{k}-p{pi}"), text, level)); + } + } + } + if headings.is_empty() { firsts } else { headings } +} + +/// The table of contents as nested `li`/`ol` items (the content of the navigation's outer +/// `ol`): a deeper level goes into the previous item, skipped levels count as one. +fn nav_list(entries: &[(String, String, u8)], files: &BTreeMap) -> String { + let mut out = String::new(); + let mut open: Vec = Vec::new(); + for (target, label, level) in entries { + let file = files.get(target).map_or("content.xhtml", String::as_str); + let item = format!("
  • {}", xml_text(label)); + match open.last().copied() { + None => open.push(*level), + Some(top) if *level > top => { + out.push_str("
      "); + open.push(*level); + } + Some(_) => { + // Close the levels whose parent is as deep as this entry or deeper. + while open.len() > 1 && open.len().checked_sub(2).and_then(|i| open.get(i)).is_some_and(|parent| *parent >= *level) { + open.pop(); + out.push_str("
    "); + } + out.push_str("
  • "); + if let Some(t) = open.last_mut() { + *t = (*t).min(*level); + } + } + } + out.push_str(&item); + } + for i in (0..open.len()).rev() { + out.push_str(""); + if i > 0 { + out.push_str(""); + } + } + out +} + /// Reading order of document content: (page, y, x) of each story's first frame and of graphics. #[derive(Debug, Clone, PartialEq)] pub enum Block { @@ -673,23 +839,16 @@ fn graphic_html<'a>( 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)); - } - } // Anchored objects go where they are anchored: their graphics in the text, an // above-line object as a block of its own. let mut object = |o: &designcraft_doc::AnchoredObject| { @@ -711,7 +870,7 @@ fn body_html<'a>( _ => out, } }; - let _ = write!(body, "
    \n{}
    \n", story_html_with(doc, *sid, &mut object)); + let _ = write!(body, "
    \n{}
    \n", story_html_with(doc, *sid, heading_ids.then_some(anchor.as_str()), &mut object)); } Block::Image(iid) => { let Some(it) = doc.item(*iid) else { continue }; @@ -750,7 +909,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), @@ -776,26 +935,47 @@ 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)); + // One file per chapter (split before each h1), so e-readers needn't load the whole book. + let chapters: Vec = split_chapters(&body) + .into_iter() + .map(|part| { + format!( + "\n\n\n{}\n\n{part}\n\n", + xml_text(&title) + ) + }) + .collect(); + // The file each navigation target (an element id) ended up in. + let mut files: BTreeMap = BTreeMap::new(); + for (k, c) in chapters.iter().enumerate() { + for rest in c.split(" id=\"").skip(1) { + if let Some(id) = rest.split('"').next() { + files.entry(id.to_string()).or_insert_with(|| chapter_file(k)); + } + } } + // Note links whose target ended up in another file (endnotes end the book) point at it. + let chapters: Vec = chapters.iter().enumerate().map(|(k, c)| link_across_chapters(c, &chapter_file(k), &files)).collect(); + let mut nav_items = nav_list(&toc, &files); if nav_items.is_empty() { nav_items = format!("
  • {}
  • ", xml_text(&title)); } let nav = format!( - "\n\n\n{}\n\n\n", - xml_text(&title) + "\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", + "\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)); } @@ -806,7 +986,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) ); @@ -823,11 +1003,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)?; @@ -989,6 +1172,155 @@ mod tests { assert!(html.contains("
      \n
    • • typed
    • \n
    \n"), "{html}"); } + /// #769, #770: the navigation lists the h1–h3 headings (nested by level) and links to them, + /// with its title in the book's language; without headings, each story's first paragraph. + #[test] + fn navigation_follows_the_headings() { + let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() }); + let lid = d.default_layer(); + for (name, tag) in [("Chapter", "h1"), ("Section", "h2")] { + d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { + name: name.into(), + based_on: None, + next_style: None, + para: Default::default(), + chars: CharAttrs::default(), + shortcut: String::new(), + }); + d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new() }); + } + let text = "One\nBody\nOne point one\nMore\nTwo\nText"; + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, text, ParaFormat::default()).unwrap(); + let st = d.story_mut(sid).unwrap(); + for (i, style) in [(0, "Chapter"), (2, "Section"), (4, "Chapter")] { + st.paras[i].style = style.into(); + } + // A story that starts with body text adds nothing to the table of contents. + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 400.0, 300.0, 500.0), lid, "Text after the figure.", ParaFormat::default()).unwrap(); + let read = |d: &Document, opts: &EpubOptions, path: &str| { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap(); + let mut s = String::new(); + z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); + s + }; + let nl = EpubOptions { language: "nl".into(), ..Default::default() }; + let nav = read(&d, &nl, "OEBPS/nav.xhtml"); + let link = |f: &str, t: &str, l: &str| format!("{l}"); + // Each h1 starts a chapter file (#768). + let want = format!( + "

    Inhoud

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

    One

    ") && content.contains("

    In dit boek

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

    Contents

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

    \n

    ").count(), "{c}"); + assert!(!c.contains(CHAPTER_MARK)); + } + let opf = read("OEBPS/content.opf"); + assert!(opf.contains("\n\n\n"), "{opf}"); + assert!(opf.contains(" = + [("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; + assert_eq!(split_chapters("
    \n

    a

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

    a

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

    A

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

    A

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

    a

    \n{m}

    B

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

    a

    \n
    \n", "
    \n

    B

    \n
    \n"] + ); + assert_eq!(split_chapters(""), vec![String::new()]); + } + #[test] fn styles_whose_names_slug_alike_keep_their_own_classes() { let mut d = Document::new(&NewDocument::default()); @@ -1103,6 +1435,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 {