Merge pull request #102 from skylartaylor/fix/idml-cell-edge-priority

IDML: honor explicit cell edges at table borders
This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-10-07 22:03:36 -04:00
commit 08b6e335dd
5 files changed
+112 -2

No files matched your search

+53 -2
View File
@@ -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());
}
}
+19
View File
@@ -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)]
+3
View File
@@ -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);
+3
View File
@@ -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;
+34
View File
@@ -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#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
<Story Self="s1"><ParagraphStyleRange><CharacterStyleRange>
<Table HeaderRowCount="0" FooterRowCount="0" TopBorderStrokeWeight="2">
<Row Name="0" MinimumHeight="24"/>
<Column Name="0" SingleColumnWidth="60"/><Column Name="1" SingleColumnWidth="60"/>
<Cell Name="0:0" TopEdgeStrokeColor="Swatch/None" TopEdgeStrokePriority="1"
LeftEdgeStrokeColor="Color/Brand" LeftEdgeStrokeWeight="3" LeftEdgeStrokePriority="2"
BottomEdgeStrokeWeight="0" BottomEdgeStrokePriority="0" RightEdgeStrokeColor="Swatch/None">
<ParagraphStyleRange><CharacterStyleRange><Content>A</Content></CharacterStyleRange></ParagraphStyleRange>
</Cell>
<Cell Name="1:0"><ParagraphStyleRange><CharacterStyleRange><Content>B</Content></CharacterStyleRange></ParagraphStyleRange></Cell>
</Table></CharacterStyleRange></ParagraphStyleRange></Story></idPkg:Story>"#;
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());