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 })));
}
}