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-622/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-622/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-622/gt.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions crates/office2pdf/src/ir/elements.rs
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,13 @@ pub struct Table {
/// PowerPoint's border-painting conventions are unmeasured against
/// their native GT, so they keep the centred-stroke path.
pub paints_borders_inside_boundary: bool,
/// When true, `<printOptions gridLines="1"/>` asks Excel to print its
/// gridline hairline on every cell boundary of the printed range, under
/// any explicit border styling (issue #622). Only spreadsheet tables set
/// this, and it is honoured only together with
/// `paints_borders_inside_boundary`, whose boundary-band machinery the
/// gridlines reuse; Word/PowerPoint tables never print gridlines.
pub prints_gridlines: bool,
}

/// A table row.
Expand Down
1 change: 1 addition & 0 deletions crates/office2pdf/src/parser/docx_tables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ pub(super) fn convert_table(
// Word GT has not verified descender seating for bottom cells (#618).
seats_bottom_aligned_text_on_descender: false,
paints_borders_inside_boundary: false,
prints_gridlines: false,
}
}

Expand Down
4 changes: 4 additions & 0 deletions crates/office2pdf/src/parser/pptx_table_style_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ fn test_apply_table_style_first_row_gets_header_fill_and_text_color() {
default_vertical_align: None,
seats_bottom_aligned_text_on_descender: false,
paints_borders_inside_boundary: false,
prints_gridlines: false,
};

table_styles::apply_table_style(&mut table, &props, &styles);
Expand Down Expand Up @@ -284,6 +285,7 @@ fn test_apply_table_style_banded_rows_skip_first_row() {
default_vertical_align: None,
seats_bottom_aligned_text_on_descender: false,
paints_borders_inside_boundary: false,
prints_gridlines: false,
};

table_styles::apply_table_style(&mut table, &props, &styles);
Expand Down Expand Up @@ -358,6 +360,7 @@ fn test_apply_table_style_explicit_cell_fill_not_overridden() {
default_vertical_align: None,
seats_bottom_aligned_text_on_descender: false,
paints_borders_inside_boundary: false,
prints_gridlines: false,
};

table_styles::apply_table_style(&mut table, &props, &styles);
Expand Down Expand Up @@ -403,6 +406,7 @@ fn test_apply_table_style_missing_style_id_is_noop() {
default_vertical_align: None,
seats_bottom_aligned_text_on_descender: false,
paints_borders_inside_boundary: false,
prints_gridlines: false,
};

table_styles::apply_table_style(&mut table, &props, &styles);
Expand Down
1 change: 1 addition & 0 deletions crates/office2pdf/src/parser/pptx_tables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,7 @@ impl<'a> PptxTableParser<'a> {
// cells (#618).
seats_bottom_aligned_text_on_descender: false,
paints_borders_inside_boundary: false,
prints_gridlines: false,
};
table_styles::apply_table_style(&mut table, &self.table_props, self.table_styles);
table
Expand Down
9 changes: 9 additions & 0 deletions crates/office2pdf/src/parser/xlsx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ pub(crate) mod cond_fmt_raw;

#[path = "xlsx_fit_to_page.rs"]
mod fit_to_page;
#[path = "xlsx_print_options.rs"]
mod print_options;
#[path = "xlsx_tables.rs"]
mod tables;
#[path = "xlsx_cells.rs"]
Expand Down Expand Up @@ -340,6 +342,7 @@ impl XlsxParser {
let metadata = extract_xlsx_metadata(&book);
let cond_fmt_hints = cond_fmt_raw::extract_cond_fmt_hints(data);
let fitting_sheets = fit_to_page::sheets_fitting_to_page(data);
let gridline_sheets = print_options::sheets_printing_gridlines(data);
let mut row_stripes = tables::extract_row_stripes(data);
let normal_font = extract_normal_font(data);

Expand Down Expand Up @@ -405,6 +408,7 @@ impl XlsxParser {
};

let sheet_name = sheet.get_name().to_string();
let sheet_prints_gridlines: bool = gridline_sheets.contains(&sheet_name);

// Extract sheet header/footer
let hf = sheet.get_header_footer();
Expand Down Expand Up @@ -488,6 +492,7 @@ impl XlsxParser {
default_vertical_align: Some(crate::ir::CellVerticalAlign::Bottom),
seats_bottom_aligned_text_on_descender: true,
paints_borders_inside_boundary: true,
prints_gridlines: sheet_prints_gridlines,
},
header: sheet_header.clone(),
footer: sheet_footer.clone(),
Expand Down Expand Up @@ -551,6 +556,7 @@ impl Parser for XlsxParser {
let metadata = extract_xlsx_metadata(&book);
let cond_fmt_hints = cond_fmt_raw::extract_cond_fmt_hints(data);
let fitting_sheets = fit_to_page::sheets_fitting_to_page(data);
let gridline_sheets = print_options::sheets_printing_gridlines(data);
let mut row_stripes = tables::extract_row_stripes(data);
let normal_font = extract_normal_font(data);

Expand Down Expand Up @@ -637,6 +643,7 @@ impl Parser for XlsxParser {
// Collect row page breaks and split rows into page segments
let row_breaks = collect_row_breaks(sheet);
let sheet_name = sheet.get_name().to_string();
let sheet_prints_gridlines: bool = gridline_sheets.contains(&sheet_name);

// Extract sheet header/footer
let hf = sheet.get_header_footer();
Expand Down Expand Up @@ -689,6 +696,7 @@ impl Parser for XlsxParser {
default_vertical_align: Some(crate::ir::CellVerticalAlign::Bottom),
seats_bottom_aligned_text_on_descender: true,
paints_borders_inside_boundary: true,
prints_gridlines: sheet_prints_gridlines,
},
header: sheet_header.clone(),
footer: sheet_footer.clone(),
Expand Down Expand Up @@ -766,6 +774,7 @@ impl Parser for XlsxParser {
),
seats_bottom_aligned_text_on_descender: true,
paints_borders_inside_boundary: true,
prints_gridlines: sheet_prints_gridlines,
},
header: sheet_header.clone(),
footer: sheet_footer.clone(),
Expand Down
1 change: 1 addition & 0 deletions crates/office2pdf/src/parser/xlsx_pagination.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ fn slice_table_columns(table: &Table, start: usize, end: usize) -> Table {
default_vertical_align: table.default_vertical_align,
seats_bottom_aligned_text_on_descender: table.seats_bottom_aligned_text_on_descender,
paints_borders_inside_boundary: table.paints_borders_inside_boundary,
prints_gridlines: table.prints_gridlines,
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/office2pdf/src/parser/xlsx_pagination_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ fn make_page(column_widths: Vec<f64>, rows: Vec<TableRow>) -> SheetPage {
default_vertical_align: None,
seats_bottom_aligned_text_on_descender: false,
paints_borders_inside_boundary: false,
prints_gridlines: false,
},
header: None,
footer: None,
Expand Down
101 changes: 101 additions & 0 deletions crates/office2pdf/src/parser/xlsx_print_options.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
use std::collections::HashSet;

use quick_xml::Reader;
use quick_xml::events::{BytesStart, Event};

use super::cond_fmt_raw::{
parse_relationships, parse_sheet_relationships, read_zip_text, worksheet_path,
};

/// Names of the sheets whose `<printOptions gridLines="1"/>` is set.
///
/// The flag strictly gates printed gridlines: native Excel GT of a sheet
/// without it contains zero gridline primitives, while a flagged sheet rules
/// every cell boundary of the printed range (issue #622). umya-spreadsheet's
/// `PrintOptions` models only the centering attributes, so the flag is read
/// from the archive directly, like `sheets_fitting_to_page`.
pub(crate) fn sheets_printing_gridlines(data: &[u8]) -> HashSet<String> {
let mut printing: HashSet<String> = HashSet::new();
let Ok(mut archive) = crate::parser::open_zip(data) else {
return printing;
};
let Some(workbook_xml) = read_zip_text(&mut archive, "xl/workbook.xml") else {
return printing;
};
let Some(relationships_xml) = read_zip_text(&mut archive, "xl/_rels/workbook.xml.rels") else {
return printing;
};

let relationships = parse_relationships(&relationships_xml);
for (sheet_name, relationship_id) in parse_sheet_relationships(&workbook_xml) {
let Some(target) = relationships.get(&relationship_id) else {
continue;
};
let Some(worksheet_xml) = read_zip_text(&mut archive, &worksheet_path(target)) else {
continue;
};
if worksheet_prints_gridlines(&worksheet_xml) {
printing.insert(sheet_name);
}
}
printing
}

/// Whether the worksheet's `<printOptions>` asks for printed gridlines.
///
/// Unlike `<pageSetUpPr>`, `<printOptions>` follows `<sheetData>` in
/// CT_Worksheet, so the scan must run to the end of the document. The
/// `<customSheetViews>` subtree is skipped: CT_Worksheet places it before the
/// sheet-level `<printOptions>`, and each CT_CustomSheetView nests its own
/// `<printOptions>`, which describes that saved view only — a first-match
/// scan would read the view's options and shadow the sheet's.
fn worksheet_prints_gridlines(worksheet_xml: &str) -> bool {
let mut reader = Reader::from_str(worksheet_xml);
// Element depth inside the skipped `<customSheetViews>` subtree; 0 means
// the scan is at sheet level.
let mut skipped_subtree_depth: usize = 0;
loop {
match reader.read_event() {
Ok(Event::Start(ref element)) => {
if skipped_subtree_depth > 0 {
skipped_subtree_depth += 1;
} else if element.local_name().as_ref() == b"customSheetViews" {
skipped_subtree_depth = 1;
} else if element.local_name().as_ref() == b"printOptions" {
return print_options_request_gridlines(element);
}
}
Ok(Event::Empty(ref element)) => {
if skipped_subtree_depth == 0 && element.local_name().as_ref() == b"printOptions" {
return print_options_request_gridlines(element);
}
}
Ok(Event::End(_)) => {
skipped_subtree_depth = skipped_subtree_depth.saturating_sub(1);
}
Ok(Event::Eof) | Err(_) => return false,
_ => {}
}
}
}

/// ECMA-376 §18.3.1.70: gridlines print only when `gridLines` and
/// `gridLinesSet` (default true) are both true — an explicit
/// `gridLinesSet="0"` vetoes `gridLines="1"`.
fn print_options_request_gridlines(element: &BytesStart<'_>) -> bool {
let mut grid_lines: bool = false;
let mut grid_lines_set: bool = true;
for attribute in element.attributes().flatten() {
let is_on: bool = matches!(attribute.value.as_ref(), b"1" | b"true");
match attribute.key.local_name().as_ref() {
b"gridLines" => grid_lines = is_on,
b"gridLinesSet" => grid_lines_set = is_on,
_ => {}
}
}
grid_lines && grid_lines_set
}

#[cfg(test)]
#[path = "xlsx_print_options_tests.rs"]
mod tests;
95 changes: 95 additions & 0 deletions crates/office2pdf/src/parser/xlsx_print_options_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
use super::*;

// `printOptions` follows `sheetData` in CT_Worksheet — the real
// NumberFormatTests fixture writes `<printOptions headings="1"
// gridLines="1"/>` between the sheet data and the page margins.
const PRINTS_GRIDLINES: &str = r#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData>
<printOptions headings="1" gridLines="1"/>
<pageMargins left="0.7" right="0.7" top="0.75" bottom="0.75" header="0.3" footer="0.3"/>
</worksheet>"#;

const NO_PRINT_OPTIONS: &str = r#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData>
<pageMargins left="0.7" right="0.7" top="0.75" bottom="0.75" header="0.3" footer="0.3"/>
</worksheet>"#;

// Excel also writes `printOptions` for centering alone; that must not turn
// gridlines on.
const CENTERED_ONLY: &str = r#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData/>
<printOptions horizontalCentered="1"/>
</worksheet>"#;

// CT_Worksheet orders `customSheetViews` before the sheet-level
// `printOptions`, and each custom view nests its own `<printOptions>`
// (CT_CustomSheetView). A first-match scan reads the view's options instead
// of the sheet's.
const CUSTOM_VIEW_THEN_SHEET_LEVEL: &str = r#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData>
<customSheetViews>
<customSheetView guid="{3C29A897-4F3B-4A0B-9A5C-2D53E1F1F001}" scale="85">
<selection activeCell="A1" sqref="A1"/>
<printOptions horizontalCentered="1"/>
<pageMargins left="0.7" right="0.7" top="0.75" bottom="0.75" header="0.3" footer="0.3"/>
</customSheetView>
</customSheetViews>
<printOptions headings="1" gridLines="1"/>
<pageMargins left="0.7" right="0.7" top="0.75" bottom="0.75" header="0.3" footer="0.3"/>
</worksheet>"#;

const CUSTOM_VIEW_GRIDLINES_ONLY: &str = r#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData>
<customSheetViews>
<customSheetView guid="{3C29A897-4F3B-4A0B-9A5C-2D53E1F1F001}" printArea="1">
<printOptions gridLines="1"/>
<pageMargins left="0.7" right="0.7" top="0.75" bottom="0.75" header="0.3" footer="0.3"/>
</customSheetView>
</customSheetViews>
<pageMargins left="0.7" right="0.7" top="0.75" bottom="0.75" header="0.3" footer="0.3"/>
</worksheet>"#;

#[test]
fn reads_grid_lines_after_sheet_data() {
assert!(worksheet_prints_gridlines(PRINTS_GRIDLINES));
}

#[test]
fn custom_view_print_options_do_not_shadow_the_sheet_level_element() {
assert!(worksheet_prints_gridlines(CUSTOM_VIEW_THEN_SHEET_LEVEL));
}

#[test]
fn custom_view_grid_lines_do_not_leak_to_the_sheet() {
assert!(!worksheet_prints_gridlines(CUSTOM_VIEW_GRIDLINES_ONLY));
}

#[test]
fn explicit_grid_lines_set_false_vetoes_grid_lines() {
// ECMA-376 §18.3.1.70: gridlines print only when `gridLines` AND
// `gridLinesSet` (default true) are both true.
let vetoed = PRINTS_GRIDLINES.replace(r#"gridLines="1""#, r#"gridLines="1" gridLinesSet="0""#);
assert!(!worksheet_prints_gridlines(&vetoed));
let vetoed_spelt = PRINTS_GRIDLINES.replace(
r#"gridLines="1""#,
r#"gridLines="true" gridLinesSet="false""#,
);
assert!(!worksheet_prints_gridlines(&vetoed_spelt));
let confirmed =
PRINTS_GRIDLINES.replace(r#"gridLines="1""#, r#"gridLines="1" gridLinesSet="1""#);
assert!(worksheet_prints_gridlines(&confirmed));
}

#[test]
fn absent_or_unrelated_print_options_stay_off() {
assert!(!worksheet_prints_gridlines(NO_PRINT_OPTIONS));
assert!(!worksheet_prints_gridlines(CENTERED_ONLY));
}

#[test]
fn accepts_the_boolean_spelt_out() {
let spelt = PRINTS_GRIDLINES.replace(r#"gridLines="1""#, r#"gridLines="true""#);
assert!(worksheet_prints_gridlines(&spelt));
let off = PRINTS_GRIDLINES.replace(r#"gridLines="1""#, r#"gridLines="0""#);
assert!(!worksheet_prints_gridlines(&off));
}
50 changes: 50 additions & 0 deletions crates/office2pdf/src/parser/xlsx_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -946,3 +946,53 @@ fn test_empty_sheet_context_derives_metric_from_normal_font() {
assert_eq!(fallback_ctx.default_column_width_pt, 8.0 * 5.25 + 5.0);
assert_eq!(fallback_ctx.normal_font, None);
}

/// Rewrite the workbook's worksheet parts, inserting `insertion` before each
/// closing `</worksheet>` tag. umya's writer does not model
/// `printOptions@gridLines`, so the attribute is injected into the archive
/// the way Excel writes it — after `sheetData`.
fn inject_before_worksheet_close(xlsx: &[u8], insertion: &str) -> Vec<u8> {
let mut archive = zip::ZipArchive::new(Cursor::new(xlsx.to_vec())).unwrap();
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
for index in 0..archive.len() {
let mut file = archive.by_index(index).unwrap();
let name: String = file.name().to_string();
let mut contents: Vec<u8> = Vec::new();
std::io::Read::read_to_end(&mut file, &mut contents).unwrap();
if name.starts_with("xl/worksheets/") && name.ends_with(".xml") {
let text: String = String::from_utf8(contents).unwrap();
contents = text
.replace("</worksheet>", &format!("{insertion}</worksheet>"))
.into_bytes();
}
writer
.start_file(name, zip::write::FileOptions::default())
.unwrap();
std::io::Write::write_all(&mut writer, &contents).unwrap();
}
writer.finish().unwrap().into_inner()
}

#[test]
fn test_print_options_grid_lines_flags_the_sheet_table() {
let plain = build_xlsx_bytes("Sheet1", &[("A1", "x"), ("B2", "y")]);
let flagged = inject_before_worksheet_close(&plain, r#"<printOptions gridLines="1"/>"#);

let parser = XlsxParser;
let (doc, _warnings) = parser.parse(&flagged, &ConvertOptions::default()).unwrap();
let table = &get_sheet_page(&doc, 0).table;
assert!(
table.prints_gridlines,
"printOptions gridLines must set the table's gridline flag"
);
assert!(
table.paints_borders_inside_boundary,
"the gridline flag rides on the boundary-band regime"
);

let (doc, _warnings) = parser.parse(&plain, &ConvertOptions::default()).unwrap();
assert!(
!get_sheet_page(&doc, 0).table.prints_gridlines,
"a sheet without printOptions must not print gridlines"
);
}
Loading
Loading