diff --git a/README.md b/README.md index 757aec0a..11d06ea9 100644 --- a/README.md +++ b/README.md @@ -304,7 +304,11 @@ word start the next line. A line left holding only tabs keeps their runs' height. Centred, right and decimal stops keep their field on the line. A negative left or right indent widens a body paragraph's lines into the margin, as Word does; table cells still clamp it to zero until they clip their -content at the cell edge (#2261). +content at the cell edge (#2261). A body list item's marker likewise starts at +its signed left indent minus its hanging span, and its lines at the signed left +indent, even left of the margin. While a paragraph style's `w:ind` still +stands in for a direct `w:numPr` level's (#2383), such an item keeps its marker +at the margin. DOCX section body text supports top, centered and bottom page alignment, including paragraph after-spacing and page breaks. Continuous sections use their diff --git a/assets/bugfixes/issue-2233/after.jpg b/assets/bugfixes/issue-2233/after.jpg index c6519386..d22e0db5 100644 Binary files a/assets/bugfixes/issue-2233/after.jpg and b/assets/bugfixes/issue-2233/after.jpg differ diff --git a/assets/bugfixes/issue-2233/before.jpg b/assets/bugfixes/issue-2233/before.jpg index 8099785b..a260e73c 100644 Binary files a/assets/bugfixes/issue-2233/before.jpg and b/assets/bugfixes/issue-2233/before.jpg differ diff --git a/assets/bugfixes/issue-2233/gt.jpg b/assets/bugfixes/issue-2233/gt.jpg index be250703..ddc91a43 100644 Binary files a/assets/bugfixes/issue-2233/gt.jpg and b/assets/bugfixes/issue-2233/gt.jpg differ diff --git a/assets/bugfixes/issue-2233/layout-audit.json b/assets/bugfixes/issue-2233/layout-audit.json index 268b6b93..e2676e8c 100644 --- a/assets/bugfixes/issue-2233/layout-audit.json +++ b/assets/bugfixes/issue-2233/layout-audit.json @@ -2,31 +2,31 @@ "pages": [ { "lines": { - "gt": 3, - "out": 3, - "matched": 3, + "gt": 1, + "out": 1, + "matched": 1, "missing": 0, "extra": 0, - "deviant": 0, + "deviant": 1, "missing_text": [], "extra_text": [] }, "baseline": { - "mean_abs_dy": 0.04504733333331975, - "worst_dy": 0.08896000000002857, - "worst_dy_signed": 0.08896000000002857, - "worst_line": "Aboutprovidinginformation" + "mean_abs_dy": 0.2096899999999664, + "worst_dy": 0.2096899999999664, + "worst_dy_signed": -0.2096899999999664, + "worst_line": "1.Testmarker" }, "dx0": { - "mean_abs": 0.07666666666666326, - "worst": 0.10499999999998977 + "mean_abs": 0.025000000000005684, + "worst": -0.025000000000005684 }, "width": { - "mean_abs_pct": 0.15638470487368875, - "worst_pct": 0.31213315474126385 + "mean_abs_pct": 1.2162516818425884, + "worst_pct": 1.2162516818425884 }, "instances": { - "compared": 3, + "compared": 1, "large_shift_threshold": 5.0, "large_shift_count": 0, "large_shifts": [], @@ -47,8 +47,8 @@ "mismatches": [] }, "pitch": { - "pairs": 2, - "worst_delta": 0.12546999999999287 + "pairs": 0, + "worst_delta": 0.0 }, "wraps": { "count": 0, diff --git a/assets/bugfixes/issue-2233/render-clusters-page-1.json b/assets/bugfixes/issue-2233/render-clusters-page-1.json index 74b4f907..0a15d94f 100644 --- a/assets/bugfixes/issue-2233/render-clusters-page-1.json +++ b/assets/bugfixes/issue-2233/render-clusters-page-1.json @@ -1,195 +1,67 @@ { "clusters": [ { - "area_pt2": 30.4128, - "bbox_pt": { - "height": 9.6, - "width": 8.64, - "x": 85.44, - "y": 60.0 - }, - "disposition": { - "class": "glyph-edge-rasterization", - "kind": "accepted-rendering", - "note": "Inspected in the 150 DPI diff and 600 DPI GT/after crops: 'Recipient' (R, e, c, i) differs only in glyph outlines at the same positions, Word's embedded Times New Roman against this host's metric-compatible Liberation Serif; its start is 0.10pt and its baseline 0.01pt from Word's." - }, - "id": "p1-5dc5304100ba", - "region": "top-left" - }, - { - "area_pt2": 30.4128, + "area_pt2": 96.768, "bbox_pt": { "height": 7.2, - "width": 5.76, - "x": 95.04, - "y": 62.88 + "width": 19.2, + "x": 107.04, + "y": 78.24 }, "disposition": { - "class": "glyph-edge-rasterization", - "kind": "accepted-rendering", - "note": "Inspected in the 150 DPI diff and 600 DPI GT/after crops: 'Recipient' (R, e, c, i) differs only in glyph outlines at the same positions, Word's embedded Times New Roman against this host's metric-compatible Liberation Serif; its start is 0.10pt and its baseline 0.01pt from Word's." + "issue": "#1874", + "kind": "issue", + "note": "\"est marker\" sits 1.05-1.07pt right of Word: Word kerns the T-e pair (e at 88.03pt against our 89.10pt) in this package without a styles part, while the run states no w:kern. The 150 DPI crop shows the same glyphs, outlines and weight, offset by one pair kern; \"T\" matches at 80.55pt against 80.525pt. Sub-material, recorded under #1874." }, - "id": "p1-132d48374563", + "id": "p1-bab31bb1f25c", "region": "top-left" }, { - "area_pt2": 28.8, + "area_pt2": 90.5472, "bbox_pt": { - "height": 9.6, - "width": 6.72, - "x": 136.8, - "y": 155.52 + "height": 9.12, + "width": 16.32, + "x": 88.32, + "y": 76.32 }, "disposition": { - "class": "glyph-edge-rasterization", - "kind": "accepted-rendering", - "note": "Inspected in the 150 DPI diff and 600 DPI GT/after crops: 'Dear recipient' (D, e, a, r, i, p) differs only in glyph outlines at the same positions, Times New Roman drawn as the metric-compatible Liberation Serif; its start is 0.03pt and its baseline 0.04pt from Word's, unchanged by this fix." + "issue": "#1874", + "kind": "issue", + "note": "\"est marker\" sits 1.05-1.07pt right of Word: Word kerns the T-e pair (e at 88.03pt against our 89.10pt) in this package without a styles part, while the run states no w:kern. The 150 DPI crop shows the same glyphs, outlines and weight, offset by one pair kern; \"T\" matches at 80.55pt against 80.525pt. Sub-material, recorded under #1874." }, - "id": "p1-0762ad287736", + "id": "p1-fdf16f925882", "region": "top-left" }, { - "area_pt2": 28.3392, + "area_pt2": 55.5264, "bbox_pt": { "height": 7.2, - "width": 4.8, - "x": 101.76, - "y": 155.52 + "width": 11.52, + "x": 136.32, + "y": 78.24 }, "disposition": { - "class": "glyph-edge-rasterization", - "kind": "accepted-rendering", - "note": "Inspected in the 150 DPI diff and 600 DPI GT/after crops: 'Dear recipient' (D, e, a, r, i, p) differs only in glyph outlines at the same positions, Times New Roman drawn as the metric-compatible Liberation Serif; its start is 0.03pt and its baseline 0.04pt from Word's, unchanged by this fix." + "issue": "#1874", + "kind": "issue", + "note": "\"est marker\" sits 1.05-1.07pt right of Word: Word kerns the T-e pair (e at 88.03pt against our 89.10pt) in this package without a styles part, while the run states no w:kern. The 150 DPI crop shows the same glyphs, outlines and weight, offset by one pair kern; \"T\" matches at 80.55pt against 80.525pt. Sub-material, recorded under #1874." }, - "id": "p1-be741a945e83", + "id": "p1-56013fea6996", "region": "top-left" }, { - "area_pt2": 27.8784, - "bbox_pt": { - "height": 7.2, - "width": 6.24, - "x": 227.04, - "y": 78.72 - }, - "disposition": { - "class": "glyph-edge-rasterization", - "kind": "accepted-rendering", - "note": "Inspected in the 150 DPI diff and 600 DPI GT/after crops: 'About providing information', the paragraph this fix moves 71pt left, now covers Word's glyphs (A, b, o, u, p, r, n, g, a, t) with outline-only differences, Times New Roman drawn as the metric-compatible Liberation Serif; its start is 0.10pt and its baseline 0.09pt from Word's." - }, - "id": "p1-1031b376123d", - "region": "top" - }, - { - "area_pt2": 24.8832, + "area_pt2": 54.3744, "bbox_pt": { "height": 10.08, - "width": 6.72, - "x": 130.08, - "y": 78.72 - }, - "disposition": { - "class": "glyph-edge-rasterization", - "kind": "accepted-rendering", - "note": "Inspected in the 150 DPI diff and 600 DPI GT/after crops: 'About providing information', the paragraph this fix moves 71pt left, now covers Word's glyphs (A, b, o, u, p, r, n, g, a, t) with outline-only differences, Times New Roman drawn as the metric-compatible Liberation Serif; its start is 0.10pt and its baseline 0.09pt from Word's." - }, - "id": "p1-413d1972e343", - "region": "top-left" - }, - { - "area_pt2": 23.5008, - "bbox_pt": { - "height": 7.2, - "width": 5.76, - "x": 101.28, - "y": 62.88 - }, - "disposition": { - "class": "glyph-edge-rasterization", - "kind": "accepted-rendering", - "note": "Inspected in the 150 DPI diff and 600 DPI GT/after crops: 'Recipient' (R, e, c, i) differs only in glyph outlines at the same positions, Word's embedded Times New Roman against this host's metric-compatible Liberation Serif; its start is 0.10pt and its baseline 0.01pt from Word's." - }, - "id": "p1-a08c2c33abd1", - "region": "top-left" - }, - { - "area_pt2": 23.5008, - "bbox_pt": { - "height": 7.68, - "width": 6.24, - "x": 94.08, - "y": 75.84 - }, - "disposition": { - "class": "glyph-edge-rasterization", - "kind": "accepted-rendering", - "note": "Inspected in the 150 DPI diff and 600 DPI GT/after crops: 'About providing information', the paragraph this fix moves 71pt left, now covers Word's glyphs (A, b, o, u, p, r, n, g, a, t) with outline-only differences, Times New Roman drawn as the metric-compatible Liberation Serif; its start is 0.10pt and its baseline 0.09pt from Word's." - }, - "id": "p1-5c2de5b5fd65", - "region": "top-left" - }, - { - "area_pt2": 23.5008, - "bbox_pt": { - "height": 7.2, - "width": 6.24, - "x": 109.44, - "y": 78.72 - }, - "disposition": { - "class": "glyph-edge-rasterization", - "kind": "accepted-rendering", - "note": "Inspected in the 150 DPI diff and 600 DPI GT/after crops: 'About providing information', the paragraph this fix moves 71pt left, now covers Word's glyphs (A, b, o, u, p, r, n, g, a, t) with outline-only differences, Times New Roman drawn as the metric-compatible Liberation Serif; its start is 0.10pt and its baseline 0.09pt from Word's." - }, - "id": "p1-5e660269328d", - "region": "top-left" - }, - { - "area_pt2": 20.736, - "bbox_pt": { - "height": 10.56, - "width": 4.8, - "x": 104.16, - "y": 75.36 - }, - "disposition": { - "class": "glyph-edge-rasterization", - "kind": "accepted-rendering", - "note": "Inspected in the 150 DPI diff and 600 DPI GT/after crops: 'About providing information', the paragraph this fix moves 71pt left, now covers Word's glyphs (A, b, o, u, p, r, n, g, a, t) with outline-only differences, Times New Roman drawn as the metric-compatible Liberation Serif; its start is 0.10pt and its baseline 0.09pt from Word's." - }, - "id": "p1-b18b0ac62282", - "region": "top-left" - }, - { - "area_pt2": 20.5056, - "bbox_pt": { - "height": 8.64, - "width": 6.24, - "x": 178.56, - "y": 79.68 - }, - "disposition": { - "class": "glyph-edge-rasterization", - "kind": "accepted-rendering", - "note": "Inspected in the 150 DPI diff and 600 DPI GT/after crops: 'About providing information', the paragraph this fix moves 71pt left, now covers Word's glyphs (A, b, o, u, p, r, n, g, a, t) with outline-only differences, Times New Roman drawn as the metric-compatible Liberation Serif; its start is 0.10pt and its baseline 0.09pt from Word's." - }, - "id": "p1-6aeadbc09d9c", - "region": "top-left" - }, - { - "area_pt2": 20.2752, - "bbox_pt": { - "height": 6.24, - "width": 6.24, - "x": 95.04, - "y": 155.52 + "width": 10.08, + "x": 126.72, + "y": 74.88 }, "disposition": { - "class": "glyph-edge-rasterization", - "kind": "accepted-rendering", - "note": "Inspected in the 150 DPI diff and 600 DPI GT/after crops: 'Dear recipient' (D, e, a, r, i, p) differs only in glyph outlines at the same positions, Times New Roman drawn as the metric-compatible Liberation Serif; its start is 0.03pt and its baseline 0.04pt from Word's, unchanged by this fix." + "issue": "#1874", + "kind": "issue", + "note": "\"est marker\" sits 1.05-1.07pt right of Word: Word kerns the T-e pair (e at 88.03pt against our 89.10pt) in this package without a styles part, while the run states no w:kern. The 150 DPI crop shows the same glyphs, outlines and weight, offset by one pair kern; \"T\" matches at 80.55pt against 80.525pt. Sub-material, recorded under #1874." }, - "id": "p1-4b56a2ad43f4", + "id": "p1-cdf915b18a72", "region": "top-left" } ], @@ -204,9 +76,9 @@ "schema_version": 1, "strict": true, "summary": { - "dispositioned": 12, + "dispositioned": 4, "duplicate": 0, - "total": 12, + "total": 4, "undispositioned": 0, "unknown": 0 }, diff --git a/assets/bugfixes/issue-2383/compare.jpg b/assets/bugfixes/issue-2383/compare.jpg new file mode 100644 index 00000000..e84b2ffc Binary files /dev/null and b/assets/bugfixes/issue-2383/compare.jpg differ diff --git a/assets/bugfixes/issue-2384/compare.jpg b/assets/bugfixes/issue-2384/compare.jpg new file mode 100644 index 00000000..9cf3b03c Binary files /dev/null and b/assets/bugfixes/issue-2384/compare.jpg differ diff --git a/assets/bugfixes/issue-2385/compare.jpg b/assets/bugfixes/issue-2385/compare.jpg new file mode 100644 index 00000000..ab7479b5 Binary files /dev/null and b/assets/bugfixes/issue-2385/compare.jpg differ diff --git a/assets/bugfixes/issue-2386/compare.jpg b/assets/bugfixes/issue-2386/compare.jpg new file mode 100644 index 00000000..96bcd56d Binary files /dev/null and b/assets/bugfixes/issue-2386/compare.jpg differ diff --git a/crates/office2pdf/src/parser/docx.rs b/crates/office2pdf/src/parser/docx.rs index 713f0713..5ac323a8 100644 --- a/crates/office2pdf/src/parser/docx.rs +++ b/crates/office2pdf/src/parser/docx.rs @@ -35,7 +35,8 @@ use self::contexts::{ toc_caption_identifier, toc_heading_depth, }; use self::lists::{ - NumberingMap, TaggedElement, build_numbering_map, extract_num_info, group_into_lists, + NumberingMap, StyleSuppliedIndents, TaggedElement, build_numbering_map, extract_num_info, + group_into_lists, }; use self::media::{ DrawingTextBoxPlacement, extract_drawing_image, extract_drawing_text_box_blocks, @@ -59,8 +60,8 @@ use self::styles::{ use self::tables::{convert_table, convert_table_in_text_box}; use self::text::{ ThemeFonts, WORD_UNSTATED_FONT_SIZE_PT, extract_doc_default_paragraph_style, - extract_doc_default_text_style_with_theme, extract_paragraph_style, extract_run_style, - extract_run_style_id, extract_run_text, extract_run_text_marking_clears, + extract_doc_default_text_style_with_theme, extract_indent, extract_paragraph_style, + extract_run_style, extract_run_style_id, extract_run_text, extract_run_text_marking_clears, extract_tab_stop_overrides, insert_east_asian_auto_space, is_column_break, is_page_break, pair_kerning_from_half_points, parse_hex_color, parse_theme_fonts, resolve_hyperlink_url, resolve_latin_theme_font_family, resolve_theme_font_family, take_clearing_break_markers, @@ -1061,6 +1062,25 @@ fn convert_sdt_children( result } +/// Which indents a paragraph with a direct `w:numPr` takes from its style +/// chain because its own `w:ind` does not state them. None when the numbering +/// comes from the style. +fn style_supplied_indents(para: &docx_rs::Paragraph, style_map: &StyleMap) -> StyleSuppliedIndents { + if !para.has_numbering { + return StyleSuppliedIndents::default(); + } + let (left, _, first_line) = extract_indent(¶.property.indent); + let style: Option<&ParagraphStyle> = get_paragraph_style_id(¶.property) + .and_then(|id| style_map.get(id)) + .or_else(|| style_map.get(DOC_DEFAULT_STYLE_ID)) + .map(|resolved| &resolved.paragraph); + StyleSuppliedIndents { + left: left.is_none() && style.is_some_and(|style| style.indent_left.is_some()), + first_line: first_line.is_none() + && style.is_some_and(|style| style.indent_first_line.is_some()), + } +} + fn convert_body_table_block( source_table: &docx_rs::Table, images: &ImageMap, @@ -1085,7 +1105,10 @@ fn convert_paragraph_element( ctx: &DocxConversionContext, styles: &docx_rs::Styles, ) -> Vec { - let num_info = extract_num_info(para, styles); + let num_info = extract_num_info(para, styles).map(|mut info| { + info.style_supplied_indents = style_supplied_indents(para, style_map); + info + }); // Build the paragraph IR let mut blocks = Vec::new(); diff --git a/crates/office2pdf/src/parser/docx_lists.rs b/crates/office2pdf/src/parser/docx_lists.rs index 0991c9a9..5829f34f 100644 --- a/crates/office2pdf/src/parser/docx_lists.rs +++ b/crates/office2pdf/src/parser/docx_lists.rs @@ -10,6 +10,17 @@ use crate::ir::{ pub(super) struct NumInfo { pub(super) num_id: usize, pub(super) level: u32, + /// Under a direct `w:numPr`, which indents the paragraph's style chain + /// supplies where its own `w:ind` is silent. + pub(super) style_supplied_indents: StyleSuppliedIndents, +} + +/// Indents a numbered paragraph takes from its style chain rather than from +/// its own `w:ind`. +#[derive(Debug, Clone, Copy, Default)] +pub(super) struct StyleSuppliedIndents { + pub(super) left: bool, + pub(super) first_line: bool, } #[derive(Debug, Clone)] @@ -349,7 +360,11 @@ pub(super) fn extract_num_info( if num_id == 0 { return None; } - return Some(NumInfo { num_id, level }); + return Some(NumInfo { + num_id, + level, + style_supplied_indents: StyleSuppliedIndents::default(), + }); } let style_id = para @@ -370,7 +385,11 @@ pub(super) fn extract_num_info( if num_id == 0 { return None; } - Some(NumInfo { num_id, level }) + Some(NumInfo { + num_id, + level, + style_supplied_indents: StyleSuppliedIndents::default(), + }) } /// An intermediate element that carries optional numbering info alongside blocks. @@ -532,6 +551,35 @@ fn apply_numbering_level_indentation( } } +/// Whether a style-chain indent stands in for one the paragraph's direct +/// numbering level declares. Word uses the level's there; the parser keeps +/// the style's (#2383). +fn style_indent_masks_level( + supplied: StyleSuppliedIndents, + level_style: Option<&ParagraphStyle>, +) -> bool { + level_style.is_some_and(|level| { + (supplied.left && level.indent_left.is_some()) + || (supplied.first_line && level.indent_first_line.is_some()) + }) +} + +/// Clamps a list paragraph's left indent and marker origin at the margin, the +/// geometry the renderer drew for every list before list indents kept their +/// sign (issue #2233). +fn clamp_list_indent_at_margin(style: &mut ParagraphStyle) { + let Some(left) = style.indent_left.map(|left| left.max(0.0)) else { + return; + }; + style.indent_left = Some(left); + if let Some(first_line) = style + .indent_first_line + .filter(|first_line| *first_line < 0.0) + { + style.indent_first_line = Some((left + first_line).max(0.0) - left); + } +} + fn finalize_list(numbered_items: Vec, numberings: &NumberingMap) -> List { // Build merged per-level styles from every numId present. The first item // encountered at a given level establishes that level's style — adjacent @@ -813,6 +861,16 @@ pub(super) fn group_into_lists( &mut paragraph, resolved_level.map(|level| &level.paragraph_style), ); + // TODO(#2383): Word lays such a paragraph out at its level's + // indent. Until the parser does, keep it at the margin as + // before: hanging the style-chain indent into the margin would + // move it further from Word (issue #2233). + if style_indent_masks_level( + info.style_supplied_indents, + resolved_level.map(|level| &level.paragraph_style), + ) { + clamp_list_indent_at_margin(&mut paragraph.style); + } let mut item = ListItem { content: vec![*paragraph], level: info.level, diff --git a/crates/office2pdf/src/parser/docx_tests.rs b/crates/office2pdf/src/parser/docx_tests.rs index 9a2158bf..1d103ac6 100644 --- a/crates/office2pdf/src/parser/docx_tests.rs +++ b/crates/office2pdf/src/parser/docx_tests.rs @@ -1909,6 +1909,191 @@ fn docx_level_text_literal_text_survives_parent_numbers() { assert_eq!(labels, ["1.1", "1.1.a", "1.1.b", "1.2"]); } +/// Word lets a list paragraph hang into the margin like any other paragraph: +/// its marker starts at the margin plus the signed `w:ind w:left` minus the +/// hanging span, and its body lines start at the margin plus the signed left +/// indent, even when either lies left of the margin (issue #2233). Main +/// clamped both at the margin. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn docx_list_marker_and_body_hang_into_the_margin_at_their_signed_indent() { + let body: String = "Hanging list item ".repeat(14); + // Level format and text, then the left indent and hanging span in twips. + // Each marker is narrower than its hanging span, so the body starts at + // the left indent. + let cases: [(&str, &str, i32, i32); 4] = [ + ("decimal", "%1.", -720, 360), + ("decimal", "%1.", 200, 720), + ("bullet", "\u{2022}", -360, 360), + ("decimal", "%1.", 720, 360), + ]; + for (number_format, level_text, left_twips, hanging_twips) in cases { + let abstract_num = docx_rs::AbstractNumbering::new(0).add_level(hanging_list_level( + 0, + number_format, + level_text, + left_twips, + hanging_twips, + )); + let data = build_docx_with_numbering( + vec![abstract_num], + vec![docx_rs::Numbering::new(1, 0)], + vec![ + docx_rs::Paragraph::new().add_run(docx_rs::Run::new().add_text("Margin")), + docx_rs::Paragraph::new() + .add_run(docx_rs::Run::new().add_text(&body)) + .numbering(docx_rs::NumberingId::new(1), docx_rs::IndentLevel::new(0)), + ], + ); + let (document, _) = DocxParser.parse(&data, &ConvertOptions::default()).unwrap(); + let source = crate::render::typst_gen::generate_typst(&document).unwrap(); + let runs = crate::render::pdf::compiled_text_runs(&source.source, 0).unwrap(); + let margin: f64 = runs + .iter() + .find(|run| run.text == "Margin") + .expect("the margin reference should be placed") + .left_pt; + let first_line = runs + .iter() + .find(|run| run.text.starts_with("Hanging")) + .expect("the list body should be placed"); + let marker = runs + .iter() + .find(|run| { + (run.baseline_pt - first_line.baseline_pt).abs() < 0.01 + && run.left_pt < first_line.left_pt + }) + .unwrap_or_else(|| panic!("no marker beside the body: {runs:?}")); + let continuations: Vec = runs + .iter() + .filter(|run| run.baseline_pt > first_line.baseline_pt + 1.0) + .map(|run| run.left_pt) + .collect(); + let left: f64 = f64::from(left_twips) / 20.0; + let hanging: f64 = f64::from(hanging_twips) / 20.0; + let case = format!("{number_format} left {left}pt, hanging {hanging}pt"); + assert!( + (marker.left_pt - (margin + left - hanging)).abs() < 0.05, + "{case}: the marker must start at {}pt, got {}pt", + margin + left - hanging, + marker.left_pt + ); + assert!( + (first_line.left_pt - (margin + left)).abs() < 0.05, + "{case}: the body must start at {}pt, got {}pt", + margin + left, + first_line.left_pt + ); + assert!( + !continuations.is_empty() + && continuations + .iter() + .all(|start| (start - (margin + left)).abs() < 0.05), + "{case}: wrapped lines must start at {}pt, got {continuations:?}", + margin + left + ); + } +} + +/// The gate `TODO(#2383)` in `group_into_lists` sets: under a direct +/// `w:numPr`, a paragraph style's `w:ind` still stands in for the level's, +/// so such a list keeps its marker at the margin instead of hanging the style +/// chain's indent into it (issue #2233). Word draws these markers at the +/// level's indent, margin + left − hanging (docxide-pdf +/// `slovak_fuel_pricing_report`); delete this test with the gate. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn docx_style_indent_under_direct_numbering_keeps_the_marker_at_the_margin() { + // The style's left indent and hanging span in twips, and where the body + // starts from the margin, in points. + let cases: [(i32, Option, f64); 2] = [(0, None, 0.0), (720, Some(1021), 36.0)]; + for (style_left, style_hanging, body_start) in cases { + let abstract_num = docx_rs::AbstractNumbering::new(0) + .add_level(hanging_list_level(0, "decimal", "%1.", 720, 360)); + let numberings = docx_rs::Numberings::new() + .add_abstract_numbering(abstract_num) + .add_numbering(docx_rs::Numbering::new(1, 0)); + let style = docx_rs::Style::new("Indented", docx_rs::StyleType::Paragraph).indent( + Some(style_left), + style_hanging.map(docx_rs::SpecialIndentType::Hanging), + None, + None, + ); + let docx = docx_rs::Docx::new() + .numberings(numberings) + .add_style(style) + .add_paragraph( + docx_rs::Paragraph::new().add_run(docx_rs::Run::new().add_text("Margin")), + ) + .add_paragraph( + docx_rs::Paragraph::new() + .add_run(docx_rs::Run::new().add_text("Styled item")) + .style("Indented") + .numbering(docx_rs::NumberingId::new(1), docx_rs::IndentLevel::new(0)), + ); + let mut cursor = Cursor::new(Vec::new()); + docx.build().pack(&mut cursor).unwrap(); + let data = cursor.into_inner(); + let (document, _) = DocxParser.parse(&data, &ConvertOptions::default()).unwrap(); + let source = crate::render::typst_gen::generate_typst(&document).unwrap(); + let runs = crate::render::pdf::compiled_text_runs(&source.source, 0).unwrap(); + let margin: f64 = runs + .iter() + .find(|run| run.text == "Margin") + .unwrap() + .left_pt; + let body = runs + .iter() + .find(|run| run.text.contains("Styled item")) + .unwrap_or_else(|| panic!("the item should be placed: {runs:?}")); + let marker_left: f64 = runs + .iter() + .filter(|run| (run.baseline_pt - body.baseline_pt).abs() < 0.01) + .map(|run| run.left_pt) + .fold(f64::INFINITY, f64::min); + assert!( + (marker_left - margin).abs() < 0.05, + "style left {style_left}: the marker must stay at the {margin}pt margin, got {marker_left}pt" + ); + if body_start > 0.0 { + assert!( + (body.left_pt - (margin + body_start)).abs() < 0.05, + "style left {style_left}: the body must start at {}pt, got {}pt", + margin + body_start, + body.left_pt + ); + } + } +} + +/// docxide-pdf `case97` (`numbering_negative_indent.docx`), unmodified: a +/// numbered paragraph with `w:ind w:left="-100" w:hanging="5"` and a left tab +/// at 171 twips. Word draws "1." at 66.775pt, by the 72pt − 5pt − 0.25pt +/// origin, and tabs the body to 80.525pt, by the 8.55pt stop; main drew them +/// at 72pt and 88pt (issue #2233). +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn docx_list_hanging_into_the_margin_starts_where_word_starts_it() { + let data = + include_bytes!("../../../../tests/fixtures/docx/docxide/numbering_negative_indent.docx"); + let (document, _) = DocxParser.parse(data, &ConvertOptions::default()).unwrap(); + let source = crate::render::typst_gen::generate_typst(&document).unwrap(); + let runs = crate::render::pdf::compiled_text_runs(&source.source, 0).unwrap(); + let start = |text: &str| -> f64 { + runs.iter() + .find(|run| run.text.starts_with(text)) + .unwrap_or_else(|| panic!("{text:?} is not painted: {runs:?}")) + .left_pt + }; + for (text, word_start) in [("1.", 66.775), ("Test", 80.525)] { + let placed: f64 = start(text); + assert!( + (placed - word_start).abs() < 0.25, + "Word starts {text:?} at {word_start}pt, got {placed}pt" + ); + } +} + /// Rewrites `word/settings.xml` inside a DOCX, replacing the /// `w:defaultTabStop` element with `replacement` (empty string removes it). fn rewrite_settings_default_tab_stop(docx_bytes: &[u8], replacement: &str) -> Vec { diff --git a/crates/office2pdf/src/parser/docx_text.rs b/crates/office2pdf/src/parser/docx_text.rs index 86369bbf..afc14835 100644 --- a/crates/office2pdf/src/parser/docx_text.rs +++ b/crates/office2pdf/src/parser/docx_text.rs @@ -248,7 +248,9 @@ fn extract_paragraph_borders( Some(Box::new(border)) } -fn extract_indent(indent: &Option) -> (Option, Option, Option) { +pub(super) 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.rs b/crates/office2pdf/src/render/typst_gen.rs index e6f97bcd..2b0744d9 100644 --- a/crates/office2pdf/src/render/typst_gen.rs +++ b/crates/office2pdf/src/render/typst_gen.rs @@ -6350,6 +6350,8 @@ fn generate_block_inner( line_box_em, available_measure_pt: ctx.available_measure_pt, baseline_snap: None, + // A Word list hangs into the margin like its paragraphs. + hangs_into_margin: ctx.breaks_hangul_at_eojeol, }, ctx, ) diff --git a/crates/office2pdf/src/render/typst_gen_lists.rs b/crates/office2pdf/src/render/typst_gen_lists.rs index a764cd1b..1a1e55a4 100644 --- a/crates/office2pdf/src/render/typst_gen_lists.rs +++ b/crates/office2pdf/src/render/typst_gen_lists.rs @@ -289,14 +289,25 @@ fn list_root_level(list: &List) -> u32 { list.items.first().map(|item| item.level).unwrap_or(0) } -fn paragraph_list_indent(style: &ParagraphStyle) -> Option { - let indent_left = style.indent_left?.max(0.0); +/// A list paragraph's marker origin and hanging span from the list's origin, +/// or `None` without a hanging indent. +/// +/// With `hangs_into_margin` both keep their sign, as in a Word body list: a +/// negative `w:ind w:left`, or a hanging span wider than it, starts the marker +/// left of the margin (issue #2233). Otherwise the left indent and the marker +/// clamp at the origin. +fn paragraph_list_indent( + style: &ParagraphStyle, + hangs_into_margin: bool, +) -> Option { + let clamp = |pt: f64| if hangs_into_margin { pt } else { pt.max(0.0) }; + let indent_left = clamp(style.indent_left?); let first_line_indent = style.indent_first_line?; if first_line_indent >= -0.0001 { return None; } - let marker_origin_pt = (indent_left + first_line_indent).max(0.0); + let marker_origin_pt = clamp(indent_left + first_line_indent); let marker_width_pt = indent_left - marker_origin_pt; (marker_width_pt > 0.0001).then_some(ListIndentGeometry { marker_origin_pt, @@ -307,12 +318,13 @@ fn paragraph_list_indent(style: &ParagraphStyle) -> Option { fn common_list_level_indent( items: &[crate::ir::ListItem], level: u32, + hangs_into_margin: bool, ) -> Option { let mut geometries = items .iter() .filter(|item| item.level == level) .filter_map(|item| item.content.first()) - .map(|paragraph| paragraph_list_indent(¶graph.style)); + .map(|paragraph| paragraph_list_indent(¶graph.style, hangs_into_margin)); let first = geometries.next()??; geometries @@ -564,7 +576,7 @@ pub(super) fn generate_list_with_spacing_model( 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); - let indent = common_list_level_indent(&list.items, root_level); + let indent = common_list_level_indent(&list.items, root_level, eojeol_wrap.hangs_into_margin); // Typst applies one root `spacing:` after every complete root item. In a // PowerPoint outline that item can end in a nested child, whose own // a:spcAft is the real boundary gap. Even one adjacent root pair is not @@ -1766,12 +1778,16 @@ const WORD_BODY_ORIGIN: &str = "word_body_origin"; fn word_numbering_suffix_placement( kind: ListKind, paragraph_style: &ParagraphStyle, + hangs_into_margin: bool, ) -> Option<(ListMarkerSuffix, ListIndentGeometry)> { let suffix = paragraph_style.numbering_suffix?; if suffix == ListMarkerSuffix::Tab && kind != ListKind::Ordered { return None; } - Some((suffix, paragraph_list_indent(paragraph_style)?)) + Some(( + suffix, + paragraph_list_indent(paragraph_style, hangs_into_margin)?, + )) } /// What a list's items need to decide their Hangul line breaking (issue #626). @@ -1787,6 +1803,9 @@ pub(super) struct ListEojeolWrap { pub(super) available_measure_pt: Option, /// PowerPoint's raw and layout baseline seats for absolute slide snapping. pub(super) baseline_snap: Option, + /// Whether a marker or body may start left of the list's origin, as a + /// Word body list hanging into the margin does (issue #2233). + pub(super) hangs_into_margin: bool, } /// Word's suffix positions only the first body line; continuation lines keep @@ -1806,8 +1825,10 @@ fn write_word_numbering_suffix_advance( numbers: &[u32], default_tab_width_pt: f64, tabbed_body: Option<&str>, + hangs_into_margin: bool, ) { - let Some((suffix, indent)) = word_numbering_suffix_placement(style.kind, paragraph_style) + let Some((suffix, indent)) = + word_numbering_suffix_placement(style.kind, paragraph_style, hangs_into_margin) else { return; }; @@ -1956,7 +1977,7 @@ fn generate_list_items( let (_, item_func) = list_funcs(style.kind); let marker_fallback = common_list_level_text_style(items, base_level); let uses_word_suffixes = eojeol_wrap.breaks_hangul_at_eojeol - && common_list_level_indent(items, base_level).is_some() + && common_list_level_indent(items, base_level, eojeol_wrap.hangs_into_margin).is_some() && items .iter() .filter(|item| item.level == base_level) @@ -2007,11 +2028,16 @@ fn generate_list_items( // starts. A slide list keeps its own tab handling. let body_origin_pt: Option = (eojeol_wrap.breaks_hangul_at_eojeol && super::text::paragraph_contains_tabs(¶graph.runs)) - .then(|| paragraph_list_indent(¶graph.style)) + .then(|| paragraph_list_indent(¶graph.style, eojeol_wrap.hangs_into_margin)) .flatten() .map(|indent| indent.marker_origin_pt + indent.marker_width_pt); if uses_word_suffixes - && word_numbering_suffix_placement(style.kind, ¶graph.style).is_some() + && word_numbering_suffix_placement( + style.kind, + ¶graph.style, + eojeol_wrap.hangs_into_margin, + ) + .is_some() { let tabbed_body: Option = match body_origin_pt { Some(_) => { @@ -2035,6 +2061,7 @@ fn generate_list_items( &item_numbers, ctx.default_tab_width_pt, tabbed_body.as_deref(), + eojeol_wrap.hangs_into_margin, ); if tabbed_body.is_none() { write_list_paragraph_runs(out, paragraph, eojeol_wrap, None, ctx)?; @@ -2100,18 +2127,24 @@ fn generate_list_items( // Typst list is laid out inside the parent item's body: // subtract the parent's text origin so the child marker // lands at its absolute position (issue #356). - let parent_indent = common_list_level_indent(&items[i..=i], base_level); - let indent = - common_list_level_indent(&items[child_start..child_end], child_level).map( - |mut child| { - if let Some(parent) = parent_indent { - child.marker_origin_pt = (child.marker_origin_pt - - (parent.marker_origin_pt + parent.marker_width_pt)) - .max(0.0); - } - child - }, - ); + let parent_indent = common_list_level_indent( + &items[i..=i], + base_level, + eojeol_wrap.hangs_into_margin, + ); + let indent = common_list_level_indent( + &items[child_start..child_end], + child_level, + eojeol_wrap.hangs_into_margin, + ) + .map(|mut child| { + if let Some(parent) = parent_indent { + child.marker_origin_pt = (child.marker_origin_pt + - (parent.marker_origin_pt + parent.marker_width_pt)) + .max(0.0); + } + child + }); // PowerPoint's a:spcAft belongs to the item that declares it. // A shared nested `spacing:` cannot carry the last child's // gap across the return to its parent level, so retain every diff --git a/crates/office2pdf/src/render/typst_gen_tables.rs b/crates/office2pdf/src/render/typst_gen_tables.rs index 09eb695a..864aca0d 100644 --- a/crates/office2pdf/src/render/typst_gen_tables.rs +++ b/crates/office2pdf/src/render/typst_gen_tables.rs @@ -3421,6 +3421,11 @@ fn generate_cell_content( line_box_em: None, available_measure_pt: ctx.available_measure_pt, baseline_snap: None, + // TODO(#2261 cell clip): Word clips a cell list + // that hangs past its cell at the cell's edge. Until + // cells clip, its indents clamp at the cell's inset, + // as cell paragraphs do (issue #2233). + hangs_into_margin: false, }, ctx, )?; diff --git a/references/docxide-pdf.md b/references/docxide-pdf.md index 79a0d464..5d9504f7 100644 --- a/references/docxide-pdf.md +++ b/references/docxide-pdf.md @@ -42,7 +42,7 @@ inline probes check ancestor counters and literals; case72 probes calibrate restarts, suppression, implicit counters and skipped-level origins. MS-OI29500 2.1.282 confirms Word ignores restart rules inside complete level overrides. Marker clearance/suffixes follow `pdf/list_label.rs`. Native probes calibrate -font/size/tab/wrap differences and inline marker ascent versus body descent/leading. +font/size/tab/wrap differences and inline marker ascent versus body descent/leading; Word's `case97` export puts a list marker with a negative `w:ind w:left` left of the margin, as the body list path now does (#2233). Damaged-PNG recovery follows `src/pdf/images.rs`; locked `png` 0.18.1 recovers samples, calibrated by Word CRC/truncation probes. Picture crops follow `src/docx/images.rs`; raw XML retains per-picture offsets independently of [docx-rs#936](https://github.com/bokuweb/docx-rs/pull/936). An inline picture beside text rides the line like a glyph, as `inline_line_advance` in `src/pdf/layout.rs` (`fe7b8bea`) lays out: picture plus text descent (#2227). SmartArt follows package relationships into cached DrawingML and separate text transforms; native outlines stay, compatible others use Annex D, and unsupported outlines/path paints warn and fall back. diff --git a/tests/fixtures/docx/docxide/README.md b/tests/fixtures/docx/docxide/README.md index c66fb932..88729ba0 100644 --- a/tests/fixtures/docx/docxide/README.md +++ b/tests/fixtures/docx/docxide/README.md @@ -226,3 +226,11 @@ line in compatibility mode 15 (greeting baseline 141.30pt) and drops it in mode revision and license: the mode-15 case with a full-width `w:tblpPr` table. Native Word drops the mark's line and starts the greeting right below the table, on a 117.28pt baseline; a compiled-layout regression checks it. + +`numbering_negative_indent.docx` is the unmodified synthetic `cases/case97/input.docx` +from [docxide-pdf at fe7b8bea](https://github.com/sverrejb/docxide-pdf/tree/fe7b8beafa7612cc89ad62a7d27b2c6141c4d6ed/tests/fixtures/cases/case97), +Apache-2.0: one numbered Times New Roman 14pt paragraph, "Test marker", with +`w:ind w:left="-100" w:hanging="5"` and a left tab at 171 twips. Native Word +draws "1." at 66.775pt, 0.025pt from the 72pt − 5pt − 0.25pt origin, and tabs +the body to 80.525pt, by the 8.55pt stop; a compiled-layout regression checks +both within 0.25pt. diff --git a/tests/fixtures/docx/docxide/numbering_negative_indent.docx b/tests/fixtures/docx/docxide/numbering_negative_indent.docx new file mode 100644 index 00000000..72c452f2 Binary files /dev/null and b/tests/fixtures/docx/docxide/numbering_negative_indent.docx differ