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 }