diff --git a/assets/bugfixes/issue-685/after.jpg b/assets/bugfixes/issue-685/after.jpg new file mode 100644 index 00000000..2193f5d1 Binary files /dev/null and b/assets/bugfixes/issue-685/after.jpg differ diff --git a/assets/bugfixes/issue-685/before.jpg b/assets/bugfixes/issue-685/before.jpg new file mode 100644 index 00000000..1a7716b3 Binary files /dev/null and b/assets/bugfixes/issue-685/before.jpg differ diff --git a/assets/bugfixes/issue-685/gt.jpg b/assets/bugfixes/issue-685/gt.jpg new file mode 100644 index 00000000..bb305094 Binary files /dev/null and b/assets/bugfixes/issue-685/gt.jpg differ diff --git a/crates/office2pdf/src/render/typst_gen_list_tests.rs b/crates/office2pdf/src/render/typst_gen_list_tests.rs index 854686af..587b8f1c 100644 --- a/crates/office2pdf/src/render/typst_gen_list_tests.rs +++ b/crates/office2pdf/src/render/typst_gen_list_tests.rs @@ -1054,3 +1054,63 @@ fn an_item_declaring_no_gap_emits_none() { "an item with no gap emits nothing: {source}" ); } + +/// A hanging-indent bullet separates its glyph from the body with the tab +/// alone (issue #685). +/// +/// `fixed_text_list_marker` appends a space after the glyph, and the +/// hanging-indent branch then appends the tab that carries the gap to the +/// indent. Both together put the body one space past the indent — 101.60pt +/// against a reference's 99.01pt on the audited deck, enough to move a wrap +/// point. +#[test] +fn a_hanging_indent_bullet_separates_with_the_tab_alone() { + use crate::ir::List; + + let list = List { + kind: ListKind::Unordered, + items: vec![ListItem { + content: vec![Paragraph { + style: ParagraphStyle { + indent_left: Some(27.0), + indent_first_line: Some(-27.0), + ..ParagraphStyle::default() + }, + runs: vec![Run { + text: "Bulleted".to_string(), + style: TextStyle::default(), + href: None, + footnote: None, + }], + }], + level: 0, + start_at: None, + }], + level_styles: BTreeMap::new(), + }; + let doc = make_doc(vec![make_fixed_page( + 960.0, + 540.0, + vec![make_fixed_text_box( + 50.0, + 50.0, + 600.0, + 400.0, + Insets::default(), + crate::ir::TextBoxVerticalAlign::Top, + vec![Block::List(list)], + )], + )]); + + let source = generate_typst(&doc).unwrap().source; + + // The tab compiles into segments, so the marker is the first of them. + assert!( + source.contains("let tab_segment_0 = [•]"), + "the glyph is the whole first tab segment: {source}" + ); + assert!( + !source.contains("let tab_segment_0 = [• ]"), + "no space may trail the glyph before the tab: {source}" + ); +} diff --git a/crates/office2pdf/src/render/typst_gen_lists.rs b/crates/office2pdf/src/render/typst_gen_lists.rs index c87175fa..92ffa1bf 100644 --- a/crates/office2pdf/src/render/typst_gen_lists.rs +++ b/crates/office2pdf/src/render/typst_gen_lists.rs @@ -1032,9 +1032,13 @@ fn prepend_fixed_text_list_marker_run( list_style.marker_style.cloned() }; if fixed_text_list_hanging_indent_pt(style).is_some() { + // The tab carries the whole gap to the indent, so the space + // `fixed_text_list_marker` puts after the glyph is a second separator. + // It pushed the text 2.59pt past the indent on the audited deck, which + // is enough to move a wrap point (issue #685). return prepend_marker_run( runs, - format!("{marker_text}\t"), + format!("{}\t", marker_text.trim_end()), normalized_marker_style.as_ref(), ); }