diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index bda00182..78d71736 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -370,10 +370,12 @@ fn emit( graphic: cell.graphic.clone(), }); // Edges: every cell draws its top and left; the fragment's bottom/right cells their bottom/right. - // The table border replaces outer edges. + // The table border replaces outer edges unless the cell explicitly overrides it. let at_top = (y - top).abs() < 1e-6; let at_bottom = (y1 - bottom).abs() < 1e-6; - let edge = |i: usize, outer: bool| if outer { o.border.clone() } else { cell.strokes[i].clone() }; + let edge = |i: usize, outer: bool| { + if outer && !cell.border_overrides[i] { o.border.clone() } else { cell.strokes[i].clone() } + }; let mut push = |a: Point, b: Point, s: CellStroke| { if s.is_visible() { strokes.push(StrokeSeg { a, b, stroke: s }); @@ -469,3 +471,52 @@ pub fn cell_caret(cs: &ComposedStory, table: u64, row: usize, col: usize, pos: u None => Some((fi, c.origin.x, c.origin.y + 10.0, 9.0, 3.0)), } } + +#[cfg(test)] +mod border_tests { + use super::*; + use designcraft_doc::{CellRange, ParaFormat, SpreadRef, build::NewDocument}; + + fn compose_table(table: Table) -> ComposedStory { + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 120.0, 200.0), layer, "", ParaFormat::default()).unwrap(); + doc.story_mut(sid).unwrap().insert_table(0, table); + crate::compose_story(&doc, sid, &ComposeOptions::default()) + } + + #[test] + fn explicit_cell_edges_override_table_border_without_changing_native_defaults() { + let mut table = Table::new(77, 1, 1, 0, 0, 100.0); + table.options.border.weight = 2.0; + table.cell_mut(0, 0).unwrap().strokes = std::array::from_fn(|_| CellStroke::none()); + let native = compose_table(table.clone()); + let strokes = &native.frames[0].tables[0].strokes; + assert_eq!(strokes.len(), 4, "default cells still use all four table borders"); + assert!(strokes.iter().all(|s| s.stroke.weight == 2.0)); + + let cell = table.cell_mut(0, 0).unwrap(); + cell.border_overrides = [true, true, true, false]; + cell.strokes[2] = CellStroke { weight: 3.0, ..Default::default() }; + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2, "top and left None overrides suppress borders"); + let bottom = fragment.strokes.iter().find(|s| s.a.y == fragment.rect.y1 && s.b.y == fragment.rect.y1).unwrap(); + assert_eq!(bottom.stroke.weight, 3.0); + let right = fragment.strokes.iter().find(|s| s.a.x == fragment.rect.x1 && s.b.x == fragment.rect.x1).unwrap(); + assert_eq!(right.stroke.weight, 2.0, "unoverridden right edge keeps the table border"); + } + + #[test] + fn merged_cell_keeps_explicit_border_suppression() { + let mut table = Table::new(77, 2, 2, 0, 0, 100.0); + table.merge(CellRange::new(0, 0, 1, 1)).unwrap(); + let cell = table.cell_mut(0, 0).unwrap(); + cell.border_overrides = [true; 4]; + cell.strokes = std::array::from_fn(|_| CellStroke::none()); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.cells.len(), 1); + assert!(fragment.strokes.is_empty()); + } +} diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 1afa45f7..3b6d3796 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -116,6 +116,10 @@ pub struct Cell { /// Edge strokes: top, left, bottom, right. #[serde(default)] pub strokes: [CellStroke; 4], + /// Explicit cell-edge formatting takes precedence over the table border, in the same + /// top, left, bottom, right order as `strokes`. Older documents keep their table border. + #[serde(default)] + pub border_overrides: [bool; 4], /// Applied cell style ("" = [None]). #[serde(default, skip_serializing_if = "String::is_empty")] pub style: String, @@ -137,6 +141,7 @@ impl Default for Cell { vj: VerticalJustification::Top, rotation: 0.0, strokes: Default::default(), + border_overrides: [false; 4], style: String::new(), graphic: None, } @@ -1102,6 +1107,20 @@ mod tests { let st: Story = serde_json::from_str(old).unwrap(); assert!(st.tables.is_empty()); } + + #[test] + fn cell_border_overrides_default_for_old_documents_and_roundtrip() { + let cell = Cell::default(); + let mut old = serde_json::to_value(&cell).unwrap(); + old.as_object_mut().unwrap().remove("borderOverrides"); + let back: Cell = serde_json::from_value(old).unwrap(); + assert_eq!(back.border_overrides, [false; 4]); + + let explicit = Cell { border_overrides: [true, false, true, false], ..cell }; + let back: Cell = serde_json::from_str(&serde_json::to_string(&explicit).unwrap()).unwrap(); + assert_eq!(back, explicit); + assert_eq!(back.blank_like().border_overrides, explicit.border_overrides); + } } #[cfg(test)] diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 6e999d52..c98bff7d 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -2038,6 +2038,9 @@ impl<'a> Ex<'a> { .attr("RotationAngle", num(cell.rotation)); for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() { self.cell_stroke_attrs(&mut ce, side, &cell.strokes[i]); + if cell.border_overrides[i] { + ce.set(&format!("{side}StrokePriority"), 1); + } } for psr in self.story_paras(&cell.text) { ce.push(psr); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 79800df8..6ac3b592 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1442,6 +1442,9 @@ impl<'r> Importer<'r> { for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() { let base = cell.strokes[i].clone(); cell.strokes[i] = stroke(self, ce, side, &base); + // A positive edge priority records a local cell override, including an + // explicit None stroke. Keep it when this edge meets the table perimeter. + cell.border_overrides[i] = ce.num(&format!("{side}StrokePriority")).is_some_and(|priority| priority > 0.0); } if let Some(slot) = t.cell_mut(r, c) { *slot = cell; diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 01ead1e0..cbd70cae 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -496,6 +496,40 @@ fn round_trips_tables() { assert_eq!(bt.options.alt_rows.as_ref().map(|a| a.first_color.as_str()), Some("Brand")); } +#[test] +fn imports_and_round_trips_explicit_cell_border_overrides() { + // Hand-written IDML: the outer cell edge may explicitly suppress or replace the + // table border. An absent/zero priority keeps the table border's precedence. + let story = r#" + + + + + + A + + B +
"#; + let d = import_idml(&fixture_with_story(story)).unwrap(); + d.check().unwrap(); + let table = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + let cell = table.cell(0, 0).unwrap(); + assert_eq!(cell.border_overrides, [true, true, false, false]); + assert!(!cell.strokes[0].is_visible(), "explicit None is retained"); + assert_eq!(cell.strokes[1].color, "Brand"); + assert_eq!(cell.strokes[1].weight, 3.0); + assert_eq!(table.options.border.weight, 2.0); + assert_eq!(table.cell(0, 1).unwrap().border_overrides, [false; 4]); + + let back = import_idml(&export_idml(&d)).unwrap(); + let roundtrip = back.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert_eq!(roundtrip.cell(0, 0).unwrap().border_overrides, cell.border_overrides); + assert_eq!(roundtrip.cell(0, 0).unwrap().strokes, cell.strokes); + assert_eq!(roundtrip.cell(0, 1).unwrap().border_overrides, [false; 4]); +} + #[test] fn round_trips_footnotes_and_options() { let mut d = Document::new(&NewDocument::default());