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-1262/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-1262/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-1262/gt.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
631 changes: 631 additions & 0 deletions assets/bugfixes/issue-1262/layout-audit.json

Large diffs are not rendered by default.

7,735 changes: 7,735 additions & 0 deletions assets/bugfixes/issue-1262/render-clusters-page-1.json

Large diffs are not rendered by default.

Binary file added assets/bugfixes/issue-1514/compare.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
178 changes: 173 additions & 5 deletions crates/office2pdf/src/parser/xlsx_cells.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,135 @@ use crate::ir::{BorderSide, CellBorder, Color, Insets, TableCell, TextStyle};
/// overflow may extend the printed range.
const MAX_XLSX_COLUMNS: u32 = 16384;

/// Return a cell's displayed text, preserving whitespace from a literal-only
/// zero section that `umya-spreadsheet` currently trims.
///
/// The workspace patch can select `\-\ \ ` instead of falling back to the
/// positive section, but its width-independent string formatter returns `-`.
/// Excel keeps both escaped spaces; they matter when the text is right-aligned.
/// Keep this narrow compatibility layer until a released dependency carries
/// the complete behavior (issue #1262).
fn formatted_cell_value(cell: &umya_spreadsheet::Cell) -> String {
if cell.get_value_number().is_some_and(|value| value == 0.0)
&& let Some(number_format) = cell.get_style().get_number_format()
&& let Some(literal) = literal_zero_section_text(number_format.get_format_code())
{
return literal;
}
cell.get_formatted_value()
}

/// Decode a conventional three- or four-section format's zero section when it
/// contains only quoted or escaped literal text (plus bracketed controls).
/// Value-dependent sections stay on the dependency's normal formatter path.
fn literal_zero_section_text(format: &str) -> Option<String> {
let mut sections: Vec<&str> = Vec::with_capacity(4);
let mut section_start: usize = 0;
let mut in_quotes = false;
let mut skips_next = false;

for (index, ch) in format.char_indices() {
if skips_next {
skips_next = false;
continue;
}
match ch {
'\\' | '_' | '*' if !in_quotes => skips_next = true,
'"' => in_quotes = !in_quotes,
';' if !in_quotes => {
sections.push(&format[section_start..index]);
section_start = index + ch.len_utf8();
}
_ => {}
}
}
if in_quotes || skips_next {
return None;
}
sections.push(&format[section_start..]);
if !matches!(sections.len(), 3 | 4) {
return None;
}
if sections
.iter()
.any(|section| section_has_condition(section))
{
return None;
}

let mut literal = String::with_capacity(sections[2].len());
let mut chars = sections[2].chars();
let mut in_quotes = false;
while let Some(ch) = chars.next() {
if in_quotes {
if ch == '"' {
in_quotes = false;
} else {
literal.push(ch);
}
continue;
}

match ch {
'"' => in_quotes = true,
'\\' => literal.push(chars.next()?),
'[' => {
let mut control = String::new();
let mut closed = false;
for control_char in chars.by_ref() {
if control_char == ']' {
closed = true;
break;
}
control.push(control_char);
}
if !closed || !is_non_rendering_number_format_control(&control) {
return None;
}
}
ch if ch.is_whitespace() => literal.push(ch),
_ => return None,
}
}

(!in_quotes).then_some(literal)
}

fn section_has_condition(section: &str) -> bool {
let mut chars = section.chars();
let mut in_quotes = false;
while let Some(ch) = chars.next() {
match ch {
'"' => in_quotes = !in_quotes,
'\\' | '_' | '*' if !in_quotes => {
chars.next();
}
'[' if !in_quotes => {
let control: String = chars.by_ref().take_while(|ch| *ch != ']').collect();
if matches!(control.chars().next(), Some('<' | '>' | '=')) {
return true;
}
}
_ => {}
}
}
false
}

fn is_non_rendering_number_format_control(control: &str) -> bool {
const COLORS: &[&str] = &[
"black", "blue", "cyan", "green", "magenta", "red", "white", "yellow",
];
let lowercase = control.to_ascii_lowercase();
COLORS.contains(&lowercase.as_str())
|| lowercase
.strip_prefix("color")
.is_some_and(|index| !index.is_empty() && index.chars().all(|ch| ch.is_ascii_digit()))
|| lowercase
.strip_prefix("$-")
.is_some_and(|locale| !locale.is_empty())
}

/// A cell range within a sheet (1-indexed, inclusive).
#[derive(Debug, Clone, Copy)]
pub(crate) struct CellRange {
Expand Down Expand Up @@ -1067,7 +1196,7 @@ fn compute_spill_width(
}
let neighbor_is_empty: bool = sheet
.get_cell((neighbor_col, row_idx))
.map(|cell| cell.get_formatted_value().is_empty())
.map(|cell| formatted_cell_value(cell).is_empty())
.unwrap_or(true);
if !neighbor_is_empty {
blocked = true;
Expand Down Expand Up @@ -1693,6 +1822,10 @@ fn measured_printed_grid_row_height(height: f64, normal_font: Option<&NormalFont
/// workbook of issue #1068 (Segoe UI 10 Normal) exports 12/15/18/25/30/40/49
/// for declared 12/15/18/25.5/30/40/49.5, and its nine row boundaries down
/// the page all land within 0.12pt of that model.
/// A fresh Excel 16.112.3 export found a narrower exception: the theme-scheme
/// Trebuchet MS 10 workbook of #1262 snaps 19.5pt custom rows up to 20pt
/// (#1514). That combination remains on the conservative truncating path until
/// its fractional-height and theme controls are measured.
///
/// Both declared and recomputed worksheet heights go through here. An
/// auto-sized row additionally prints at the taller of this track and the
Expand Down Expand Up @@ -2157,9 +2290,7 @@ pub(super) fn build_rows_for_range(
// umya-spreadsheet tuple is (column, row), both 1-indexed
let umya_cell = sheet.get_cell((col_idx, row_idx));
let cell_indent_pt: f64 = ctx.cell_indent_pt(col_idx, row_idx);
let mut value = umya_cell
.map(|cell| cell.get_formatted_value())
.unwrap_or_default();
let mut value = umya_cell.map(formatted_cell_value).unwrap_or_default();
if let Some(cell) = umya_cell
&& let Some(number_format) = cell.get_style().get_number_format()
&& uses_native_arabic_digits(number_format.get_format_code())
Expand Down Expand Up @@ -2499,7 +2630,7 @@ fn spill_reach_max_col(
if merge_tops.contains_key(&(col, row)) || merge_skips.contains(&(col, row)) {
continue;
}
let text: String = cell.get_formatted_value();
let text: String = formatted_cell_value(cell);
if text.is_empty() || text.contains('\n') {
continue;
}
Expand Down Expand Up @@ -2663,3 +2794,40 @@ pub(super) fn prepare_sheet_context(
row_end,
))
}

#[cfg(test)]
mod literal_zero_section_tests {
use super::literal_zero_section_text;

#[test]
fn decodes_all_escaped_whitespace_from_the_zero_section() {
assert_eq!(
literal_zero_section_text(r"#,##0_);[Red]\(#,##0\);\-\ \ "),
Some("- ".to_string())
);
}

#[test]
fn supports_quoted_and_empty_zero_sections() {
assert_eq!(
literal_zero_section_text(r#""TRUE";"TRUE";"FALSE""#),
Some("FALSE".to_string())
);
assert_eq!(literal_zero_section_text("0;-0;"), Some(String::new()));
}

#[test]
fn leaves_value_dependent_and_non_zero_specific_formats_to_umya() {
assert_eq!(literal_zero_section_text("#,##0.00"), None);
assert_eq!(literal_zero_section_text("0;-0;0"), None);
assert_eq!(
literal_zero_section_text(r#"0;[Red]-0;"unterminated"#),
None
);
assert_eq!(
literal_zero_section_text(r#"[=0]"zero";[>0]"positive";"negative""#),
None
);
assert_eq!(literal_zero_section_text(r#"0;-0;[h]" hours""#), None);
}
}
62 changes: 54 additions & 8 deletions crates/office2pdf/tests/xlsx_fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2204,10 +2204,10 @@ fn structure_fit_to_page_sheet_scales_its_anchored_picture_to_the_native_size()
/// prints 73 rows of A1:R73 whose printed tracks total 1372pt against A3
/// portrait's 1082.55pt of printable height. A native Excel for Mac export of
/// it, staged and run inside Excel's own sandbox container, is a single A3 page
/// drawn at 0.78 — `mutool draw -F trace` reports a `.78` text transform and a
/// 14.82pt pitch across the 19pt tracks. Bounding the columns alone left the
/// sheet at the width fit's 0.89, a 16.91pt pitch and a second page
/// (issue #1181).
/// drawn at 0.78 — `mutool draw -F trace` reports a `.78` text transform.
/// Bounding the columns alone left the sheet at the width fit's 0.89 and a
/// second page (issue #1181). The current 14.82pt body pitch is a separate
/// known row-snap defect; fresh Excel 16.112.3 prints 15.60pt (#1514).
#[test]
fn structure_fit_to_page_sheet_without_declared_bounds_fits_its_rows_on_one_page() {
let pages = sheet_pages("issue_1181_fit_to_height.xlsx");
Expand All @@ -2224,9 +2224,9 @@ fn structure_fit_to_page_sheet_without_declared_bounds_fits_its_rows_on_one_page
{printable_height}pt of printable height"
);

// Excel's 0.78 over the sheet's 19pt tracks, which the export measures at
// 14.82pt. Asserting the pitch rather than the ratio keeps the check on
// what the page shows.
// Keep the current converter's 0.78 fit result pinned independently of the
// known 19.5pt -> 19pt row snap. #1514 will move this body pitch from 14.82
// to the native export's 15.60pt once its fractional-height controls land.
let tracks: Vec<f64> = budget
.table
.rows
Expand All @@ -2236,10 +2236,56 @@ fn structure_fit_to_page_sheet_without_declared_bounds_fits_its_rows_on_one_page
let body_track: f64 = tracks[tracks.len() - 1];
assert!(
(body_track - 14.82).abs() < 0.01,
"a 19pt track must print at the export's 14.82pt, got {body_track}"
"the current #1514 row-snap path must remain explicit, got {body_track}"
);
}

/// The reported monthly-budget workbook formats zero-valued entry cells with
/// the third section of `#,##0_);[Red]\(#,##0\);\-\ \ `. Excel prints the
/// escaped dash followed by both escaped spaces. All 71 cells are explicit
/// `<v>0</v>` cells in the worksheet XML; falling back to the positive section
/// printed `0`, while trimming the literal section shifted every right-aligned
/// dash 3.76pt to the right (issue #1262).
#[test]
fn structure_monthly_budget_zero_values_preserve_the_literal_zero_section() {
let pages = sheet_pages("issue_1181_fit_to_height.xlsx");
let budget = sheet_page_named(&pages, "Monthly college budget");
let zero_cells: &[(usize, &[usize])] = &[
(31, &[2, 3]),
(34, &[5, 6, 7, 8, 9, 10, 11, 12, 13]),
(45, &[2, 3, 5, 6, 8, 9, 11, 12, 13]),
(46, &[2, 3, 5, 6, 8, 9, 11, 12, 13]),
(49, &[2, 3, 5, 6, 8, 9, 11, 12, 13]),
(50, &[2, 3]),
(56, &[3, 4, 5, 6, 7, 9, 10, 11]),
(59, &[2, 3, 4]),
(61, &[2, 3, 4, 5, 6, 8, 9, 10, 11, 12]),
(62, &[2, 3]),
(63, &[2, 3, 4]),
(64, &[2, 3, 4]),
(68, &[2, 3]),
];

assert_eq!(
zero_cells
.iter()
.map(|(_, cells)| cells.len())
.sum::<usize>(),
71
);
for (row_index, column_indexes) in zero_cells {
for column_index in *column_indexes {
assert_eq!(
table_cell_text(&budget.table.rows[*row_index].cells[*column_index]),
"- ",
"worksheet cell at row {}, column {} must preserve the zero section's two spaces",
row_index + 1,
column_index + 1
);
}
}
}

/// Every chart in the same workbook states where its plot area sits inside the
/// chart area, because the template floats each chart over the cells that print
/// its heading — `january income:` and `$1,225` for this one — and the chart's
Expand Down
Loading