diff --git a/assets/bugfixes/issue-624/after.jpg b/assets/bugfixes/issue-624/after.jpg new file mode 100644 index 00000000..f4f314ad Binary files /dev/null and b/assets/bugfixes/issue-624/after.jpg differ diff --git a/assets/bugfixes/issue-624/before.jpg b/assets/bugfixes/issue-624/before.jpg new file mode 100644 index 00000000..4db6e616 Binary files /dev/null and b/assets/bugfixes/issue-624/before.jpg differ diff --git a/assets/bugfixes/issue-624/gt.jpg b/assets/bugfixes/issue-624/gt.jpg new file mode 100644 index 00000000..770e3671 Binary files /dev/null and b/assets/bugfixes/issue-624/gt.jpg differ diff --git a/assets/bugfixes/issue-724/compare.jpg b/assets/bugfixes/issue-724/compare.jpg new file mode 100644 index 00000000..ec9f1cf8 Binary files /dev/null and b/assets/bugfixes/issue-724/compare.jpg differ diff --git a/assets/bugfixes/issue-725/compare.jpg b/assets/bugfixes/issue-725/compare.jpg new file mode 100644 index 00000000..ec9f1cf8 Binary files /dev/null and b/assets/bugfixes/issue-725/compare.jpg differ diff --git a/crates/office2pdf/src/parser/docx_table_tests.rs b/crates/office2pdf/src/parser/docx_table_tests.rs index 69652d2e..c5bc1e48 100644 --- a/crates/office2pdf/src/parser/docx_table_tests.rs +++ b/crates/office2pdf/src/parser/docx_table_tests.rs @@ -143,6 +143,419 @@ fn test_table_column_widths_from_spanned_cell_widths_without_grid() { ); } +/// Helper for the auto-layout redistribution tests: a cell with an optional +/// `w:tcW`, an optional `w:gridSpan`, and one run in the given font/size. +/// Not cfg-gated: the degrade-path tests that run on every target use it too. +fn auto_layout_cell_xml(tcw_dxa: Option, grid_span: Option, text: &str) -> String { + let mut tc_pr = String::new(); + if let Some(width) = tcw_dxa { + tc_pr.push_str(&format!(r#""#)); + } + if let Some(span) = grid_span { + tc_pr.push_str(&format!(r#""#)); + } + format!( + r#"{tc_pr}{text}"# + ) +} + +/// Word's auto layout shrinks each over-subscribed column in proportion to +/// its compressible slack above min-content, not by a uniform scale over the +/// preferences (issue #624). Grid 100/100/100pt, `w:tblW` 300pt, but one row +/// prefers 200pt for the last column: Σpref = 400pt > 300pt. Every cell holds +/// "aa" in embedded Libertinus Serif at 20pt ('a' advance 0.457em, measured +/// with fontTools on the typst-assets face), so each column's min-content is +/// 0.914em x 20pt + 2 x 5.4pt default margins = 29.08pt, and +/// k = (300 - 87.24) / (400 - 87.24) = 0.68027. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn test_auto_layout_conflict_distributes_surplus_by_compressible_slack() { + let document_xml = format!( + r#" + + + + + {a}{b}{c} + {a}{b}{wide} + + + + "#, + a = auto_layout_cell_xml(Some(2000), None, "aa"), + b = auto_layout_cell_xml(Some(2000), None, "aa"), + c = auto_layout_cell_xml(Some(2000), None, "aa"), + wide = auto_layout_cell_xml(Some(4000), None, "aa"), + ); + let data = build_docx_with_columns(&document_xml); + let (doc, _warnings) = DocxParser.parse(&data, &ConvertOptions::default()).unwrap(); + let widths = &first_table(&doc).column_widths; + + let expected: [f64; 3] = [77.32, 77.32, 145.35]; + for (index, (width, expected_width)) in widths.iter().zip(expected).enumerate() { + assert!( + (width - expected_width).abs() < 0.05, + "column {index}: expected {expected_width}pt, got {width}pt (all: {widths:?})" + ); + } + let total: f64 = widths.iter().sum(); + assert!( + (total - 300.0).abs() < 0.01, + "total must stay 300pt, got {total}" + ); +} + +/// A cell that follows a `w:gridSpan` cell occupies the grid column after the +/// span, and its `w:tcW` claims THAT column; the span cell's own `w:tcW` is +/// ignored while it stays below the sum of its spanned columns' preferences. +/// Grid 50/50/200pt; the second row is a span-2 cell (tcW 25pt, ignored) plus +/// a 400pt-preference cell that must land on grid column 3. Every cell holds +/// "a" at 20pt: min-content 0.457em x 20pt + 10.8pt = 19.94pt, and +/// k = (300 - 59.82) / (500 - 59.82) = 0.54564. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn test_auto_layout_tracks_grid_occupancy_through_grid_span() { + let document_xml = format!( + r#" + + + + + {a}{b}{c} + {span}{wide} + + + + "#, + a = auto_layout_cell_xml(Some(1000), None, "a"), + b = auto_layout_cell_xml(Some(1000), None, "a"), + c = auto_layout_cell_xml(Some(4000), None, "a"), + span = auto_layout_cell_xml(Some(500), Some(2), "a"), + wide = auto_layout_cell_xml(Some(8000), None, "a"), + ); + let data = build_docx_with_columns(&document_xml); + let (doc, _warnings) = DocxParser.parse(&data, &ConvertOptions::default()).unwrap(); + let widths = &first_table(&doc).column_widths; + + let expected: [f64; 3] = [36.34, 36.34, 227.32]; + for (index, (width, expected_width)) in widths.iter().zip(expected).enumerate() { + assert!( + (width - expected_width).abs() < 0.05, + "column {index}: expected {expected_width}pt, got {width}pt (all: {widths:?})" + ); + } +} + +/// A column whose min-content exceeds its preference floors at min-content: +/// its compressible slack is zero, so redistribution cannot shrink it below +/// the widest unbreakable token. Grid 30/270pt, one row prefers 300pt for +/// column 2 (Σpref = 330 > 300). Column 1 holds "WWWW" at 20pt +/// ('W' advance 0.951em): min = 4 x 0.951em x 20pt + 10.8pt = 86.88pt > 30pt. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn test_auto_layout_floors_a_column_at_its_min_content_width() { + let document_xml = format!( + r#" + + + + + {narrow}{wide} + {narrow}{wider} + + + + "#, + narrow = auto_layout_cell_xml(Some(600), None, "WWWW"), + wide = auto_layout_cell_xml(Some(5400), None, "a"), + wider = auto_layout_cell_xml(Some(6000), None, "a"), + ); + let data = build_docx_with_columns(&document_xml); + let (doc, _warnings) = DocxParser.parse(&data, &ConvertOptions::default()).unwrap(); + let widths = &first_table(&doc).column_widths; + + assert!( + (widths[0] - 86.88).abs() < 0.05, + "column 1 floors at its min-content 86.88pt, got {widths:?}" + ); + assert!( + (widths[1] - 213.12).abs() < 0.05, + "column 2 absorbs the remainder, got {widths:?}" + ); +} + +/// When the cell preferences agree with the fit width (Σpref == W) the grid +/// is reproduced verbatim — even when a column's min-content exceeds its +/// preference. No golden mock but the invoice reaches the redistribution +/// path, and their output must not move (issue #624). +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn test_auto_layout_without_conflict_returns_grid_verbatim() { + let document_xml = format!( + r#" + + + + + {narrow}{wide} + + + + "#, + narrow = auto_layout_cell_xml(Some(500), None, "WWWW"), + wide = auto_layout_cell_xml(Some(5500), None, "a"), + ); + let data = build_docx_with_columns(&document_xml); + let (doc, _warnings) = DocxParser.parse(&data, &ConvertOptions::default()).unwrap(); + let widths = &first_table(&doc).column_widths; + + assert!( + (widths[0] - 25.0).abs() < 0.01 && (widths[1] - 275.0).abs() < 0.01, + "a conflict-free table keeps its declared grid, got {widths:?}" + ); +} + +/// When any token cannot be measured (here U+E000, which no embedded face +/// covers) the redistribution degrades to the pre-#624 uniform scale over the +/// per-column preference maxima, so font-less environments keep today's +/// output byte-identical. Grid 100/100pt, per-column max preferences +/// 150/100pt, uniform scale 200/250 = 0.8 → 120/80pt. +#[test] +fn test_auto_layout_with_unmeasurable_text_degrades_to_uniform_scale() { + let document_xml = format!( + r#" + + + + + {a}{b} + {c}{d} + + + + "#, + a = auto_layout_cell_xml(Some(2000), None, "a"), + b = auto_layout_cell_xml(Some(2000), None, "a"), + c = auto_layout_cell_xml(Some(3000), None, "\u{E000}"), + d = auto_layout_cell_xml(Some(2000), None, "a"), + ); + let data = build_docx_with_columns(&document_xml); + let (doc, _warnings) = DocxParser.parse(&data, &ConvertOptions::default()).unwrap(); + let widths = &first_table(&doc).column_widths; + + assert!( + (widths[0] - 120.0).abs() < 0.01 && (widths[1] - 80.0).abs() < 0.01, + "unmeasurable content must keep the uniform-scale result, got {widths:?}" + ); +} + +/// `w:rFonts w:eastAsia` routes only East Asian codepoints; a Latin-only run +/// beside an unresolvable East Asian family must still measure with its Latin +/// face and reach the slack-proportional path. Grid 100/100pt, per-column +/// preferences 150/100pt against W = 200pt, "a" at 20pt in each cell: +/// min = 19.94pt, k = (200 - 39.88) / (250 - 39.88) = 0.76204. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn test_auto_layout_latin_text_ignores_unresolvable_east_asian_family() { + let cell = |tcw: u32| -> String { + format!( + r#"a"# + ) + }; + let document_xml = format!( + r#" + + + + + {a}{b} + {c}{d} + + + + "#, + a = cell(2000), + b = cell(2000), + c = cell(3000), + d = cell(2000), + ); + let data = build_docx_with_columns(&document_xml); + let (doc, _warnings) = DocxParser.parse(&data, &ConvertOptions::default()).unwrap(); + let widths = &first_table(&doc).column_widths; + + assert!( + (widths[0] - 119.05).abs() < 0.05 && (widths[1] - 80.95).abs() < 0.05, + "Latin tokens must measure with the Latin face, got {widths:?}" + ); +} + +/// Word breaks a line between ANY two CJK characters, so a Korean cell's +/// min-content is its widest single glyph, not the whole phrase (issue #624 +/// review). Grid 100/100pt, the Korean cell states a conflicting 150pt tcW. +/// Treating the 13-syllable phrase as one unbreakable token would floor the +/// column near 250pt and overflow the 200pt fit width; per-character breaking +/// keeps the slack model close to the uniform 120/80pt split. The exact +/// widths depend on which Korean face resolves (or on the uniform-scale +/// degrade when none does), so the pin is a band, not a point. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn test_auto_layout_breaks_cjk_text_between_every_character() { + let korean_cell: &str = r#"총계약금액은일금오천만원정임"#; + let document_xml = format!( + r#" + + + + + {korean_cell}{latin} + + + + "#, + latin = auto_layout_cell_xml(Some(2000), None, "a"), + ); + let data = build_docx_with_columns(&document_xml); + let (doc, _warnings) = DocxParser.parse(&data, &ConvertOptions::default()).unwrap(); + let widths = &first_table(&doc).column_widths; + + let total: f64 = widths.iter().sum(); + assert!( + (total - 200.0).abs() < 0.01, + "the table must not overflow its 200pt fit width, got {widths:?}" + ); + assert!( + (115.0..=125.0).contains(&widths[0]) && (75.0..=85.0).contains(&widths[1]), + "a CJK cell floors at one glyph, near the 120/80 split, got {widths:?}" + ); +} + +/// A `w:tblW` beyond the grid total is outside the direction verified against +/// GT (Word clamps such tables to the content width, which is not modeled), +/// so the fit target stays the grid total and the conflict still compresses: +/// grid 100/100pt, prefs 150/100pt, "a" cells at 20pt → min 19.94pt each, +/// k = (200 - 39.88) / (250 - 39.88) = 0.76204 → 119.05/80.95pt. Extrapolating +/// toward the 600pt tblW would have ballooned column 1 to 366pt. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn test_auto_layout_ignores_tblw_beyond_the_grid_total() { + let document_xml = format!( + r#" + + + + + {a}{b} + + + + "#, + a = auto_layout_cell_xml(Some(3000), None, "a"), + b = auto_layout_cell_xml(Some(2000), None, "a"), + ); + let data = build_docx_with_columns(&document_xml); + let (doc, _warnings) = DocxParser.parse(&data, &ConvertOptions::default()).unwrap(); + let widths = &first_table(&doc).column_widths; + + assert!( + (widths[0] - 119.05).abs() < 0.05 && (widths[1] - 80.95).abs() < 0.05, + "an oversized tblW must not stretch the slack model, got {widths:?}" + ); +} + +/// When the preferences undershoot the fit width (Σpref < W) the slack model +/// would extrapolate k > 1 beyond every stated preference — a direction never +/// measured against GT — so the pre-#624 uniform scale is kept: maxima +/// 50/100pt scaled to the 200pt grid → 66.67/133.33pt. +#[test] +fn test_auto_layout_preference_undershoot_keeps_uniform_scale() { + let document_xml = format!( + r#" + + + + + {a}{b} + + + + "#, + a = auto_layout_cell_xml(Some(1000), None, "a"), + b = auto_layout_cell_xml(Some(2000), None, "a"), + ); + let data = build_docx_with_columns(&document_xml); + let (doc, _warnings) = DocxParser.parse(&data, &ConvertOptions::default()).unwrap(); + let widths = &first_table(&doc).column_widths; + + assert!( + (widths[0] - 200.0 / 3.0).abs() < 0.01 && (widths[1] - 400.0 / 3.0).abs() < 0.01, + "k >= 1 must return the uniform-scale result, got {widths:?}" + ); +} + +/// A conflicted table whose cells are all empty makes no font measurement at +/// all, so the slack model has nothing verified to work from: it must degrade +/// to the uniform-scale result (maxima 150/100 scaled to 200pt → 120/80pt). +/// This also keeps wasm and native identical on empty form-skeleton tables — +/// margins-only minima would have produced 119.53/80.47pt on native only. +#[test] +fn test_auto_layout_all_empty_cells_keep_uniform_scale() { + let document_xml = format!( + r#" + + + + + {a}{b} + + + + "#, + a = auto_layout_cell_xml(Some(3000), None, ""), + b = auto_layout_cell_xml(Some(2000), None, ""), + ); + let data = build_docx_with_columns(&document_xml); + let (doc, _warnings) = DocxParser.parse(&data, &ConvertOptions::default()).unwrap(); + let widths = &first_table(&doc).column_widths; + + assert!( + (widths[0] - 120.0).abs() < 0.01 && (widths[1] - 80.0).abs() < 0.01, + "an all-empty table must keep the uniform-scale widths, got {widths:?}" + ); +} + +/// Word does not break at no-break spaces, so "1 240,00" with a U+00A0 +/// thousands separator is ONE token. Libertinus Serif at 20pt: the full +/// string advances 3.26em → min 76.0pt with margins; splitting at the NBSP +/// would have measured only "240,00" (2.545em → 61.7pt). Grid 100/100pt, +/// prefs 150/100pt: k = (200 - 95.94) / (250 - 95.94) = 0.67545 → +/// 125.98/74.02pt. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn test_auto_layout_no_break_space_stays_inside_a_token() { + let document_xml = format!( + r#" + + + + + {price}{b} + + + + "#, + price = auto_layout_cell_xml(Some(3000), None, "1\u{00A0}240,00"), + b = auto_layout_cell_xml(Some(2000), None, "a"), + ); + let data = build_docx_with_columns(&document_xml); + let (doc, _warnings) = DocxParser.parse(&data, &ConvertOptions::default()).unwrap(); + let widths = &first_table(&doc).column_widths; + + assert!( + (widths[0] - 125.98).abs() < 0.05 && (widths[1] - 74.02).abs() < 0.05, + "a no-break space must not split the token, got {widths:?}" + ); +} + #[test] fn test_scan_table_headers_counts_only_leading_rows() { let document_xml = r#" diff --git a/crates/office2pdf/src/parser/docx_tables.rs b/crates/office2pdf/src/parser/docx_tables.rs index 10884110..230eb8a0 100644 --- a/crates/office2pdf/src/parser/docx_tables.rs +++ b/crates/office2pdf/src/parser/docx_tables.rs @@ -156,7 +156,12 @@ pub(super) fn convert_table( derive_column_widths_from_cells(&raw_rows).unwrap_or_default() } else { let grid: Vec = table.grid.iter().map(|&w| twips_to_pt(w as f64)).collect(); - reconcile_auto_layout_widths(&grid, &raw_rows) + // `w:tblW` shares `TableWidth`'s JSON shape with `w:tcW`; docx-rs + // serializes an absent element as `{width: 0, widthType: "auto"}`, + // which the extractor (auto) and the filter (0) both reject. + let declared_table_width_pt: Option = + extract_table_cell_width(table_prop_json.as_ref()).filter(|width| *width > 0.0); + reconcile_auto_layout_widths(&grid, &raw_rows, declared_table_width_pt) }; if header_info.is_visual_rtl { @@ -352,27 +357,368 @@ fn apply_conditional_table_style(raw_rows: &mut [RawRow], table_style: &Resolved /// resolves them across every row, which can contradict the grid outright. /// The invoice fixture's item rows ask for `700/4200/1200/1450/1476` twips /// while its Subtotal/VAT/Total rows put a 4200-twip value cell in the last -/// column, so Word widens Amount from the grid's 73.8pt to 159.8pt. Taking +/// column, so Word widens Amount from the grid's 73.8pt to 153.3pt. Taking /// the grid verbatim left it less than half Word's (issue #355). /// -/// Each column takes the widest preference any cell expresses for it, then -/// the set is scaled to the grid's total so the table keeps its declared -/// width. Falls back to the grid when no cell states a preference, and when -/// the preferences cover fewer columns than the grid. -fn reconcile_auto_layout_widths(grid: &[f64], raw_rows: &[RawRow]) -> Vec { - let Some(preferred) = derive_column_widths_from_cells(raw_rows) else { +/// Word resolves the conflict by compressing each column in proportion to +/// its compressible slack above min-content, not by a uniform scale: with +/// `pref_i` the widest single-column `w:tcW` on grid column `i`, `min_i` its +/// widest unbreakable token plus cell side margins, and `W` the fit width, +/// +/// ```text +/// k = (W - Σmin) / (Σpref - Σmin); width_i = min_i + (pref_i - min_i)·k +/// ``` +/// +/// Derived on the invoice's Word GT (issue #624): the uniform scale put +/// Description and Amount at an identical 161.32pt where Word prints 156.9 +/// and 153.3, while this rule lands every column within 0.10pt. The model +/// runs ONLY in the direction that GT verified — `Σpref > W` compression with +/// `k < 1` — and every other case returns the pre-#624 uniform scale over the +/// per-column tcW maxima: `Σpref <= W` (conflict-free tables — every other +/// golden mock — where the scale is ≈1 and the grid comes back verbatim, and +/// the unverified `k >= 1` extrapolation), any token that cannot be measured +/// (wasm, missing face or glyph), and tables whose cells are all empty (no +/// measurement to anchor the minima), so font-less and wasm output is +/// byte-identical to before. +fn reconcile_auto_layout_widths( + grid: &[f64], + raw_rows: &[RawRow], + declared_table_width_pt: Option, +) -> Vec { + let Some(cell_maxima) = derive_column_widths_from_cells(raw_rows) else { return grid.to_vec(); }; - if preferred.len() != grid.len() { + if cell_maxima.len() != grid.len() { return grid.to_vec(); } - let preferred_total: f64 = preferred.iter().sum(); + let cell_maxima_total: f64 = cell_maxima.iter().sum(); let grid_total: f64 = grid.iter().sum(); - if preferred_total <= 0.0 || grid_total <= 0.0 || preferred.iter().any(|width| *width <= 0.0) { + if cell_maxima_total <= 0.0 + || grid_total <= 0.0 + || cell_maxima.iter().any(|width| *width <= 0.0) + { return grid.to_vec(); } - let scale: f64 = grid_total / preferred_total; - preferred.iter().map(|width| width * scale).collect() + // The pre-#624 result: one uniform scale over the per-column tcW maxima. + // Kept verbatim as the degrade target so environments that cannot measure + // text (wasm, missing fonts) keep producing today's output. + let uniform_scale: f64 = grid_total / cell_maxima_total; + let uniformly_scaled: Vec = cell_maxima + .iter() + .map(|width| width * uniform_scale) + .collect(); + + // Word fits the table to `w:tblW` when stated, else to the grid total — + // but only the SHRINK direction is verified against GT: every #624 + // measurement (the invoice) has 0 < tblW <= grid total and Σpref > W. A + // tblW beyond the grid total is where Word starts clamping to the section + // content width, which is not modeled (section geometry is not threaded + // into tables), so such tables keep the grid total as their fit target. + let fit_width_pt: f64 = match declared_table_width_pt { + Some(declared) + if declared > 0.0 && declared <= grid_total + AUTO_LAYOUT_WIDTH_EPSILON_PT => + { + declared + } + _ => grid_total, + }; + + let preferred: Vec = derive_grid_column_preferences(grid, raw_rows); + let preferred_total: f64 = preferred.iter().sum(); + // Σpref <= W covers both the no-conflict case (Σpref == W: the uniform + // scale is ≈1 and the grid comes back verbatim) and the k >= 1 surplus + // direction, where the slack model would extrapolate beyond every stated + // preference — never measured against GT — so the pre-#624 uniform scale + // is kept for both. Only Σpref > W (k < 1 compression) is verified. + if preferred_total <= fit_width_pt + AUTO_LAYOUT_WIDTH_EPSILON_PT { + return uniformly_scaled; + } + let Some(min_content) = derive_grid_column_min_content_widths(raw_rows, grid.len()) else { + return uniformly_scaled; + }; + + // A preference below min-content carries no compressible slack: the + // column floors at min-content and takes no share of the surplus. + let clamped_preferred: Vec = preferred + .iter() + .zip(&min_content) + .map(|(preference, min)| preference.max(*min)) + .collect(); + let min_total: f64 = min_content.iter().sum(); + let compressible_slack: f64 = clamped_preferred.iter().sum::() - min_total; + if compressible_slack <= AUTO_LAYOUT_WIDTH_EPSILON_PT { + // Every column already sits at min-content; how Word grows such a + // table to a wider tblW is unmeasured, so keep today's output. + return uniformly_scaled; + } + // k < 1 always holds here (Σpref > W was gated above, so the extrapolating + // k >= 1 branch never reaches this point); k clamps at 0 when W < Σmin, + // flooring every column at min-content and letting the table overflow W, + // which is untested. + let slack_share: f64 = ((fit_width_pt - min_total) / compressible_slack).max(0.0); + clamped_preferred + .iter() + .zip(&min_content) + .map(|(preference, min)| min + (preference - min) * slack_share) + .collect() +} + +/// One twip (0.05pt) — the resolution of every source value. A conflict +/// smaller than one twip is dxa rounding noise, not an authored disagreement, +/// so it skips token measurement entirely and keeps the uniform-scale result. +const AUTO_LAYOUT_WIDTH_EPSILON_PT: f64 = 0.05; + +/// The preferred width of each grid column: the widest `w:tcW` any +/// single-column cell states on it, falling back to the declared `gridCol`. +/// +/// Occupancy is tracked through `w:gridSpan` — a cell following a span-4 cell +/// sits on grid column 5 and its `w:tcW` claims that column, which is exactly +/// how the invoice's Subtotal rows hand their 4200-twip value cell to the +/// last column. A spanned cell's own `w:tcW` is ignored unless it exceeds the +/// sum of its spanned columns' preferences (untested by fixtures: the excess +/// is spread proportionally). +fn derive_grid_column_preferences(grid: &[f64], raw_rows: &[RawRow]) -> Vec { + let mut stated: Vec> = vec![None; grid.len()]; + for row in raw_rows { + for cell in &row.cells { + if cell.col_span != 1 || cell.col_index >= grid.len() { + continue; + } + let Some(preferred_width) = cell.preferred_width else { + continue; + }; + let slot: &mut Option = &mut stated[cell.col_index]; + *slot = Some(slot.map_or(preferred_width, |width| width.max(preferred_width))); + } + } + let mut preferred: Vec = stated + .iter() + .zip(grid) + .map(|(stated_width, grid_width)| stated_width.unwrap_or(*grid_width)) + .collect(); + raise_spanned_ranges_to_spanning_cells(&mut preferred, raw_rows, |cell| cell.preferred_width); + preferred +} + +/// The min-content width of each grid column: over its single-column cells, +/// the widest unbreakable token plus the cell's left and right margins. +/// +/// Word never compresses a column below this in auto layout. Borders are NOT +/// added — measured on the invoice, adding them degrades the fit. Returns +/// `None` when any cell's text cannot be measured, and also when NO cell +/// produced a font measurement at all (every cell empty or whitespace-only): +/// a margins-only minimum is unverified against GT, and running the slack +/// model from it would move empty form-skeleton tables away from today's +/// output on native while wasm — which never measures — kept the uniform +/// scale. Degrading keeps both targets identical. +fn derive_grid_column_min_content_widths( + raw_rows: &[RawRow], + column_count: usize, +) -> Option> { + let mut any_token_measured: bool = false; + + let mut min_content: Vec = vec![0.0; column_count]; + for row in raw_rows { + for cell in &row.cells { + if cell.col_span != 1 || cell.col_index >= column_count { + continue; + } + let cell_min: f64 = measured_cell_min_content_pt(cell, &mut any_token_measured)?; + min_content[cell.col_index] = min_content[cell.col_index].max(cell_min); + } + } + // Spanned cells' mins are ignored unless exceeding the spanned columns' + // min sum (untested by fixtures) — but their text must still be + // measurable, or the whole table degrades consistently. + let mut spanned_all_measured: bool = true; + raise_spanned_ranges_to_spanning_cells(&mut min_content, raw_rows, |cell| { + match measured_cell_min_content_pt(cell, &mut any_token_measured) { + Some(cell_min) => Some(cell_min), + None => { + spanned_all_measured = false; + None + } + } + }); + (spanned_all_measured && any_token_measured).then_some(min_content) +} + +/// One cell's min-content: its widest unbreakable token plus its left and +/// right margins (`w:tcMar` default 108 twips = 5.4pt per writing side when +/// neither the cell nor the table states one). Sets `any_token_measured` +/// when at least one real font measurement backed the result. +fn measured_cell_min_content_pt(cell: &RawCell, any_token_measured: &mut bool) -> Option { + const DEFAULT_CELL_SIDE_MARGIN_PT: f64 = 5.4; + let widest_token_pt: f64 = max_unbreakable_token_advance_pt(&cell.content, any_token_measured)?; + let (left_margin, right_margin): (f64, f64) = cell.padding.map_or( + (DEFAULT_CELL_SIDE_MARGIN_PT, DEFAULT_CELL_SIDE_MARGIN_PT), + |padding| (padding.left, padding.right), + ); + Some(widest_token_pt + left_margin + right_margin) +} + +/// Shared spanned-cell rule for preferences and min-content: when a +/// `w:gridSpan` cell's own requirement exceeds the sum its spanned columns +/// already carry, raise those columns proportionally to cover it. No fixture +/// exercises this branch; the invoice's span-4 label cells all require less +/// than their columns' sums and are ignored here. +fn raise_spanned_ranges_to_spanning_cells( + column_values: &mut [f64], + raw_rows: &[RawRow], + mut cell_requirement: impl FnMut(&RawCell) -> Option, +) { + for row in raw_rows { + for cell in &row.cells { + let span: usize = cell.col_span as usize; + if span < 2 { + continue; + } + let range_end: usize = (cell.col_index + span).min(column_values.len()); + if cell.col_index >= range_end { + continue; + } + let Some(required_width) = cell_requirement(cell) else { + continue; + }; + let range = &mut column_values[cell.col_index..range_end]; + let range_sum: f64 = range.iter().sum(); + if required_width > range_sum && range_sum > 0.0 { + let scale: f64 = required_width / range_sum; + for value in range.iter_mut() { + *value *= scale; + } + } + } + } +} + +/// Word breaks tokens at ordinary whitespace, but a no-break space (U+00A0), +/// narrow no-break space (U+202F), or figure space (U+2007) stays inside the +/// token — "1 240,00" with an NBSP thousands separator is one token. +fn is_token_breaking_whitespace(character: char) -> bool { + character.is_whitespace() && !matches!(character, '\u{00A0}' | '\u{202F}' | '\u{2007}') +} + +/// The advance of the widest unbreakable token in a cell's paragraphs, in +/// points, measured with each run's resolved family, weight, and size — bold +/// runs use the bold face, East Asian codepoints the `w:eastAsia` face. +/// +/// Token boundaries mirror Word's line breaking: breaking whitespace closes a +/// token (no-break spaces do not — see [`is_token_breaking_whitespace`]), and +/// a CJK character is ALWAYS a token of its own because Word may break +/// between any two CJK characters — a Korean phrase's min-content is its +/// widest single glyph, and "모델A" splits as 모/델/A. Everything else forms +/// maximal non-CJK segments that accumulate across run boundaries (a price +/// like "$1,240.00" split over runs is one unbreakable token). +/// TODO(issue #624): Word also breaks after hyphens, and kinsoku forbids +/// breaks before CJK closing punctuation / after opening punctuation; no +/// fixture exercises either, so neither is modeled here. +/// +/// Each contiguous same-family segment is measured with ONE +/// `text_advance_em` call, keeping the global face-cache mutex out of the +/// per-character path. `any_token_measured` is set when at least one call +/// succeeded, so callers can tell a real measurement from the vacuous 0 of an +/// empty cell. +/// +/// Non-paragraph blocks (images, nested tables) also bound Word's +/// min-content, but no auto-layout fixture carries them, so they contribute +/// nothing rather than blocking measurement. Returns `None` when a run's +/// family or size is unresolved or a glyph is missing, so the caller can +/// degrade to a measurement-free path. +fn max_unbreakable_token_advance_pt( + blocks: &[Block], + any_token_measured: &mut bool, +) -> Option { + let mut widest_token_pt: f64 = 0.0; + for block in blocks { + let Block::Paragraph(paragraph) = block else { + continue; + }; + let mut current_token_pt: f64 = 0.0; + for run in ¶graph.runs { + if run.text.chars().all(is_token_breaking_whitespace) { + if !run.text.is_empty() { + widest_token_pt = widest_token_pt.max(current_token_pt); + current_token_pt = 0.0; + } + continue; + } + let font_family: &str = run.style.font_family.as_deref()?; + let font_size: f64 = run.style.font_size?; + let is_bold: bool = run.style.bold == Some(true); + + let text: &str = &run.text; + // Start of the current maximal non-CJK, non-breaking segment. + let mut segment_start: Option = None; + for (byte_index, character) in text.char_indices() { + if !is_token_breaking_whitespace(character) + && !crate::render::typst_gen::is_cjk_like(character) + { + segment_start.get_or_insert(byte_index); + continue; + } + if let Some(start) = segment_start.take() { + current_token_pt += measured_segment_advance_pt( + font_family, + is_bold, + font_size, + &text[start..byte_index], + any_token_measured, + )?; + } + if is_token_breaking_whitespace(character) { + widest_token_pt = widest_token_pt.max(current_token_pt); + current_token_pt = 0.0; + continue; + } + // A CJK character: break before and after, so it is a + // singleton token measured with the `w:eastAsia` face. + widest_token_pt = widest_token_pt.max(current_token_pt); + current_token_pt = 0.0; + let cjk_family: &str = run + .style + .east_asian_font_family + .as_deref() + .unwrap_or(font_family); + let character_end: usize = byte_index + character.len_utf8(); + let singleton_pt: f64 = measured_segment_advance_pt( + cjk_family, + is_bold, + font_size, + &text[byte_index..character_end], + any_token_measured, + )?; + widest_token_pt = widest_token_pt.max(singleton_pt); + } + if let Some(start) = segment_start.take() { + // The token stays open: it may continue into the next run. + current_token_pt += measured_segment_advance_pt( + font_family, + is_bold, + font_size, + &text[start..], + any_token_measured, + )?; + } + } + widest_token_pt = widest_token_pt.max(current_token_pt); + } + Some(widest_token_pt) +} + +/// One `text_advance_em` call for a whole token segment, converted to points. +/// Marks `any_token_measured` on success so callers can tell a real +/// measurement from the vacuous zero of an empty cell. +fn measured_segment_advance_pt( + family: &str, + is_bold: bool, + font_size_pt: f64, + segment: &str, + any_token_measured: &mut bool, +) -> Option { + let advance_em: f64 = crate::render::pdf::text_advance_em(family, is_bold, segment)?; + *any_token_measured = true; + Some(advance_em * font_size_pt) } fn derive_column_widths_from_cells(raw_rows: &[RawRow]) -> Option> { diff --git a/crates/office2pdf/src/render/pdf.rs b/crates/office2pdf/src/render/pdf.rs index d4e10184..cba53035 100644 --- a/crates/office2pdf/src/render/pdf.rs +++ b/crates/office2pdf/src/render/pdf.rs @@ -530,6 +530,93 @@ pub(crate) fn max_digit_advance_em(_family: &str) -> Option { None } +/// Total horizontal advance of `text`, in em units, on the face `family` +/// resolves to at the requested weight. +/// +/// Word's auto table layout never compresses a column below its min-content +/// width — the advance of its widest unbreakable token — so the DOCX parser +/// needs advances from the same faces the renderer will draw with +/// (issue #624). The face is resolved once per `(family, weight)` through the +/// same alias and substitute chain rendering uses and cached; bold runs must +/// measure against the bold face because its advances differ (Libertinus +/// Serif's "Total" is 2.392em bold against 2.138em regular). +/// +/// Kerning and ligatures are deliberately ignored: the per-glyph `hmtx` sum +/// reproduced Word's invoice column widths within 0.10pt, and callers assert +/// with tolerances, so the ≲1-2% shaping error is acceptable. Returns `None` +/// when no face resolves or any character lacks a glyph, so the caller can +/// degrade to a measurement-free path. +/// +/// Accepted limitation (shared behavior with `max_digit_advance_em`, whose +/// resolution chain is identical): resolution sees only the system fonts +/// plus the discovered Office font dirs — +/// `ConvertOptions::font_paths` and fonts embedded in the document itself are +/// not consulted, because the parser has no per-conversion font context to +/// thread through. A family only such fonts provide simply fails to resolve +/// here and the caller degrades to its measurement-free path, so the miss is +/// conservative rather than wrong. +/// TODO(issue #624): thread the per-conversion font set (options.font_paths + +/// document-embedded faces) into these measurement helpers once one exists. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn text_advance_em(family: &str, bold: bool, text: &str) -> Option { + use std::collections::HashMap; + use std::sync::Mutex; + type ResolvedFaceCache = HashMap<(String, bool), Option>; + static FACE_CACHE: OnceLock> = OnceLock::new(); + + let cache = FACE_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + let key: (String, bool) = (family.to_lowercase(), bold); + let cached_font: Option> = cache + .lock() + .expect("resolved face cache mutex should not be poisoned") + .get(&key) + .cloned(); + let font: Option = match cached_font { + Some(font) => font, + None => { + // Use the same font set the compiler will use (system + discovered + // Office font dirs); this also primes the compile-time cache. + let search_context = super::font_context::resolve_font_search_context(&[]); + let data = get_fonts_for_extra_paths(search_context.search_paths()); + let variant = typst::text::FontVariant { + weight: if bold { + typst::text::FontWeight::BOLD + } else { + typst::text::FontWeight::REGULAR + }, + ..typst::text::FontVariant::default() + }; + let resolved: Option = super::font_subst::family_candidates(family) + .iter() + .find_map(|candidate| data.book.select(&candidate.to_lowercase(), variant)) + .and_then(|index| data.fonts.get(index)) + .and_then(|slot| slot.get()); + cache + .lock() + .expect("resolved face cache mutex should not be poisoned") + .insert(key, resolved.clone()); + resolved + } + }; + + let font: typst::text::Font = font?; + let ttf = font.ttf(); + let upem: f64 = f64::from(ttf.units_per_em()).max(1.0); + let mut total_em: f64 = 0.0; + for character in text.chars() { + let glyph_advance: u16 = ttf + .glyph_index(character) + .and_then(|glyph| ttf.glyph_hor_advance(glyph))?; + total_em += f64::from(glyph_advance) / upem; + } + Some(total_em) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn text_advance_em(_family: &str, _bold: bool, _text: &str) -> Option { + None +} + /// PowerPoint's line height factor: it gives every line 1.2 times the font /// size, whatever the font's own metrics say. /// diff --git a/crates/office2pdf/src/render/pdf_tests.rs b/crates/office2pdf/src/render/pdf_tests.rs index 7715a6f1..ca7d606f 100644 --- a/crates/office2pdf/src/render/pdf_tests.rs +++ b/crates/office2pdf/src/render/pdf_tests.rs @@ -607,3 +607,34 @@ fn test_tagged_pdf_with_pdfa_combined() { "Should contain structure tags" ); } + +/// The embedded Libertinus Serif faces make the token measurement +/// deterministic on every target (like the digit-advance pin for #621). +/// Ground truth from fontTools `hmtx` sums on the typst-assets faces: +/// "Total" is 2.138em regular and 2.392em bold at 1000 upem — the bold face +/// must be selected for bold runs, not the regular one (issue #624). +#[test] +fn test_text_advance_em_reads_regular_and_bold_faces() { + let regular: f64 = text_advance_em("Libertinus Serif", false, "Total") + .expect("the embedded Libertinus Serif regular face must resolve"); + assert!( + (regular - 2.138).abs() < 1e-6, + "regular 'Total' should be 2.138em, got {regular}" + ); + + let bold: f64 = text_advance_em("Libertinus Serif", true, "Total") + .expect("the embedded Libertinus Serif bold face must resolve"); + assert!( + (bold - 2.392).abs() < 1e-6, + "bold 'Total' should be 2.392em, got {bold}" + ); +} + +/// A character without a glyph (U+E000 private use) yields `None` so the +/// caller can degrade to a measurement-free path; an empty string is a valid +/// zero-width measurement. +#[test] +fn test_text_advance_em_is_none_for_missing_glyphs() { + assert_eq!(text_advance_em("Libertinus Serif", false, "\u{E000}"), None); + assert_eq!(text_advance_em("Libertinus Serif", false, ""), Some(0.0)); +} diff --git a/crates/office2pdf/src/render/typst_gen.rs b/crates/office2pdf/src/render/typst_gen.rs index 8f08b9e2..25ff7347 100644 --- a/crates/office2pdf/src/render/typst_gen.rs +++ b/crates/office2pdf/src/render/typst_gen.rs @@ -46,6 +46,10 @@ mod tables; #[path = "typst_gen_text.rs"] mod text; +// The DOCX table min-content measurement routes East Asian codepoints to the +// run's `w:eastAsia` face the same way rendering does (issue #624). +pub(crate) use self::text::is_cjk_like; + /// An image asset to be embedded in the Typst compilation. #[derive(Debug, Clone)] pub struct ImageAsset { diff --git a/crates/office2pdf/src/render/typst_gen_text.rs b/crates/office2pdf/src/render/typst_gen_text.rs index 15b0b645..e6152a88 100644 --- a/crates/office2pdf/src/render/typst_gen_text.rs +++ b/crates/office2pdf/src/render/typst_gen_text.rs @@ -977,7 +977,7 @@ fn needs_no_wrap_joiner(previous: char, current: char) -> bool { !previous.is_whitespace() && !current.is_whitespace() } -pub(super) fn is_cjk_like(ch: char) -> bool { +pub(crate) fn is_cjk_like(ch: char) -> bool { matches!( ch as u32, 0x1100..=0x11FF diff --git a/crates/office2pdf/tests/docx_fixtures.rs b/crates/office2pdf/tests/docx_fixtures.rs index 87ba502a..0bb318aa 100644 --- a/crates/office2pdf/tests/docx_fixtures.rs +++ b/crates/office2pdf/tests/docx_fixtures.rs @@ -1512,9 +1512,11 @@ fn invoice_auto_layout_table_widens_the_amount_column() { // Subtotal/VAT/Total rows put a 4200-twip value cell in the last column // and a 700-twip label spanning the first four. With no // `` Word uses auto layout and reconciles - // those preferences against the grid, landing near - // 27.9/156.9/47.6/59.0/159.8 pt. Taking `w:tblGrid` verbatim left the - // Amount column at 73.8 pt, less than half Word's (issue #355). + // those preferences against the grid, landing at + // 27.9/156.9/47.8/65.3/153.3 pt (trace-verified for issue #624; the + // 159.8pt Amount figure recorded when #355 was filed was a first-pass + // estimate this measurement supersedes). Taking `w:tblGrid` verbatim + // left the Amount column at 73.8 pt, less than half Word's (issue #355). let pages = flow_pages("../../golden_mocks/business/sources/docx/01_invoice_en.docx"); let table = pages .iter() @@ -1534,7 +1536,7 @@ fn invoice_auto_layout_table_widens_the_amount_column() { // The Amount column must be comparable to Description, not to Qty. assert!( widths[4] > 140.0, - "Amount should widen toward Word's 159.8pt, got {:?}", + "Amount should widen toward Word's 153.3pt, got {:?}", widths ); assert!( @@ -1547,6 +1549,32 @@ fn invoice_auto_layout_table_widens_the_amount_column() { "the narrow columns stay narrow, got {:?}", widths ); + + // Issue #624: Word distributes the conflict by compressible slack above + // min-content, printing 27.9/156.9/47.8/65.3/153.3 pt — the uniform scale + // instead flattened Description and Amount to an identical 161.32pt and + // starved Unit Price to 55.7pt. The slack model needs token measurement, + // which degrades to the equal-share result (its signature: columns 2 and + // 5 identical) when no face resolves for Arial or its substitutes. + let degraded_to_equal_share: bool = (widths[1] - widths[4]).abs() < 0.01; + #[cfg(any(target_os = "macos", target_os = "windows"))] + assert!( + !degraded_to_equal_share, + "Arial resolves on this platform, so the slack model must run, got {:?}", + widths + ); + if !degraded_to_equal_share { + assert!( + (widths[3] - 65.3).abs() < 2.0, + "Unit Price should hold Word's 65.3pt, got {:?}", + widths + ); + assert!( + widths[4] < widths[1], + "Amount (Word 153.3pt) stays narrower than Description (156.9pt), got {:?}", + widths + ); + } } // ---------------------------------------------------------------------------