Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/bugfixes/issue-624/after.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/bugfixes/issue-624/before.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/bugfixes/issue-624/gt.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/bugfixes/issue-724/compare.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/bugfixes/issue-725/compare.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
413 changes: 413 additions & 0 deletions crates/office2pdf/src/parser/docx_table_tests.rs

Large diffs are not rendered by default.

372 changes: 359 additions & 13 deletions crates/office2pdf/src/parser/docx_tables.rs

Large diffs are not rendered by default.

87 changes: 87 additions & 0 deletions crates/office2pdf/src/render/pdf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,93 @@ pub(crate) fn max_digit_advance_em(_family: &str) -> Option<f64> {
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<f64> {
use std::collections::HashMap;
use std::sync::Mutex;
type ResolvedFaceCache = HashMap<(String, bool), Option<typst::text::Font>>;
static FACE_CACHE: OnceLock<Mutex<ResolvedFaceCache>> = 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<Option<typst::text::Font>> = cache
.lock()
.expect("resolved face cache mutex should not be poisoned")
.get(&key)
.cloned();
let font: Option<typst::text::Font> = 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<typst::text::Font> = 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<f64> {
None
}

/// PowerPoint's line height factor: it gives every line 1.2 times the font
/// size, whatever the font's own metrics say.
///
Expand Down
31 changes: 31 additions & 0 deletions crates/office2pdf/src/render/pdf_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
4 changes: 4 additions & 0 deletions crates/office2pdf/src/render/typst_gen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion crates/office2pdf/src/render/typst_gen_text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 32 additions & 4 deletions crates/office2pdf/tests/docx_fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// `<w:tblLayout w:type="fixed"/>` 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()
Expand All @@ -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!(
Expand All @@ -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
);
}
}

// ---------------------------------------------------------------------------
Expand Down
Loading