diff --git a/assets/bugfixes/issue-463/after.jpg b/assets/bugfixes/issue-463/after.jpg new file mode 100644 index 00000000..58e84d9b Binary files /dev/null and b/assets/bugfixes/issue-463/after.jpg differ diff --git a/assets/bugfixes/issue-463/before.jpg b/assets/bugfixes/issue-463/before.jpg new file mode 100644 index 00000000..ae96b4f6 Binary files /dev/null and b/assets/bugfixes/issue-463/before.jpg differ diff --git a/assets/bugfixes/issue-463/gt.jpg b/assets/bugfixes/issue-463/gt.jpg new file mode 100644 index 00000000..4c897d86 Binary files /dev/null and b/assets/bugfixes/issue-463/gt.jpg differ diff --git a/crates/office2pdf/src/render/typst_gen.rs b/crates/office2pdf/src/render/typst_gen.rs index cf0e07ae..8bc5baff 100644 --- a/crates/office2pdf/src/render/typst_gen.rs +++ b/crates/office2pdf/src/render/typst_gen.rs @@ -1567,17 +1567,9 @@ fn generate_block(out: &mut String, block: &Block, ctx: &mut GenCtx) -> Result<( let settings: Option = first_paragraph.and_then(|paragraph| { word_line_height_settings(¶graph.runs, ¶graph.style, ctx.line_grid_pitch) }); - if let Some(settings) = settings { - out.push_str("#block(width: 100%)[\n"); - out.push_str(&settings); - // The wrapper's line box spans Word's full line advance, so - // the item gaps are the raw `w:spacing` values. - generate_list(out, list, true)?; - out.push_str("]\n"); - Ok(()) - } else { - generate_list(out, list, false) - } + // `generate_list` emits the wrapper itself, so the line box and + // the list's own `w:spacing` gaps share one block (issue #463). + generate_list(out, list, settings.as_deref()) } Block::MathEquation(math) => { generate_math_equation(out, math); diff --git a/crates/office2pdf/src/render/typst_gen_list_tests.rs b/crates/office2pdf/src/render/typst_gen_list_tests.rs index 191b780d..4a946f6f 100644 --- a/crates/office2pdf/src/render/typst_gen_list_tests.rs +++ b/crates/office2pdf/src/render/typst_gen_list_tests.rs @@ -219,7 +219,7 @@ fn test_generate_list_preserves_paragraph_spacing_between_items() { assert!(output.source.contains("spacing: 19pt"), "{}", output.source); assert!( - output.source.contains("#block(below: 19pt)"), + output.source.contains("#block(width: 100%, below: 19pt)"), "{}", output.source ); @@ -269,7 +269,7 @@ fn test_generate_list_uses_word_line_box_and_boundary_spacing() { assert!(source.contains("#set par(leading: 0pt)"), "{source}"); assert!(source.contains("spacing: 8pt"), "{source}"); assert!( - source.contains("#block(above: 0pt, below: 8pt)"), + source.contains("#block(width: 100%, above: 0pt, below: 8pt)"), "{source}" ); } @@ -308,7 +308,7 @@ fn test_generate_list_combines_exact_line_height_with_paragraph_spacing() { assert!(output.source.contains("spacing: 24pt"), "{}", output.source); assert!( - output.source.contains("#block(below: 24pt)"), + output.source.contains("#block(width: 100%, below: 24pt)"), "{}", output.source ); @@ -897,3 +897,59 @@ fn test_generate_list_metric_spacing_is_the_raw_paragraph_gap() { "expected list below spacing to be the raw 4pt gap in: {source}" ); } + +#[test] +fn test_list_wrapper_block_carries_the_edge_spacing() { + // The line-height wrapper used to leave `above`/`below` unset, so + // Typst's own 1.2em default block spacing governed the gap between a + // list and its neighbours while the computed gap sat on an inner block + // where it could not reach the boundary. A numbered item followed by a + // shaded code paragraph opened ~10pt too far (issue #463). + use crate::ir::List; + + let make_item = |text: &str| ListItem { + content: vec![Paragraph { + style: ParagraphStyle { + space_after: Some(4.0), + ..ParagraphStyle::default() + }, + runs: vec![Run { + text: text.to_string(), + style: TextStyle { + font_family: Some("Libertinus Serif".to_string()), + font_size: Some(10.0), + ..TextStyle::default() + }, + href: None, + footnote: None, + }], + }], + level: 0, + start_at: None, + }; + let list = List { + kind: ListKind::Ordered, + items: vec![ + make_item("Convert a single file."), + make_item("Batch convert."), + ], + level_styles: BTreeMap::new(), + }; + let source = generate_typst(&make_doc(vec![make_flow_page(vec![Block::List(list)])])) + .unwrap() + .source; + + assert!( + !source.contains("#block(width: 100%)["), + "the list wrapper must pin its own spacing, not inherit Typst's default: {source}" + ); + let wrapper_start = source + .find("#block(width: 100%") + .expect("the list is wrapped in a full-width block"); + let wrapper_params = + &source[wrapper_start..source[wrapper_start..].find(")[").unwrap() + wrapper_start]; + assert!( + wrapper_params.contains("below: 4pt"), + "the outermost list block carries the item gap: {wrapper_params}" + ); +} diff --git a/crates/office2pdf/src/render/typst_gen_lists.rs b/crates/office2pdf/src/render/typst_gen_lists.rs index e4205fc6..f51acc59 100644 --- a/crates/office2pdf/src/render/typst_gen_lists.rs +++ b/crates/office2pdf/src/render/typst_gen_lists.rs @@ -347,11 +347,21 @@ fn common_list_line_box(list: &List) -> Option { .then_some(first) } +/// Emit a list, wrapped in a single block that carries both the Word line +/// box (`line_height_settings`, when the paragraphs use one) and the list's +/// own `w:spacing` edge gaps. +/// +/// The two used to be separate blocks, with the gaps on the inner one. Typst +/// does not apply block spacing at a container's edges, so the gaps never +/// reached the boundary and the outer wrapper fell back to Typst's own 1.2em +/// `block.spacing` — about 10pt too much after a one-item numbered list +/// (issue #463). pub(super) fn generate_list( out: &mut String, list: &List, - wrapper_spans_full_line: bool, + line_height_settings: Option<&str>, ) -> Result<(), ConvertError> { + let wrapper_spans_full_line: bool = line_height_settings.is_some(); let root_level: u32 = list_root_level(list); let style = list_style_for_level(list, root_level); let fallback_marker_style = common_list_level_text_style(&list.items, root_level); @@ -360,17 +370,22 @@ pub(super) fn generate_list( let (space_before, space_after) = list_edge_spacing(list, root_level, wrapper_spans_full_line); let line_box = common_list_line_box(list); let start_at = list.items.first().and_then(|item| item.start_at); - if space_before.is_some() || space_after.is_some() { - out.push_str("#block("); - write_block_params( - out, - &ParagraphStyle { - space_before, - space_after, - ..ParagraphStyle::default() - }, - ); + let needs_wrapper: bool = + space_before.is_some() || space_after.is_some() || line_height_settings.is_some(); + if needs_wrapper { + // The wrapper must span the full line width: Typst blocks shrink to + // their content by default, which would strand a wide list item. + out.push_str("#block(width: 100%"); + if let Some(above) = space_before { + let _ = write!(out, ", above: {}pt", format_f64(above)); + } + if let Some(below) = space_after { + let _ = write!(out, ", below: {}pt", format_f64(below)); + } out.push_str(")[\n"); + if let Some(settings) = line_height_settings { + out.push_str(settings); + } write_line_box_settings(out, line_box); } write_list_open( @@ -384,7 +399,7 @@ pub(super) fn generate_list( ); generate_list_items(out, list, &list.items, root_level, wrapper_spans_full_line)?; out.push_str(")\n"); - if space_before.is_some() || space_after.is_some() { + if needs_wrapper { out.push_str("]\n"); } Ok(()) diff --git a/crates/office2pdf/src/render/typst_gen_tables.rs b/crates/office2pdf/src/render/typst_gen_tables.rs index f871ef99..0666ecbc 100644 --- a/crates/office2pdf/src/render/typst_gen_tables.rs +++ b/crates/office2pdf/src/render/typst_gen_tables.rs @@ -596,7 +596,7 @@ fn generate_cell_content( if can_render_fixed_text_list_inline(list) { generate_fixed_text_list(out, list, true, None)?; } else { - generate_list(out, list, false)?; + generate_list(out, list, None)?; } } Block::MathEquation(math) => generate_math_equation(out, math),