From f51e89680217be59e112117be09f76ad0e4c844b Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 16:16:00 +0200 Subject: [PATCH 1/2] EPUB/HTML: list paragraphs share one ul/ol, and pre lines one block (#767) story_html wrote every paragraph on its own, so li paragraphs had no list around them and each line of a pre style was its own
.
Consecutive li paragraphs now share one list: ol for a numbered style,
ul for a bulleted one, and an unmarked ul (list-style:none) when the
style has no list type of its own (typed bullets). Consecutive pre
paragraphs of one style (and direction) become one 
 with their
lines separated by newlines.

Fixes #767
---
 crates/epub/src/lib.rs | 100 +++++++++++++++++++++++++++++++++++++++--
 1 file changed, 97 insertions(+), 3 deletions(-)

diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs
index 77b509d0b..f500bcd47 100644
--- a/crates/epub/src/lib.rs
+++ b/crates/epub/src/lib.rs
@@ -430,13 +430,41 @@ pub fn story_html(doc: &Document, sid: StoryId) -> 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();
+    // 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.
+    let mut list: Option<&str> = None;
+    let mut pre: Option<(String, bool)> = None;
     for (pi, r) in st.para_ranges().iter().enumerate() {
         let pf = &st.paras[pi];
         // Export Tagging: the style's element and class.
         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 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("
\n"); + } + let list_tag = (tag == "li").then(|| 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 { + if let Some(l) = list { + let _ = writeln!(out, "", l.split(' ').next().unwrap_or(l)); + } + if let Some(l) = list_tag { + let _ = writeln!(out, "<{l}>"); + } + list = list_tag; + } + if continues_pre { + out.push('\n'); + } else { + let _ = write!(out, "<{tag} class=\"{}\"{}>", xml_attr(&class), if rtl { " dir=\"rtl\"" } else { "" }); + } if text_empty { out.push_str(" "); } @@ -477,7 +505,18 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { let _ = write!(out, "<{ctag}{cls}{style_attr}>{t}"); } } - let _ = writeln!(out, ""); + if tag == "pre" { + // Closed when the next paragraph isn't a line of the same block. + pre = Some((class, rtl)); + } else { + let _ = writeln!(out, ""); + } + } + if pre.is_some() { + out.push_str("
\n"); + } + if let Some(l) = list { + let _ = writeln!(out, "", l.split(' ').next().unwrap_or(l)); } out } @@ -788,6 +827,61 @@ mod tests { assert!(z.by_name("OEBPS/nav.xhtml").is_ok()); } + 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!("
  • {t}
  • "); + assert!( + html.contains(&format!( + "\n
      \n{}\n{}\n
    \n", + li("bullet", "first"), + li("bullet", "second"), + li("number", "one"), + li("number", "two") + )), + "{html}" + ); + assert!(html.contains("
    x = 1\ny = 2
    \n

    End

    "), "{html}"); + assert_eq!(html.matches("\n
  • • typed
  • \n\n"), "{html}"); + } + #[test] fn styles_whose_names_slug_alike_keep_their_own_classes() { let mut d = Document::new(&NewDocument::default()); From 553a04311a0aa3eeb667a5ffbfa90d0a6086741f Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 16:16:35 +0200 Subject: [PATCH 2/2] EPUB/HTML: bool::then_some for the list tag (clippy) Refs #767 --- crates/epub/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index f500bcd47..06745d16c 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -446,7 +446,7 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String { if !continues_pre && pre.take().is_some() { out.push_str("\n"); } - let list_tag = (tag == "li").then(|| match props.list_type { + 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\"",