diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index ebb9d3de5..162686eb0 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -436,13 +436,41 @@ fn story_html_with(doc: &Document, sid: StoryId, object: &mut dyn FnMut(&designc 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_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 { + 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(" "); } @@ -497,7 +525,18 @@ fn story_html_with(doc: &Document, sid: StoryId, object: &mut dyn FnMut(&designc 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 } @@ -895,6 +934,61 @@ mod tests { assert!(!story_html(&d, sid).contains("{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());