diff --git a/assets/bugfixes/issue-351/after.jpg b/assets/bugfixes/issue-351/after.jpg new file mode 100644 index 00000000..5c34a894 Binary files /dev/null and b/assets/bugfixes/issue-351/after.jpg differ diff --git a/assets/bugfixes/issue-351/before.jpg b/assets/bugfixes/issue-351/before.jpg new file mode 100644 index 00000000..e824e7ff Binary files /dev/null and b/assets/bugfixes/issue-351/before.jpg differ diff --git a/assets/bugfixes/issue-351/gt.jpg b/assets/bugfixes/issue-351/gt.jpg new file mode 100644 index 00000000..4c897d86 Binary files /dev/null and b/assets/bugfixes/issue-351/gt.jpg differ diff --git a/crates/office2pdf/src/ir/style.rs b/crates/office2pdf/src/ir/style.rs index 89301cf5..36d15f63 100644 --- a/crates/office2pdf/src/ir/style.rs +++ b/crates/office2pdf/src/ir/style.rs @@ -36,6 +36,9 @@ pub struct ParagraphStyle { pub direction: Option, /// Custom tab stop positions for this paragraph. pub tab_stops: Option>, + /// Paragraph-wide shading fill (`w:pPr/w:shd`), painted behind the full + /// paragraph width like Word's code-block backgrounds. + pub background: Option, } /// A custom tab stop definition. @@ -216,6 +219,9 @@ impl ParagraphStyle { if other.tab_stops.is_some() { self.tab_stops = other.tab_stops.clone(); } + if other.background.is_some() { + self.background = other.background; + } } } diff --git a/crates/office2pdf/src/ir/style_tests.rs b/crates/office2pdf/src/ir/style_tests.rs index 7db34629..47334626 100644 --- a/crates/office2pdf/src/ir/style_tests.rs +++ b/crates/office2pdf/src/ir/style_tests.rs @@ -188,6 +188,7 @@ fn paragraph_style_merge_from_all_none_source_preserves_target() { alignment: TabAlignment::Left, leader: TabLeader::None, }]), + background: Some(Color::new(0xEE, 0xEE, 0xEE)), }; let original: ParagraphStyle = target.clone(); let source = ParagraphStyle::default(); @@ -228,6 +229,7 @@ fn paragraph_style_merge_from_all_some_source_overwrites_target() { space_after: Some(16.0), heading_level: Some(1), direction: Some(TextDirection::Rtl), + background: Some(Color::new(0xF4, 0xF4, 0xF4)), tab_stops: Some(vec![TabStop { position: 144.0, alignment: TabAlignment::Right, diff --git a/crates/office2pdf/src/parser/docx_style_tests.rs b/crates/office2pdf/src/parser/docx_style_tests.rs index 075498cd..d2d5872a 100644 --- a/crates/office2pdf/src/parser/docx_style_tests.rs +++ b/crates/office2pdf/src/parser/docx_style_tests.rs @@ -373,3 +373,21 @@ fn test_doc_default_theme_font_resolves_via_theme() { let no_theme = serde_json::json!({ "fonts": { "ascii": "Arial" } }); assert_eq!(resolve_theme_font_family(&no_theme, &theme), None); } + +#[test] +fn test_paragraph_shading_extracted_as_background() { + // Word paints w:pPr/w:shd behind the whole paragraph (code blocks in + // the CLI-manual fixture); the fill must reach the IR (issue #351). + let mut shaded = docx_rs::Paragraph::new() + .add_run(docx_rs::Run::new().add_text("$ cargo install office2pdf-cli")); + shaded.property = shaded + .property + .shading(docx_rs::Shading::new().fill("F4F4F4")); + let data = build_docx_bytes(vec![shaded]); + + let parser = DocxParser; + let (doc, _warnings) = parser.parse(&data, &ConvertOptions::default()).unwrap(); + let para = first_paragraph(&doc); + + assert_eq!(para.style.background, Some(Color::new(0xF4, 0xF4, 0xF4))); +} diff --git a/crates/office2pdf/src/parser/docx_styles.rs b/crates/office2pdf/src/parser/docx_styles.rs index 8748d816..ac5667e6 100644 --- a/crates/office2pdf/src/parser/docx_styles.rs +++ b/crates/office2pdf/src/parser/docx_styles.rs @@ -195,6 +195,9 @@ pub(super) fn merge_paragraph_style( explicit_tab_overrides, inherited_tab_stops.as_deref(), ), + background: explicit + .background + .or(style_paragraph.and_then(|style| style.background)), } } diff --git a/crates/office2pdf/src/parser/docx_text.rs b/crates/office2pdf/src/parser/docx_text.rs index 3a70c8f6..71619e28 100644 --- a/crates/office2pdf/src/parser/docx_text.rs +++ b/crates/office2pdf/src/parser/docx_text.rs @@ -21,6 +21,7 @@ pub(super) fn extract_paragraph_style(prop: &docx_rs::ParagraphProperty) -> Para let (indent_left, indent_right, indent_first_line) = extract_indent(&prop.indent); let (line_spacing, space_before, space_after) = extract_line_spacing(&prop.line_spacing); let tab_stops = extract_tab_stops(&prop.tabs); + let background = extract_paragraph_shading(&prop.shading); ParagraphStyle { alignment, @@ -34,9 +35,17 @@ pub(super) fn extract_paragraph_style(prop: &docx_rs::ParagraphProperty) -> Para heading_level: None, direction: None, tab_stops, + background, } } +/// Word paints `w:pPr/w:shd` behind the whole paragraph. Only the fill color +/// participates in print output; "auto" means no shading. +fn extract_paragraph_shading(shading: &Option) -> Option { + let shading = shading.as_ref()?; + xml_util::parse_hex_color(&shading.fill) +} + fn extract_indent(indent: &Option) -> (Option, Option, Option) { let Some(indent) = indent else { return (None, None, None); diff --git a/crates/office2pdf/src/render/typst_gen_paragraph_tests.rs b/crates/office2pdf/src/render/typst_gen_paragraph_tests.rs index 70435393..c2e859ec 100644 --- a/crates/office2pdf/src/render/typst_gen_paragraph_tests.rs +++ b/crates/office2pdf/src/render/typst_gen_paragraph_tests.rs @@ -703,3 +703,30 @@ fn test_no_document_grid_keeps_default_line_height() { let result = generate_typst(&doc).unwrap().source; assert!(!result.contains("top-edge"), "no grid: {result}"); } + +#[test] +fn test_generate_paragraph_with_background_shading() { + // w:pPr/w:shd paints the whole paragraph; the block wrapper must carry + // the fill so the shading spans the full line width (issue #351). + let doc = make_doc(vec![make_flow_page(vec![Block::Paragraph(Paragraph { + style: ParagraphStyle { + background: Some(Color::new(0xF4, 0xF4, 0xF4)), + ..ParagraphStyle::default() + }, + runs: vec![Run { + text: "$ cargo install office2pdf-cli".to_string(), + style: TextStyle::default(), + href: None, + footnote: None, + }], + })])]); + let result = generate_typst(&doc).unwrap().source; + assert!( + result.contains("fill: rgb(244, 244, 244)"), + "paragraph shading must fill the block wrapper: {result}" + ); + assert!( + result.contains("#block(width: 100%"), + "shaded paragraphs need the full-width block wrapper: {result}" + ); +} diff --git a/crates/office2pdf/src/render/typst_gen_text.rs b/crates/office2pdf/src/render/typst_gen_text.rs index 7c2d69bc..3a82f504 100644 --- a/crates/office2pdf/src/render/typst_gen_text.rs +++ b/crates/office2pdf/src/render/typst_gen_text.rs @@ -83,6 +83,7 @@ pub(super) fn generate_paragraph( pub(super) fn needs_block_wrapper(style: &ParagraphStyle) -> bool { style.space_before.is_some() || style.space_after.is_some() + || style.background.is_some() || style.line_spacing.is_some() || style.line_box.is_some() || matches!(style.alignment, Some(Alignment::Justify)) @@ -145,6 +146,14 @@ fn write_block_params_continuation(out: &mut String, style: &ParagraphStyle) { if let Some(below) = style.space_after { let _ = write!(out, ", below: {}pt", format_f64(below)); } + if let Some(background) = style.background { + // Word paints w:pPr/w:shd across the full paragraph width. + let _ = write!( + out, + ", fill: rgb({}, {}, {})", + background.r, background.g, background.b + ); + } } pub(super) fn write_par_settings(out: &mut String, style: &ParagraphStyle) {