Keep an ordered list's start number in Markdown import (#3103)

Found while testing crates/formats/src/markdown.rs:160-161,463 and crates/formats/src/model.rs:111 (ListInfo)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-19 12:32:33 -04:00
committed by GitHub
co-authored by Claude Haiku 5.5
parent 74ab8d2819
commit e8866ea58b
7 changed files with 54 additions and 23 deletions
+1 -1
View File
@@ -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;
+3 -3
View File
@@ -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));
+18 -7
View File
@@ -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<String>) {
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<ListInfo>); 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));
}
+21 -4
View File
@@ -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<u32>; 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<Level> = (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<L
};
let list = rp.numbering.and_then(|n| {
let lv = doc.numbering.level(n.num, n.level)?;
Some(ListInfo { ordered: !matches!(lv.format, NumFormat::Bullet | NumFormat::None), level: n.level.min(8) })
Some(ListInfo { ordered: !matches!(lv.format, NumFormat::Bullet | NumFormat::None), level: n.level.min(8), start: lv.start })
});
(kind, list)
}
@@ -1249,6 +1263,9 @@ impl ListCounter {
}
let n = match self.counts.get_mut(lv) {
Some(c) => {
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]
+1 -1
View File
@@ -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;
+9 -6
View File
@@ -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);
+1 -1
View File
@@ -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 })));
}
}