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-396/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-396/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-396/gt.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions crates/office2pdf/src/parser/xlsx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ impl XlsxParser {
column_widths: ctx.column_widths.clone(),
header_row_count,
alignment: None,
default_cell_padding: None,
default_cell_padding: Some(xlsx_cells::XLSX_CELL_PADDING),
use_content_driven_row_heights: false,
default_vertical_align: Some(crate::ir::CellVerticalAlign::Bottom),
},
Expand Down Expand Up @@ -548,7 +548,7 @@ impl Parser for XlsxParser {
column_widths: ctx.column_widths,
header_row_count,
alignment: None,
default_cell_padding: None,
default_cell_padding: Some(xlsx_cells::XLSX_CELL_PADDING),
use_content_driven_row_heights: false,
default_vertical_align: Some(crate::ir::CellVerticalAlign::Bottom),
},
Expand Down Expand Up @@ -616,7 +616,7 @@ impl Parser for XlsxParser {
column_widths: ctx.column_widths.clone(),
header_row_count: segment_header_rows,
alignment: None,
default_cell_padding: None,
default_cell_padding: Some(xlsx_cells::XLSX_CELL_PADDING),
use_content_driven_row_heights: false,
default_vertical_align: Some(
crate::ir::CellVerticalAlign::Bottom,
Expand Down
11 changes: 11 additions & 0 deletions crates/office2pdf/src/parser/xlsx_cells.rs
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,17 @@ fn compute_spill_width(
/// Excel's fallback row height when the sheet declares none (Calibri 11).
const EXCEL_DEFAULT_ROW_HEIGHT_PT: f64 = 15.0;

/// Cell insets for spreadsheet tables. Excel pads cells only slightly
/// above/below the text; Typst's default 5pt vertical inset overflowed
/// auto-height rows (issue #396). Horizontal inset keeps ~2pt/side, the
/// value the column-spill estimate assumes.
pub(super) const XLSX_CELL_PADDING: crate::ir::Insets = crate::ir::Insets {
top: 1.0,
right: 2.0,
bottom: 1.0,
left: 2.0,
};

/// The height a row prints at. A recorded `ht` is the actual current height
/// even when `customHeight` is false; rows without one use the sheet's
/// defaultRowHeight. Exception: auto-sized rows (customHeight=false) that
Expand Down
43 changes: 26 additions & 17 deletions crates/office2pdf/src/render/typst_gen_table_cell_content_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,18 +242,21 @@ fn test_table_cell_compact_list_adds_inter_item_spacing_from_line_spacing() {
#[test]
fn test_east_asian_table_cell_uses_natural_line_height_not_grid() {
// Word does not snap table-cell text to the document grid (measured
// Korean cells sit at the font's full line, not a grid multiple), so
// cells must use the natural single-spacing line height rather than
// Typst's glyph-tight default (issue #385). Uses a Typst-embedded font
// so the test is environment-free.
// Korean cells sit at the font's full line, not a grid multiple), and a
// single-line cell occupies the whole hhea line — emitted as a fixed
// box split by the ascender/descender ratio with zero leading, so
// auto-height rows are not left short (issues #385, #396). Uses a
// Typst-embedded font so the test is environment-free.
let Some((ascender, descender, word_pitch_em)) =
crate::render::pdf::font_line_metrics_em("Libertinus Serif")
else {
return; // no font book available (e.g. exotic CI sandbox)
};
let font_size: f64 = 10.0;
let natural_leading = ((word_pitch_em - (ascender + descender)) * font_size).max(0.0);
let grid_leading = 18.0 - (ascender + descender) * font_size;
let metric_em: f64 = ascender + descender;
let top_em: f64 = word_pitch_em * ascender / metric_em;
let bottom_em: f64 = word_pitch_em * descender / metric_em;
let grid_leading = 18.0 - metric_em * font_size;
let cell = TableCell {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
Expand Down Expand Up @@ -290,14 +293,14 @@ fn test_east_asian_table_cell_uses_natural_line_height_not_grid() {
assert!(
result.contains(&format!(
"top-edge: {}em, bottom-edge: -{}em",
format_f64(ascender),
format_f64(descender)
format_f64(top_em),
format_f64(bottom_em)
)),
"Korean cell must use fixed nominal-metric em edges: {result}"
"Korean cell must fill the full hhea line box: {result}"
);
assert!(
result.contains(&format!("leading: {}pt", format_f64(natural_leading))),
"Korean cell must use natural single-spacing leading ({natural_leading}pt): {result}"
result.contains("#set par(leading: 0pt)"),
"cell line box uses zero leading (box already equals the full line): {result}"
);
assert!(
!result.contains(&format!("leading: {}pt", format_f64(grid_leading))),
Expand All @@ -307,16 +310,18 @@ fn test_east_asian_table_cell_uses_natural_line_height_not_grid() {

#[test]
fn test_latin_table_cell_uses_natural_line_height() {
// Latin cells likewise use their metric single-spacing line height
// (Word single spacing = hhea line), not Typst's glyph-tight default
// (issue #385).
// Latin cells likewise fill the font's full hhea line box (Word single
// spacing = hhea line), not Typst's glyph-tight default (issues #385,
// #396).
let Some((ascender, descender, word_pitch_em)) =
crate::render::pdf::font_line_metrics_em("Libertinus Serif")
else {
return;
};
let font_size: f64 = 10.0;
let natural_leading = ((word_pitch_em - (ascender + descender)) * font_size).max(0.0);
let metric_em: f64 = ascender + descender;
let top_em: f64 = word_pitch_em * ascender / metric_em;
let bottom_em: f64 = word_pitch_em * descender / metric_em;
let cell = TableCell {
content: vec![Block::Paragraph(Paragraph {
style: ParagraphStyle::default(),
Expand Down Expand Up @@ -344,7 +349,11 @@ fn test_latin_table_cell_uses_natural_line_height() {
let doc = make_doc(vec![make_flow_page(vec![Block::Table(table)])]);
let result = generate_typst(&doc).unwrap().source;
assert!(
result.contains(&format!("leading: {}pt", format_f64(natural_leading))),
"Latin cell must use natural single-spacing leading ({natural_leading}pt): {result}"
result.contains(&format!(
"top-edge: {}em, bottom-edge: -{}em",
format_f64(top_em),
format_f64(bottom_em)
)),
"Latin cell must fill the full hhea line box: {result}"
);
}
14 changes: 7 additions & 7 deletions crates/office2pdf/src/render/typst_gen_tables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -540,13 +540,13 @@ fn generate_cell_paragraph(out: &mut String, para: &Paragraph, default_tab_width
Some(Alignment::Right) => Some("right"),
_ => None,
};
// Table-cell text keeps its natural single-spacing line height: Word
// does not snap cell content to the section's document grid (measured
// Korean cells sit at the font's full line, not a grid multiple), so
// cells rendered with Typst's glyph-tight default came out shorter than
// Word's rows (issue #385). Passing no grid pitch selects the metric
// edges plus hhea leading, matching body text without the grid snap.
let line_height_settings: Option<String> = word_line_height_settings(&para.runs, style, None);
// Table-cell text occupies the font's full single-spacing (hhea) line
// as a fixed box: Word does not snap cell content to the document grid
// (measured Korean cells sit at the font's full line, not a grid
// multiple, issue #385), and a single-line cell must fill the whole
// line height Word gives it rather than only the tighter metric box —
// otherwise auto-height rows come out short (issue #396).
let line_height_settings: Option<String> = word_cell_line_box_settings(&para.runs, style);
let has_block_wrapper = cell_paragraph_needs_block_wrapper(style)
|| align_str.is_some()
|| line_height_settings.is_some();
Expand Down
28 changes: 28 additions & 0 deletions crates/office2pdf/src/render/typst_gen_text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,34 @@ fn word_line_box_and_leading(
Some((ascender_em, descender_em, leading_pt))
}

/// Line-box settings for a table cell: a fixed box spanning the font's
/// full single-spacing (hhea) line, split by the ascender/descender ratio,
/// with zero leading. A single-line cell then occupies the whole line
/// height Word gives it, rather than only the tighter metric box (which
/// left auto-height rows too short, issue #396). `None` when the font's
/// metrics are unknown or the paragraph carries its own line spacing/box.
pub(super) fn word_cell_line_box_settings(runs: &[Run], style: &ParagraphStyle) -> Option<String> {
if style.line_spacing.is_some() || style.line_box.is_some() {
return None;
}
let family: &str = runs
.iter()
.find_map(|run| run.style.font_family.as_deref())?;
let (ascender_em, descender_em, word_pitch_em) =
crate::render::pdf::font_line_metrics_em(family)?;
let metric_em: f64 = ascender_em + descender_em;
if metric_em <= 0.0 || word_pitch_em <= 0.0 {
return None;
}
let top_em: f64 = word_pitch_em * ascender_em / metric_em;
let bottom_em: f64 = word_pitch_em * descender_em / metric_em;
Some(format!(
"#set text(top-edge: {}em, bottom-edge: -{}em)\n#set par(leading: 0pt)\n",
format_f64(top_em),
format_f64(bottom_em)
))
}

/// The leading that accompanies Word metric text edges: the whitespace
/// Typst must insert between metric line boxes so consecutive lines land at
/// Word's single-space advance (or the document grid pitch). `None` when
Expand Down
6 changes: 4 additions & 2 deletions crates/office2pdf/tests/xlsx_fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,11 +310,13 @@ fn acceptance_pr_186_contributor_acceptance_double_border_rendering() {
let output = generate_typst(&document).expect("fixture should generate Typst");

assert!(!output.source.contains("dash: \"dashed\""));
// Overlay offsets track the cell padding; XLSX cells use ~2pt insets so
// Excel auto-rows are not overflowed (issue #396).
assert!(output.source.contains(
"#place(top + left, dx: -5pt, dy: -6pt, line(length: 100% + 10pt, angle: 0deg, stroke: 1pt + rgb(0, 0, 0)))"
"#place(top + left, dx: -2pt, dy: -2pt, line(length: 100% + 4pt, angle: 0deg, stroke: 1pt + rgb(0, 0, 0)))"
));
assert!(output.source.contains(
"#place(top + left, dx: -5pt, dy: -4pt, line(length: 100% + 10pt, angle: 0deg, stroke: 1pt + rgb(0, 0, 0)))"
"#place(top + left, dx: -2pt, dy: 0pt, line(length: 100% + 4pt, angle: 0deg, stroke: 1pt + rgb(0, 0, 0)))"
));
}

Expand Down
Loading