Lists: a numbered list after a bulleted list keeps its numbering in ODT, RTF and Markdown (#83) (#87)

* Lists: a numbered list after a bulleted list keeps its numbering in ODT, RTF and Markdown (#83)

* Import: a top-level list item of the other kind starts a new list

The writers now split a bulleted list from a following numbered one, but the shared importer merged consecutive list paragraphs back into one list, so ODT and RTF still read the numbered items as bullets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MarekSpodBiedry
2026-10-10 06:35:10 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 8907b589ac
commit c29e70ee8a
5 changed files with 61 additions and 3 deletions
+5 -1
View File
@@ -1221,6 +1221,7 @@ fn write_blocks(blocks: &[FBlock], out: &mut Vec<String>) {
let mut i = 0;
let mut counters = [0u32; 9];
let mut prev_list = false;
let mut prev_top_ordered = false;
while let Some(b) = blocks.get(i) {
match b {
FBlock::Table(t) => {
@@ -1251,9 +1252,12 @@ fn write_blocks(blocks: &[FBlock], out: &mut Vec<String>) {
match p.list {
Some(li) => {
let lv = li.level.min(8) as usize;
if !prev_list {
if !prev_list || (lv == 0 && prev_top_ordered != li.ordered) {
counters = [0; 9];
}
if lv == 0 {
prev_top_ordered = li.ordered;
}
let n = counters.get_mut(lv).map(|c| {
*c += 1;
*c
+10 -2
View File
@@ -383,11 +383,19 @@ impl Builder<'_> {
while let Some(b) = blocks.get(i) {
match b {
FBlock::Para(p) if p.list.is_some() => {
// A run of consecutive list paragraphs is one list.
// A run of consecutive list paragraphs is one list, until a top-level item of
// the other kind (bulleted or numbered) starts a new one.
let mut j = i;
let mut group = Vec::new();
let mut group: Vec<ListInfo> = Vec::new();
let mut top: Option<bool> = None;
while let Some(FBlock::Para(q)) = blocks.get(j) {
let Some(li) = q.list else { break };
if li.level == 0 {
if top.is_some_and(|o| o != li.ordered) {
break;
}
top = Some(li.ordered);
}
group.push(li);
j += 1;
}
+4
View File
@@ -283,6 +283,10 @@ impl Writer {
while let Some(FBlock::Para(q)) = blocks.get(i) {
let Some(li) = q.list else { break };
let l = li.level.min(8) as usize;
// A top-level item of the other kind starts a new list.
if l == 0 && seen[0] && kinds[0] != li.ordered {
break;
}
if let (Some(s), Some(k)) = (seen.get_mut(l), kinds.get_mut(l))
&& !*s
{
+11
View File
@@ -238,6 +238,7 @@ impl Writer {
fn blocks(&mut self, blocks: &[FBlock], intbl: bool, last_end: &str, out: &mut String, depth: usize) {
let mut counter = model::ListCounter::default();
let mut cur_list: Option<usize> = None;
let mut top_ordered = false;
let n = blocks.len();
for (i, b) in blocks.iter().enumerate() {
let end = if i + 1 == n { last_end } else { "\\par" };
@@ -245,6 +246,13 @@ impl Writer {
FBlock::Para(p) => {
let ls = match p.list {
Some(li) => {
// A top-level item of the other kind starts a new list.
if li.level == 0 && cur_list.is_some() && top_ordered != li.ordered {
cur_list = None;
}
if li.level == 0 {
top_ordered = li.ordered;
}
let id = match cur_list {
Some(id) => id,
None => {
@@ -255,6 +263,9 @@ impl Writer {
let FBlock::Para(q) = q else { break };
let Some(ql) = q.list else { break };
let l = ql.level.min(8) as usize;
if l == 0 && seen[0] && kinds[0] != ql.ordered {
break;
}
if let (Some(s), Some(k)) = (seen.get_mut(l), kinds.get_mut(l))
&& !*s
{
+31
View File
@@ -163,6 +163,37 @@ fn check_round_trip(ext: &str, rich: bool) {
assert_eq!(table.rows[1].iter().map(|c| c.text()).collect::<Vec<_>>(), vec!["alpha", "42"], "{ext}");
}
/// Two bullets directly followed by two numbered items.
fn bullets_then_numbers() -> Document {
let mut d = Document::new();
let bul = d.numbering.add_list(wordcraft_doc::ListKind::Bullet);
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
let mut blocks = Vec::new();
for (t, nm) in [("dot one", bul), ("dot two", bul), ("num one", num), ("num two", num)] {
let mut q = Paragraph::with_text(t, CharProps::default()).styled("ListParagraph");
q.props.numbering = Some(wordcraft_doc::props::NumRef { num: nm, level: 0 });
blocks.push(para_block(q));
}
d.body = blocks;
d
}
#[test]
fn numbered_list_after_bullets_keeps_its_kind() {
let d = bullets_then_numbers();
for ext in ["odt", "rtf"] {
let bytes = export(ext, &d).expect("handled").expect("export");
let back = import(ext, &bytes).expect("handled").expect("import");
let b = flat(&back);
let kind = |t: &str| find_para(&b, t).and_then(|p| p.list).map(|l| l.ordered);
assert_eq!(kind("dot two"), Some(false), "{ext}");
assert_eq!(kind("num one"), Some(true), "{ext}");
assert_eq!(kind("num two"), Some(true), "{ext}");
}
let md = String::from_utf8(export("md", &d).expect("handled").expect("export")).unwrap();
assert!(md.contains("1. num one") && md.contains("2. num two"), "{md}");
}
#[test]
fn markdown_round_trip() {
check_round_trip("md", true);