diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs index a25c1f5c6..4f2e4384f 100644 --- a/crates/formats/src/html.rs +++ b/crates/formats/src/html.rs @@ -803,7 +803,7 @@ impl<'r> Builder<'r> { } } if let (Some((o, _)), Some(li)) = (list_level, li_at) { - p.list = Some(ListInfo { ordered: o, level: lists.saturating_sub(1).min(8) as u8 }); + p.list = Some(ListInfo { ordered: o, level: lists.saturating_sub(1).min(8) as u8, start: 1 }); self.para_cont = self.stack.get(li).is_some_and(|e| e.used); if let Some(e) = self.stack.get_mut(li) { e.used = true; diff --git a/crates/formats/src/latex.rs b/crates/formats/src/latex.rs index 31346eb20..cc2157268 100644 --- a/crates/formats/src/latex.rs +++ b/crates/formats/src/latex.rs @@ -2377,7 +2377,7 @@ impl<'a> Parser<'a> { let level = self.lists.len().saturating_sub(1).min(8) as u8; self.para = Para::default(); if let Some(&ordered) = self.lists.last() { - self.para.list = Some(ListInfo { ordered, level }); + self.para.list = Some(ListInfo { ordered, level, start: 1 }); } let label = self.opt_arg(); if let Some(l) = label { @@ -2896,8 +2896,8 @@ Caf\'e na\"ive \c{c}a \v{s}\'{e} and {\bfseries grouped bold} after. assert_eq!(find("On Sample Documents").kind, Kind::Title); assert_eq!(find("Introduction").kind, Kind::Heading(1)); assert_eq!(find("Lists").kind, Kind::Heading(2)); - assert_eq!(find("First").list, Some(ListInfo { ordered: false, level: 0 })); - assert_eq!(find("Nested one").list, Some(ListInfo { ordered: true, level: 1 })); + assert_eq!(find("First").list, Some(ListInfo { ordered: false, level: 0, start: 1 })); + assert_eq!(find("Nested one").list, Some(ListInfo { ordered: true, level: 1, start: 1 })); assert!(ps.iter().any(|p| p.text().trim() == "b) Nested two" && p.list.is_some()), "{:?}", texts(ARTICLE)); assert_eq!(find("Quoted text.").kind, Kind::Quote); assert_eq!(find("Centered.").align, Some(Align::Center)); diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index 6e21e8176..93ef23efe 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -460,7 +460,7 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut while item.len() > 1 && item.last().is_some_and(|x| x.is_empty()) { item.pop(); } - let li = ListInfo { ordered: first.ordered, level: level.min(8) }; + let li = ListInfo { ordered: first.ordered, level: level.min(8), start: first.start }; let inner = if depth >= MAX_NEST { let mut p = Para::default(); p.push_text(&item.join(" "), &Fmt::default()); @@ -1355,7 +1355,10 @@ fn write_blocks(blocks: &[FBlock], out: &mut Vec) { prev_top_ordered = li.ordered; } let n = counters.get_mut(lv).map(|c| { - *c += 1; + if *c == 0 { + *c = li.start.saturating_sub(1); + } + *c = c.saturating_add(1); *c }); for d in counters.iter_mut().skip(lv + 1) { @@ -1703,6 +1706,14 @@ mod tests { assert_eq!(back_table2.rows[1][1].text(), "CRITICAL_DISTINCTIVE_INFO"); } + #[test] + fn ordered_list_keeps_start_number() { + let txt = |md: &str| String::from_utf8_lossy(&crate::txt::export(&import(md))).into_owned(); + assert_eq!(txt("5. a\n6. b"), "5.\ta\r\n6.\tb"); + assert_eq!(txt("3. a\n- b\n4. c"), "3.\ta\r\n•\tb\r\n4.\tc"); + assert_eq!(export(&import("5. a\n6. b")).trim_end(), "5. a\n6. b"); + } + #[test] fn lines_after_line_break_stay_in_paragraph() { use wordcraft_doc::{CharProps, Document, Paragraph, StoryRef, para_block}; @@ -1720,10 +1731,10 @@ mod tests { fn lines_after_line_break_stay_in_list_item_and_quote() { let cases = ["a\n- x", "a\n# h", "a\n1. x", "a\n--", "a\n---", "a\n==", "a\n> q"]; let shapes: [(Kind, Option); 5] = [ - (Kind::Normal, Some(ListInfo { ordered: false, level: 0 })), - (Kind::Normal, Some(ListInfo { ordered: true, level: 0 })), - (Kind::Normal, Some(ListInfo { ordered: false, level: 1 })), - (Kind::Normal, Some(ListInfo { ordered: true, level: 1 })), + (Kind::Normal, Some(ListInfo { ordered: false, level: 0, start: 1 })), + (Kind::Normal, Some(ListInfo { ordered: true, level: 0, start: 1 })), + (Kind::Normal, Some(ListInfo { ordered: false, level: 1, start: 1 })), + (Kind::Normal, Some(ListInfo { ordered: true, level: 1, start: 1 })), (Kind::Quote, None), ]; for s in cases { @@ -1731,7 +1742,7 @@ mod tests { let mut blocks = Vec::new(); if let Some(li) = list.filter(|l| l.level > 0) { let mut parent = Para::new(Kind::Normal); - parent.list = Some(ListInfo { ordered: li.ordered, level: 0 }); + parent.list = Some(ListInfo { ordered: li.ordered, level: 0, start: 1 }); parent.push_text("top", &Fmt::default()); blocks.push(FBlock::Para(parent)); } diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index c2502f2e4..449ba1990 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -112,6 +112,8 @@ pub struct ListInfo { pub ordered: bool, /// 0-based nesting level (0..=8). pub level: u8, + /// Number of the first item of an ordered list (1 unless the source says otherwise). + pub start: u32, } /// Paragraph spacing and indents (points) that a source states on the paragraph itself (RTF); @@ -587,13 +589,25 @@ impl<'a> Builder<'a> { *k = Some(li.ordered); } } + let mut starts: [Option; 9] = [None; 9]; + for li in group { + if let Some(s) = starts.get_mut(li.level.min(8) as usize) + && s.is_none() + { + *s = Some(li.start); + } + } // Levels nobody used follow the first level's kind. let first = group.first().map(|l| l.ordered).unwrap_or(false); let levels: Vec = (0..9) .map(|i| { let ordered = kinds.get(i).copied().flatten().unwrap_or(first); let src = if ordered { &num_levels } else { &bul_levels }; - src.get(i).cloned().unwrap_or_default() + let mut level = src.get(i).cloned().unwrap_or_default(); + if ordered && let Some(s) = starts.get(i).copied().flatten() { + level.start = s; + } + level }) .collect(); let numbering = &mut self.doc.numbering; @@ -851,7 +865,7 @@ fn props_kind(doc: &Document, props: &ParaProps, blank: bool) -> (Kind, Option { + if *c == 0 { + *c = li.start.saturating_sub(1); + } *c = c.saturating_add(1); *c } @@ -1361,12 +1378,12 @@ mod tests { #[test] fn list_labels() { let mut c = ListCounter::default(); - let o = |level| ListInfo { ordered: true, level }; + let o = |level| ListInfo { ordered: true, level, start: 1 }; assert_eq!(c.label(o(0)), "1."); assert_eq!(c.label(o(1)), "a."); assert_eq!(c.label(o(1)), "b."); assert_eq!(c.label(o(0)), "2."); - assert_eq!(c.label(ListInfo { ordered: false, level: 0 }), "•"); + assert_eq!(c.label(ListInfo { ordered: false, level: 0, start: 1 }), "•"); } #[test] diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index 9e2e20ab5..2df9d4519 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -1611,7 +1611,7 @@ impl Body<'_> { { let style = self.lists.iter().rev().find_map(|l| l.0.clone()); let ordered = style.and_then(|s| self.styles.lists.get(&s)).and_then(|v| v.get(lv.min(9)).copied()).unwrap_or(false); - p.list = Some(ListInfo { ordered, level: lv.min(8) as u8 }); + p.list = Some(ListInfo { ordered, level: lv.min(8) as u8, start: 1 }); self.para_cont = self.item_used.last().copied().unwrap_or(false); if let Some(u) = self.item_used.last_mut() { *u = true; diff --git a/crates/formats/src/rtf.rs b/crates/formats/src/rtf.rs index 3a84c6812..49ba6a545 100644 --- a/crates/formats/src/rtf.rs +++ b/crates/formats/src/rtf.rs @@ -1556,7 +1556,7 @@ impl Reader { let list = self.overrides.get(&ls).and_then(|id| self.lists.get(id)); let lv = st.ilvl.min(8); let ordered = list.and_then(|l| l.get(lv as usize).copied()).unwrap_or(false); - p.list = Some(ListInfo { ordered, level: lv }); + p.list = Some(ListInfo { ordered, level: lv, start: 1 }); } p } @@ -3264,7 +3264,7 @@ mod tests { fn list_item_with_a_formatting_change_keeps_its_list_on_reject() { use model::{FmtChange, ListInfo}; // A numbered item Alice centred (it was left-aligned), and a second plain item. - let li = Some(ListInfo { ordered: true, level: 0 }); + let li = Some(ListInfo { ordered: true, level: 0, start: 1 }); let mark = RevMark { author: "Alice".into(), date: "2024-03-15T10:30:00Z".into(), id: None }; let old = Para { list: li, align: Some(Align::Left), ..Default::default() }; let mut item = Para { list: li, align: Some(Align::Center), fmt_change: Some(Box::new(FmtChange { mark, old })), ..Default::default() }; @@ -3294,7 +3294,7 @@ mod tests { use model::{FmtChange, ListInfo}; // Alice took the paragraph out of a numbered list: it is no list item now, but was one. let mark = RevMark { author: "Alice".into(), date: "2024-03-15T10:30:00Z".into(), id: None }; - let old = Para { list: Some(ListInfo { ordered: true, level: 0 }), ..Default::default() }; + let old = Para { list: Some(ListInfo { ordered: true, level: 0, start: 1 }), ..Default::default() }; let mut item = Para { fmt_change: Some(Box::new(FmtChange { mark, old })), ..Default::default() }; item.push_text("Item", &Fmt::default()); let flow = Flow { blocks: vec![FBlock::Para(item)], meta: Meta::default() }; @@ -3310,9 +3310,12 @@ mod tests { fn numbered_item_made_bulleted_gets_its_numbered_list_back_on_reject() { use model::{FmtChange, ListInfo}; let mark = RevMark { author: "Alice".into(), date: "2024-03-15T10:30:00Z".into(), id: None }; - let old = Para { list: Some(ListInfo { ordered: true, level: 0 }), ..Default::default() }; - let mut item = - Para { list: Some(ListInfo { ordered: false, level: 0 }), fmt_change: Some(Box::new(FmtChange { mark, old })), ..Default::default() }; + let old = Para { list: Some(ListInfo { ordered: true, level: 0, start: 1 }), ..Default::default() }; + let mut item = Para { + list: Some(ListInfo { ordered: false, level: 0, start: 1 }), + fmt_change: Some(Box::new(FmtChange { mark, old })), + ..Default::default() + }; item.push_text("Item", &Fmt::default()); let flow = Flow { blocks: vec![FBlock::Para(item)], meta: Meta::default() }; let doc = model::to_doc(&flow); diff --git a/crates/formats/src/tests.rs b/crates/formats/src/tests.rs index 4562b8c72..caad39dce 100644 --- a/crates/formats/src/tests.rs +++ b/crates/formats/src/tests.rs @@ -205,7 +205,7 @@ fn markdown_list_items_that_start_like_blocks_keep_their_text() { let bytes = export("md", &d).unwrap().unwrap(); let blocks = flat(&import("md", &bytes).unwrap().unwrap()); let [FBlock::Para(p)] = blocks.as_slice() else { panic!("{text}: {}", String::from_utf8_lossy(&bytes)) }; - assert_eq!((p.text().as_str(), p.kind, p.list), (text, Kind::Normal, Some(model::ListInfo { ordered: false, level: 0 }))); + assert_eq!((p.text().as_str(), p.kind, p.list), (text, Kind::Normal, Some(model::ListInfo { ordered: false, level: 0, start: 1 }))); } }