diff --git a/assets/bugfixes/issue-622/after.jpg b/assets/bugfixes/issue-622/after.jpg
new file mode 100644
index 00000000..cdaf784d
Binary files /dev/null and b/assets/bugfixes/issue-622/after.jpg differ
diff --git a/assets/bugfixes/issue-622/before.jpg b/assets/bugfixes/issue-622/before.jpg
new file mode 100644
index 00000000..49a30358
Binary files /dev/null and b/assets/bugfixes/issue-622/before.jpg differ
diff --git a/assets/bugfixes/issue-622/gt.jpg b/assets/bugfixes/issue-622/gt.jpg
new file mode 100644
index 00000000..71099847
Binary files /dev/null and b/assets/bugfixes/issue-622/gt.jpg differ
diff --git a/crates/office2pdf/src/ir/elements.rs b/crates/office2pdf/src/ir/elements.rs
index dd1cf87d..22b3ad1f 100644
--- a/crates/office2pdf/src/ir/elements.rs
+++ b/crates/office2pdf/src/ir/elements.rs
@@ -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, `` 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.
diff --git a/crates/office2pdf/src/parser/docx_tables.rs b/crates/office2pdf/src/parser/docx_tables.rs
index 6ec13e86..9747e0e7 100644
--- a/crates/office2pdf/src/parser/docx_tables.rs
+++ b/crates/office2pdf/src/parser/docx_tables.rs
@@ -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,
}
}
diff --git a/crates/office2pdf/src/parser/pptx_table_style_tests.rs b/crates/office2pdf/src/parser/pptx_table_style_tests.rs
index 22e89430..2dd838dc 100644
--- a/crates/office2pdf/src/parser/pptx_table_style_tests.rs
+++ b/crates/office2pdf/src/parser/pptx_table_style_tests.rs
@@ -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);
@@ -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);
@@ -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);
@@ -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);
diff --git a/crates/office2pdf/src/parser/pptx_tables.rs b/crates/office2pdf/src/parser/pptx_tables.rs
index a2f0e6e4..e55fdd9e 100644
--- a/crates/office2pdf/src/parser/pptx_tables.rs
+++ b/crates/office2pdf/src/parser/pptx_tables.rs
@@ -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
diff --git a/crates/office2pdf/src/parser/xlsx.rs b/crates/office2pdf/src/parser/xlsx.rs
index 10d5cbb7..e20d7f11 100644
--- a/crates/office2pdf/src/parser/xlsx.rs
+++ b/crates/office2pdf/src/parser/xlsx.rs
@@ -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"]
@@ -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);
@@ -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();
@@ -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(),
@@ -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);
@@ -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();
@@ -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(),
@@ -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(),
diff --git a/crates/office2pdf/src/parser/xlsx_pagination.rs b/crates/office2pdf/src/parser/xlsx_pagination.rs
index e7567dfe..c8d9aa5b 100644
--- a/crates/office2pdf/src/parser/xlsx_pagination.rs
+++ b/crates/office2pdf/src/parser/xlsx_pagination.rs
@@ -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,
}
}
diff --git a/crates/office2pdf/src/parser/xlsx_pagination_tests.rs b/crates/office2pdf/src/parser/xlsx_pagination_tests.rs
index e23d4aea..cf14a301 100644
--- a/crates/office2pdf/src/parser/xlsx_pagination_tests.rs
+++ b/crates/office2pdf/src/parser/xlsx_pagination_tests.rs
@@ -67,6 +67,7 @@ fn make_page(column_widths: Vec, rows: Vec) -> SheetPage {
default_vertical_align: None,
seats_bottom_aligned_text_on_descender: false,
paints_borders_inside_boundary: false,
+ prints_gridlines: false,
},
header: None,
footer: None,
diff --git a/crates/office2pdf/src/parser/xlsx_print_options.rs b/crates/office2pdf/src/parser/xlsx_print_options.rs
new file mode 100644
index 00000000..e40a4ce4
--- /dev/null
+++ b/crates/office2pdf/src/parser/xlsx_print_options.rs
@@ -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 `` 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 {
+ let mut printing: HashSet = 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 `` asks for printed gridlines.
+///
+/// Unlike ``, `` follows `` in
+/// CT_Worksheet, so the scan must run to the end of the document. The
+/// `` subtree is skipped: CT_Worksheet places it before the
+/// sheet-level ``, and each CT_CustomSheetView nests its own
+/// ``, 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 `` 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;
diff --git a/crates/office2pdf/src/parser/xlsx_print_options_tests.rs b/crates/office2pdf/src/parser/xlsx_print_options_tests.rs
new file mode 100644
index 00000000..cb8bf609
--- /dev/null
+++ b/crates/office2pdf/src/parser/xlsx_print_options_tests.rs
@@ -0,0 +1,95 @@
+use super::*;
+
+// `printOptions` follows `sheetData` in CT_Worksheet — the real
+// NumberFormatTests fixture writes `` between the sheet data and the page margins.
+const PRINTS_GRIDLINES: &str = r#"
+ 1
+
+
+"#;
+
+const NO_PRINT_OPTIONS: &str = r#"
+ 1
+
+"#;
+
+// Excel also writes `printOptions` for centering alone; that must not turn
+// gridlines on.
+const CENTERED_ONLY: &str = r#"
+
+
+"#;
+
+// CT_Worksheet orders `customSheetViews` before the sheet-level
+// `printOptions`, and each custom view nests its own ``
+// (CT_CustomSheetView). A first-match scan reads the view's options instead
+// of the sheet's.
+const CUSTOM_VIEW_THEN_SHEET_LEVEL: &str = r#"
+ 1
+
+
+
+
+
+
+
+
+
+"#;
+
+const CUSTOM_VIEW_GRIDLINES_ONLY: &str = r#"
+ 1
+
+
+
+
+
+
+
+"#;
+
+#[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));
+}
diff --git a/crates/office2pdf/src/parser/xlsx_tests.rs b/crates/office2pdf/src/parser/xlsx_tests.rs
index 6799d918..59c45ef6 100644
--- a/crates/office2pdf/src/parser/xlsx_tests.rs
+++ b/crates/office2pdf/src/parser/xlsx_tests.rs
@@ -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 `` 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 {
+ 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 = 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("", &format!("{insertion}"))
+ .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#""#);
+
+ 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"
+ );
+}
diff --git a/crates/office2pdf/src/render/typst_gen.rs b/crates/office2pdf/src/render/typst_gen.rs
index 8ee16aa4..5f865eb0 100644
--- a/crates/office2pdf/src/render/typst_gen.rs
+++ b/crates/office2pdf/src/render/typst_gen.rs
@@ -661,6 +661,7 @@ fn generate_table_with_anchors(
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,
};
generate_table(out, &segment, ctx)?;
out.push('\n');
@@ -699,6 +700,7 @@ fn generate_table_with_anchors(
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,
};
generate_table(out, &segment, ctx)?;
out.push('\n');
diff --git a/crates/office2pdf/src/render/typst_gen_table_border_tests.rs b/crates/office2pdf/src/render/typst_gen_table_border_tests.rs
index be2d4bed..5c147a7e 100644
--- a/crates/office2pdf/src/render/typst_gen_table_border_tests.rs
+++ b/crates/office2pdf/src/render/typst_gen_table_border_tests.rs
@@ -1083,3 +1083,273 @@ fn test_boundary_band_auto_row_frame_estimate_computed_once_per_row() {
(2 rows), got {estimate_calls} calls"
);
}
+
+// ---------------------------------------------------------------------------
+// Printed gridlines (issue #622)
+//
+// `` prints Excel's gridline on every cell
+// boundary of the printed range. Measured on native Excel exports of the
+// NumberFormatTests fixture (/Volumes/T7/scratch/issue-622/nft2-p1.rects.txt,
+// nft2-p2.trace): every gridline is a fill band exactly 1.0pt thick, pure
+// black, boundary-anchored [B, B+1] toward +x/+y — the same convention as the
+// #619 thin border band. Any explicit border outranks the gridline on its
+// boundary (a hair border replaces the black gridline at C337), and a cell
+// fill suppresses all four adjacent gridline segments.
+// ---------------------------------------------------------------------------
+
+/// A borderless text cell, the shape most sheet cells have.
+fn plain_text_cell(text: &str) -> TableCell {
+ TableCell {
+ content: vec![Block::Paragraph(Paragraph {
+ style: ParagraphStyle::default(),
+ runs: vec![Run {
+ text: text.to_string(),
+ style: TextStyle::default(),
+ href: None,
+ footnote: None,
+ }],
+ })],
+ ..TableCell::default()
+ }
+}
+
+fn gridline_table(rows: Vec, column_widths: Vec) -> Table {
+ Table {
+ rows,
+ column_widths,
+ paints_borders_inside_boundary: true,
+ prints_gridlines: true,
+ ..Table::default()
+ }
+}
+
+#[test]
+fn test_printed_gridlines_rule_every_boundary_at_measured_geometry() {
+ let table = gridline_table(vec![fixed_row(vec![plain_text_cell("A1")])], vec![100.0]);
+ let doc = make_doc(vec![make_flow_page(vec![Block::Table(table)])]);
+ let result = generate_typst(&doc).unwrap().source;
+
+ // Gridline = 1pt pure black band [B, B+1]: with the default 5pt padding
+ // the top boundary sits at inset.top = 5pt, so the band's centre line is
+ // at dy = -5 + 0.5 = -4.5pt, exactly the #619 thin-border geometry.
+ assert!(
+ result.contains(
+ "#place(top + left, dx: -5pt, dy: -4.5pt, line(length: 100% + 11pt, angle: 0deg, stroke: 1pt + rgb(0, 0, 0)))"
+ ),
+ "top gridline must fill [B, B+1] below the top boundary: {result}"
+ );
+ assert!(
+ result.contains(
+ "#place(bottom + left, dx: -5pt, dy: 5.5pt, line(length: 100% + 11pt, angle: 0deg, stroke: 1pt + rgb(0, 0, 0)))"
+ ),
+ "bottom gridline must fill [B, B+1] below the bottom boundary: {result}"
+ );
+ assert!(
+ result.contains(
+ "#place(top + left, dx: -4.5pt, dy: -5pt, line(length: 21pt, angle: 90deg, stroke: 1pt + rgb(0, 0, 0)))"
+ ),
+ "left gridline must fill [B, B+1] right of the left boundary: {result}"
+ );
+ assert!(
+ result.contains(
+ "#place(top + right, dx: 5.5pt, dy: -5pt, line(length: 21pt, angle: 90deg, stroke: 1pt + rgb(0, 0, 0)))"
+ ),
+ "right gridline must fill [B, B+1] right of the right boundary: {result}"
+ );
+}
+
+#[test]
+fn test_printed_gridlines_paint_interior_boundaries_from_both_sides() {
+ // GT closes the grid at every page break: the row above the break draws
+ // the bottom rule. Which row breaks a page only the renderer knows, so
+ // every cell paints its own bottom (and right-columnless top) band; the
+ // two seeds of an interior boundary are boundary-anchored to the same
+ // [B, B+1] strip and coincide invisibly.
+ let table = gridline_table(
+ vec![
+ fixed_row(vec![plain_text_cell("A1"), plain_text_cell("B1")]),
+ fixed_row(vec![plain_text_cell("A2"), plain_text_cell("B2")]),
+ ],
+ vec![100.0, 100.0],
+ );
+ let doc = make_doc(vec![make_flow_page(vec![Block::Table(table)])]);
+ let result = generate_typst(&doc).unwrap().source;
+
+ assert_eq!(
+ result.matches("angle: 0deg").count(),
+ 8,
+ "each of the 4 cells must paint its top and bottom gridline: {result}"
+ );
+ assert_eq!(
+ result.matches("angle: 90deg").count(),
+ 8,
+ "each of the 4 cells must paint its left and right gridline: {result}"
+ );
+}
+
+#[test]
+fn test_explicit_border_outranks_gridline_on_its_boundary() {
+ // A medium top border owns its boundary: no black 1pt gridline may paint
+ // there, while the other three boundaries keep theirs.
+ let bordered = bordered_text_cell(
+ "Med",
+ CellBorder {
+ top: Some(solid_side(2.0)),
+ bottom: None,
+ left: None,
+ right: None,
+ },
+ );
+ let table = gridline_table(vec![fixed_row(vec![bordered])], vec![100.0]);
+ let doc = make_doc(vec![make_flow_page(vec![Block::Table(table)])]);
+ let result = generate_typst(&doc).unwrap().source;
+
+ // Medium band [B-1, B+1] centred on the boundary at inset.top = 6pt.
+ assert!(
+ result.contains(
+ "#place(top + left, dx: -5pt, dy: -6pt, line(length: 100% + 11pt, angle: 0deg, stroke: 2pt + rgb(0, 0, 0)))"
+ ),
+ "the explicit medium border must paint its boundary: {result}"
+ );
+ assert_eq!(
+ result.matches("angle: 0deg").count(),
+ 2,
+ "the top boundary must carry only the medium band, the bottom only \
+ its gridline: {result}"
+ );
+ assert!(
+ result.contains(
+ "#place(bottom + left, dx: -5pt, dy: 5.5pt, line(length: 100% + 11pt, angle: 0deg, stroke: 1pt + rgb(0, 0, 0)))"
+ ),
+ "the undeclared bottom boundary must keep its gridline: {result}"
+ );
+}
+
+#[test]
+fn test_hair_border_replaces_gridline_not_the_reverse() {
+ // GT: C337's hair borders replace the black gridline at their boundary
+ // even though a solid rule outranks a patterned one in the #619 conflict
+ // rank — the gridline is below every explicit declaration, not a peer.
+ let haired = bordered_text_cell(
+ "Hair",
+ CellBorder {
+ top: Some(BorderSide {
+ width: 1.0,
+ color: Color::black(),
+ style: BorderLineStyle::Dotted,
+ }),
+ bottom: None,
+ left: None,
+ right: None,
+ },
+ );
+ let table = gridline_table(vec![fixed_row(vec![haired])], vec![100.0]);
+ let doc = make_doc(vec![make_flow_page(vec![Block::Table(table)])]);
+ let result = generate_typst(&doc).unwrap().source;
+
+ assert!(
+ result.contains(
+ "#place(top + left, dx: -5pt, dy: -5pt, line(length: 100% + 11pt, angle: 0deg, stroke: (paint: rgb(0, 0, 0), thickness: 1pt, dash: \"dotted\")))"
+ ),
+ "the hair border must paint its boundary: {result}"
+ );
+ assert_eq!(
+ result.matches("angle: 0deg").count(),
+ 2,
+ "no solid gridline may double the hair boundary: {result}"
+ );
+ assert!(
+ !result.contains("dy: -5pt, line(length: 100% + 11pt, angle: 0deg, stroke: 1pt"),
+ "the gridline must yield to the hair border: {result}"
+ );
+}
+
+#[test]
+fn test_cell_fill_suppresses_adjacent_gridlines() {
+ // GT (Tests p1 vs the fill-free p2): a cell fill suppresses all four
+ // adjacent gridline segments — Excel truncates the verticals at the
+ // filled row and omits the horizontal at its bottom boundary.
+ let filled = TableCell {
+ background: Some(Color::new(237, 125, 49)),
+ ..plain_text_cell("Filled")
+ };
+ let table = gridline_table(
+ vec![fixed_row(vec![filled, plain_text_cell("Plain")])],
+ vec![100.0, 100.0],
+ );
+ let doc = make_doc(vec![make_flow_page(vec![Block::Table(table)])]);
+ let result = generate_typst(&doc).unwrap().source;
+
+ // Filled cell: no bands at all. Plain cell: its left boundary abuts the
+ // fill and is suppressed too; top, bottom, and right survive.
+ assert_eq!(
+ result.matches("angle: 0deg").count(),
+ 2,
+ "only the plain cell's top and bottom gridlines may paint: {result}"
+ );
+ assert_eq!(
+ result.matches("angle: 90deg").count(),
+ 1,
+ "only the plain cell's right gridline may paint: {result}"
+ );
+ assert!(
+ result.contains("#place(top + right, dx: 5.5pt"),
+ "the surviving vertical must be the plain cell's right band: {result}"
+ );
+}
+
+#[test]
+fn test_gridlines_repeat_with_the_print_title_header() {
+ // A print-title header repeats on every page; its own top and bottom
+ // gridline seeds must repeat with it so the grid stays closed under the
+ // header on pages 2+.
+ let mut table = gridline_table(
+ vec![
+ fixed_row(vec![plain_text_cell("Head")]),
+ fixed_row(vec![plain_text_cell("Body")]),
+ ],
+ vec![100.0],
+ );
+ table.header_row_count = 1;
+ let doc = make_doc(vec![make_flow_page(vec![Block::Table(table)])]);
+ let result = generate_typst(&doc).unwrap().source;
+
+ let header_pos: usize = result
+ .find("table.header(")
+ .expect("header block must exist");
+ let head_text_pos: usize = result.find("Head]").expect("header cell must exist");
+ let header_cell: &str = &result[header_pos..head_text_pos];
+ assert_eq!(
+ header_cell.matches("angle: 0deg").count(),
+ 2,
+ "the header cell must carry its top and bottom gridline bands inside \
+ the repeating header block: {result}"
+ );
+}
+
+#[test]
+fn test_gridlines_absent_without_the_flag() {
+ // The same sheet without `printOptions gridLines` prints no gridlines at
+ // all: the native-export probe workbooks measured for the #621 column
+ // model declare no printOptions element and their GT traces carry zero
+ // gridline primitives, so the flag strictly gates printing (#622).
+ let mut unflagged = gridline_table(vec![fixed_row(vec![plain_text_cell("A1")])], vec![100.0]);
+ unflagged.prints_gridlines = false;
+ let doc = make_doc(vec![make_flow_page(vec![Block::Table(unflagged)])]);
+ let result = generate_typst(&doc).unwrap().source;
+ assert!(
+ !result.contains("#place("),
+ "a borderless sheet without the flag must paint nothing: {result}"
+ );
+
+ // The gridline convention is measured only for Excel's boundary-band
+ // regime; a table outside it (Word/PowerPoint) must ignore the flag.
+ let mut word_style = gridline_table(vec![fixed_row(vec![plain_text_cell("W")])], vec![100.0]);
+ word_style.paints_borders_inside_boundary = false;
+ let doc = make_doc(vec![make_flow_page(vec![Block::Table(word_style)])]);
+ let result = generate_typst(&doc).unwrap().source;
+ assert!(
+ !result.contains("#place("),
+ "gridlines must not leak outside the boundary-band regime: {result}"
+ );
+}
diff --git a/crates/office2pdf/src/render/typst_gen_table_cell_content_tests.rs b/crates/office2pdf/src/render/typst_gen_table_cell_content_tests.rs
index 4edde7d9..1ed01a45 100644
--- a/crates/office2pdf/src/render/typst_gen_table_cell_content_tests.rs
+++ b/crates/office2pdf/src/render/typst_gen_table_cell_content_tests.rs
@@ -694,6 +694,7 @@ fn bottom_aligned_spreadsheet_cell_seats_its_line_box_on_the_descender() {
default_vertical_align: Some(CellVerticalAlign::Bottom),
seats_bottom_aligned_text_on_descender: true,
paints_borders_inside_boundary: false,
+ prints_gridlines: false,
..Table::default()
};
let doc = make_doc(vec![make_flow_page(vec![Block::Table(table)])]);
@@ -759,6 +760,7 @@ fn center_aligned_spreadsheet_cell_keeps_the_symmetric_line_box() {
default_vertical_align: Some(CellVerticalAlign::Bottom),
seats_bottom_aligned_text_on_descender: true,
paints_borders_inside_boundary: false,
+ prints_gridlines: false,
..Table::default()
};
let doc = make_doc(vec![make_flow_page(vec![Block::Table(table)])]);
@@ -823,6 +825,7 @@ fn bottom_aligned_spreadsheet_cell_in_auto_height_row_keeps_the_symmetric_line_b
default_vertical_align: Some(CellVerticalAlign::Bottom),
seats_bottom_aligned_text_on_descender: true,
paints_borders_inside_boundary: false,
+ prints_gridlines: false,
..Table::default()
};
let doc = make_doc(vec![make_flow_page(vec![Block::Table(table)])]);
diff --git a/crates/office2pdf/src/render/typst_gen_table_codegen_tests.rs b/crates/office2pdf/src/render/typst_gen_table_codegen_tests.rs
index dc2631fc..06f3eae4 100644
--- a/crates/office2pdf/src/render/typst_gen_table_codegen_tests.rs
+++ b/crates/office2pdf/src/render/typst_gen_table_codegen_tests.rs
@@ -67,6 +67,7 @@ fn test_table_with_default_cell_padding() {
default_vertical_align: None,
seats_bottom_aligned_text_on_descender: false,
paints_borders_inside_boundary: false,
+ prints_gridlines: false,
};
let doc = make_doc(vec![make_flow_page(vec![Block::Table(table)])]);
let result = generate_typst(&doc).unwrap().source;
@@ -116,6 +117,7 @@ fn test_table_cell_with_padding_override() {
default_vertical_align: None,
seats_bottom_aligned_text_on_descender: false,
paints_borders_inside_boundary: false,
+ prints_gridlines: false,
};
let doc = make_doc(vec![make_flow_page(vec![Block::Table(table)])]);
let result = generate_typst(&doc).unwrap().source;
@@ -142,6 +144,7 @@ fn test_table_alignment_center_wraps_table() {
default_vertical_align: None,
seats_bottom_aligned_text_on_descender: false,
paints_borders_inside_boundary: false,
+ prints_gridlines: false,
};
let doc = make_doc(vec![make_flow_page(vec![Block::Table(table)])]);
let result = generate_typst(&doc).unwrap().source;
@@ -817,6 +820,7 @@ fn bottom_aligned_spill_cell_anchors_its_line_box_at_the_bottom() {
default_vertical_align: Some(CellVerticalAlign::Bottom),
seats_bottom_aligned_text_on_descender: true,
paints_borders_inside_boundary: false,
+ prints_gridlines: false,
..Table::default()
};
let doc = make_doc(vec![make_flow_page(vec![Block::Table(table)])]);
@@ -873,6 +877,7 @@ fn center_aligned_spill_cell_keeps_the_centered_wrapper() {
default_vertical_align: Some(CellVerticalAlign::Bottom),
seats_bottom_aligned_text_on_descender: true,
paints_borders_inside_boundary: false,
+ prints_gridlines: false,
..Table::default()
};
let doc = make_doc(vec![make_flow_page(vec![Block::Table(table)])]);
diff --git a/crates/office2pdf/src/render/typst_gen_tables.rs b/crates/office2pdf/src/render/typst_gen_tables.rs
index ca3697ad..bc1fbc80 100644
--- a/crates/office2pdf/src/render/typst_gen_tables.rs
+++ b/crates/office2pdf/src/render/typst_gen_tables.rs
@@ -762,6 +762,28 @@ fn cell_inset_with_border(cell: &TableCell, default_cell_padding: Insets) -> Ins
/// #619). It is what lets horizontal bands own the corner blocks.
const BAND_RUN_END_EXTENSION_PT: f64 = 1.0;
+/// Width of Excel's printed gridline band, in points.
+///
+/// Measured on native Excel exports of NumberFormatTests (issue #622,
+/// /Volumes/T7/scratch/issue-622/nft2-p1.rects.txt and nft2-p2.trace): every
+/// gridline is an axis-aligned fill rect exactly 1.0pt thick filling the
+/// boundary band [B, B+1] — no stroke ops and no fractional hairlines exist
+/// anywhere in the traces.
+const PRINTED_GRIDLINE_WIDTH_PT: f64 = 1.0;
+
+/// The side a printed gridline paints on an unowned boundary.
+///
+/// Pure black, not gray and not a theme colour: the GT traces fill every
+/// gridline with "0 0 0" in ICCBased sRGB (issue #622 measurement — the
+/// common assumption of gray printed gridlines is wrong for Excel GT).
+fn printed_gridline_side() -> BorderSide {
+ BorderSide {
+ width: PRINTED_GRIDLINE_WIDTH_PT,
+ color: Color::black(),
+ style: BorderLineStyle::Solid,
+ }
+}
+
/// Total order for Excel's shared-boundary conflict rule (issue #619 review,
/// remediation 1). Derived `PartialOrd` compares the fields lexicographically
/// in declaration order.
@@ -825,7 +847,7 @@ fn resolve_boundary_painted_borders(
num_cols: usize,
repeating_header_boundary: Option,
) -> Vec>> {
- use std::collections::HashMap;
+ use std::collections::{HashMap, HashSet};
/// Grid footprint of one emitted cell.
struct CellPlacement {
@@ -1024,6 +1046,101 @@ fn resolve_boundary_painted_borders(
painted[placement.row_index][placement.cell_index] = Some(resolved);
}
}
+
+ // Printed gridlines (issue #622): `` rules
+ // every cell boundary of the printed range with Excel's gridline band,
+ // strictly below any explicit declaration — a boundary owned by any
+ // declared side (either neighbour's) keeps that side alone, hair borders
+ // included, which the #619 rank would otherwise wrongly outrank. Every
+ // placement seeds all four of its unowned sides: the two seeds of an
+ // interior boundary are boundary-anchored to the same [B, B+1] strip and
+ // coincide invisibly, and the redundant bottom seed is what closes the
+ // grid at a page break, where GT draws the bottom rule (which row ends a
+ // page only the renderer knows).
+ if table.prints_gridlines {
+ // A cell fill suppresses all four adjacent gridline segments: GT
+ // truncates the interior verticals at a filled row and omits the
+ // horizontal at the fill's bottom boundary (Tests p1 vs the
+ // fill-free p2 control), because fills paint after gridlines.
+ //
+ // TODO(#622 follow-up: a background-filled row that lands as the
+ // first row of a page under natural pagination leaves the previous
+ // page's grid open at that boundary — GT closes it; suppression is
+ // kept because an unsuppressed band would paint over the fill's top
+ // edge on every within-page filled row, the far more common case).
+ let mut fill_suppressed_horizontal: HashSet<(usize, usize)> = HashSet::new();
+ let mut fill_suppressed_vertical: HashSet<(usize, usize)> = HashSet::new();
+ for placement in &placements {
+ if cell_of(placement).background.is_none() {
+ continue;
+ }
+ for col in placement.first_col..placement.first_col + placement.col_span {
+ fill_suppressed_horizontal.insert((placement.row_index, col));
+ fill_suppressed_horizontal.insert((placement.row_index + placement.row_span, col));
+ }
+ for row in placement.row_index..placement.row_index + placement.row_span {
+ fill_suppressed_vertical.insert((placement.first_col, row));
+ fill_suppressed_vertical.insert((placement.first_col + placement.col_span, row));
+ }
+ }
+ let horizontal_boundary_is_free = |boundary: usize, col: usize| -> bool {
+ !top_sides.contains_key(&(boundary, col))
+ && !bottom_sides.contains_key(&(boundary, col))
+ && !fill_suppressed_horizontal.contains(&(boundary, col))
+ };
+ let vertical_boundary_is_free = |boundary: usize, row: usize| -> bool {
+ !left_sides.contains_key(&(boundary, row))
+ && !right_sides.contains_key(&(boundary, row))
+ && !fill_suppressed_vertical.contains(&(boundary, row))
+ };
+ for placement in &placements {
+ let column_tracks = placement.first_col..placement.first_col + placement.col_span;
+ let row_tracks = placement.row_index..placement.row_index + placement.row_span;
+ let mut seeded: CellBorder = painted[placement.row_index][placement.cell_index]
+ .take()
+ .unwrap_or_default();
+ // Whole-side seeding: a side whose boundary is even partially
+ // declared or fill-suppressed stays unseeded — the merged-cell
+ // partial-overlap simplification of #619, erring toward fewer
+ // rules, which is also GT's direction for fills.
+ if seeded.top.is_none()
+ && column_tracks
+ .clone()
+ .all(|col| horizontal_boundary_is_free(placement.row_index, col))
+ {
+ seeded.top = Some(printed_gridline_side());
+ }
+ if seeded.bottom.is_none()
+ && column_tracks.clone().all(|col| {
+ horizontal_boundary_is_free(placement.row_index + placement.row_span, col)
+ })
+ {
+ seeded.bottom = Some(printed_gridline_side());
+ }
+ if seeded.left.is_none()
+ && row_tracks
+ .clone()
+ .all(|row| vertical_boundary_is_free(placement.first_col, row))
+ {
+ seeded.left = Some(printed_gridline_side());
+ }
+ if seeded.right.is_none()
+ && row_tracks.clone().all(|row| {
+ vertical_boundary_is_free(placement.first_col + placement.col_span, row)
+ })
+ {
+ seeded.right = Some(printed_gridline_side());
+ }
+ if seeded.top.is_some()
+ || seeded.bottom.is_some()
+ || seeded.left.is_some()
+ || seeded.right.is_some()
+ {
+ painted[placement.row_index][placement.cell_index] = Some(seeded);
+ }
+ }
+ }
+
painted
}