diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index f163299..c70f477 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -7,7 +7,7 @@ use std::sync::Arc; -use designcraft_doc::{Align, CellStroke, Document, ItemId, ParaProps, RowHeightMode, Story, Table, TextFrameOptions, VerticalJustification}; +use designcraft_doc::{Align, Cell, CellStroke, Document, ItemId, ParaProps, RowHeightMode, Table, TextFrameOptions, VerticalJustification}; use designcraft_geom::{Point, Rect}; use crate::{ComposeOptions, ComposedStory, Cursor, FrameSpec, Line}; @@ -25,7 +25,8 @@ pub struct PlacedCell { pub text: Arc, /// The cell's story text (exporters map glyphs back to text). pub source: Arc, - /// Content height available in the cell (for clipping). + /// Inset content box. Text baselines fit here; descenders may enter the bottom inset. + /// Graphic content is clipped to this box. pub clip: Rect, pub overset: bool, /// A header/footer row repeated in a continuation fragment. @@ -85,10 +86,25 @@ impl TableFrag { struct CellLayout { text: Arc, content_h: f64, + line_heights: Vec, +} + +/// Cell text fits by its baseline, while its typographic/inline-object bounds must still +/// remain inside the physical cell. The bottom inset can contain ordinary descenders; +/// adding both the inset and the full descent unnecessarily grows every row. +struct CellLineExtent { + baseline: f64, + bottom: f64, +} + +impl CellLineExtent { + fn fit_height(&self, bottom_inset: f64) -> f64 { + self.baseline.max(self.bottom - bottom_inset) + } } /// Compose a cell story at width `w` (content space, top-left at the origin). -fn compose_cell(doc: &Document, story: &Story, w: f64, opts: &ComposeOptions, left_page: bool) -> CellLayout { +fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, left_page: bool) -> CellLayout { let spec = FrameSpec { id: ItemId(0), area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6), @@ -101,9 +117,28 @@ fn compose_cell(doc: &Document, story: &Story, w: f64, opts: &ComposeOptions, le left_page, page_rect: None, }; - let cs = crate::compose(doc, story, std::slice::from_ref(&spec), opts); - let content_h = cs.frames.first().map(|f| if f.lines.is_empty() { 0.0 } else { f.content_height }).unwrap_or(0.0); - CellLayout { text: Arc::new(cs), content_h } + let cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts); + let line_heights: Vec = cs + .frames + .first() + .map(|f| { + let mut extents: Vec<_> = f.lines.iter().map(|l| CellLineExtent { baseline: l.baseline, bottom: l.baseline + l.descent }).collect(); + // Line metrics include baseline shifts and super/subscript. Include the final + // geometry of in-flow objects too, but leave custom anchors out of text flow. + for object in &f.objects { + if cell.text.objects.get(object.index).is_some_and(|o| !matches!(o.position, designcraft_doc::AnchorPosition::Custom { .. })) + && let Some(extent) = extents.get_mut(object.line) + { + extent.bottom = extent.bottom.max(object.origin.y + object.size.1); + } + } + extents.iter().map(|e| e.fit_height(cell.insets[2])).collect() + }) + .unwrap_or_default(); + // Check all lines: a large descender or lowered inline object on an earlier line can + // extend below the final baseline when leading is fixed. + let content_h = line_heights.iter().copied().fold(0.0, f64::max); + CellLayout { text: Arc::new(cs), content_h, line_heights } } /// Lay out `table` (anchored at byte `anchor` of paragraph `pi`) from the cursor. Returns false @@ -158,7 +193,7 @@ pub(crate) fn place_table( let cell = &table.cells[r * nc + c]; let cs = (cell.col_span.max(1) as usize).min(nc - c); let w = colx[c + cs] - colx[c] - cell.insets[1] - cell.insets[3]; - layouts.push(Some(compose_cell(doc, &cell.text, w, opts, left_page))); + layouts.push(Some(compose_cell(doc, cell, w, opts, left_page))); } } // Row heights. @@ -314,7 +349,6 @@ fn emit( let body = table.body_rows(); let nbody = body.len(); let mut cells = Vec::new(); - let mut strokes = Vec::new(); let o = &table.options; let right = x0 + colx[nc]; for &(r, y, repeated) in rows { @@ -352,13 +386,23 @@ fn emit( VerticalJustification::Bottom if space > 0.0 => space, _ => 0.0, }; - let overset = space < -0.01; + let overset = space < -0.01 || lay.text.is_overset(); let mut text = lay.text.clone(); if overset && let Some(f) = text.frames.first() { - // Drop the lines that don't fit the cell. - let keep: Vec = f.lines.iter().filter(|l| l.baseline + l.descent <= clip.height() + 0.01).cloned().collect(); + // Use the same baseline/bounds test as auto-growth, and retain a prefix: + // overset text cannot reappear after a line that did not fit. + let keep = lay.line_heights.iter().take_while(|h| **h <= clip.height() + 0.01).count(); + let overset_at = f.lines.get(keep).map(|l| l.range.start); let mut t = (*text).clone(); - t.frames[0].lines = keep; + if let Some(frame) = t.frames.first_mut() { + frame.lines.truncate(keep); + frame.objects.retain(|o| o.line < keep); + frame.tables.retain(|table| table.line < keep); + frame.range.end = frame.lines.last().map_or(frame.range.start, |l| l.range.end); + } + if let Some(at) = overset_at { + t.overset_at = Some(at); + } text = Arc::new(t); } cells.push(PlacedCell { @@ -374,28 +418,9 @@ fn emit( repeated, 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 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 && !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 }); - } - }; - push(Point::new(rect.x0, rect.y0), Point::new(rect.x1, rect.y0), edge(0, at_top)); - push(Point::new(rect.x0, rect.y0), Point::new(rect.x0, rect.y1), edge(1, (rect.x0 - x0).abs() < 1e-6)); - if at_bottom { - push(Point::new(rect.x0, rect.y1), Point::new(rect.x1, rect.y1), edge(2, true)); - } - if (rect.x1 - right).abs() < 1e-6 { - push(Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), edge(3, true)); - } } } + let strokes = fragment_strokes(table, rows, heights, colx, x0, bottom, owners); let ft = &mut out.frames[cur.fi]; let line = ft.lines.len(); let h = bottom - top; @@ -431,6 +456,148 @@ fn emit( }); } +#[derive(Clone, Copy, PartialEq, Eq)] +enum EdgeSource { + Cell((usize, usize), usize), + TablePattern, +} + +/// Resolve a shared edge before dropping invisible strokes: a higher-priority None is +/// meaningful and must be allowed to suppress the other cell's visible edge. +fn boundary_stroke<'a>( + table: &'a Table, + before: Option<((usize, usize), &'a Cell)>, + after: Option<((usize, usize), &'a Cell)>, + before_side: usize, + after_side: usize, + pattern: Option<&'a CellStroke>, +) -> Option<(&'a CellStroke, i32, EdgeSource)> { + let supplied = |cell: &Cell, side: usize| cell.stroke_defined[side] || cell.border_overrides[side] || cell.stroke_priorities[side] != 0; + let rank = + |cell: &Cell, side: usize| (cell.stroke_priorities[side], supplied(cell, side), supplied(cell, side) && cell.strokes[side].is_visible()); + let (owner, cell, side, outer) = match (before, after) { + (Some((ao, a)), Some((bo, b))) => { + // Table patterns supply missing interior edges; cell styles and local edits + // retain precedence, including explicit zero/None. Perimeter rules never enter + // this branch. + if !supplied(a, before_side) + && !supplied(b, after_side) + && let Some(stroke) = pattern + { + return Some((stroke, 0, EdgeSource::TablePattern)); + } + // Adobe IDML specification, Cell attributes (pp. 237-240): higher numeric + // edge priority means higher drawing priority. Equal-priority conflicts are + // unspecified: prefer supplied formatting, then a visible supplied rule. + // Metadata-free native / legacy cells retain lower/right top/left ownership, + // including explicit None already stored there by older native editing code. + if rank(a, before_side) > rank(b, after_side) { (ao, a, before_side, false) } else { (bo, b, after_side, false) } + } + (Some((owner, a)), None) => (owner, a, before_side, true), + (None, Some((owner, b))) => (owner, b, after_side, true), + (None, None) => return None, + }; + if outer && !cell.border_overrides[side] { + Some((table.options.border_for(side), 0, EdgeSource::Cell(owner, side))) + } else { + Some((&cell.strokes[side], cell.stroke_priorities[side], EdgeSource::Cell(owner, side))) + } +} + +/// Keep an unchanged winning cell edge continuous across different losing neighbors. In +/// particular, splitting a merged dashed edge would restart its dash phase at every cell. +fn push_boundary_stroke( + strokes: &mut Vec<(StrokeSeg, i32)>, + previous: &mut Option, + a: Point, + b: Point, + edge: Option<(&CellStroke, i32, EdgeSource)>, +) { + let Some((stroke, priority, source)) = edge.filter(|(s, _, _)| s.is_visible()) else { + *previous = None; + return; + }; + if *previous == Some(source) + && let Some((last, last_priority)) = strokes.last_mut() + && last.b == a + && last.stroke == *stroke + && *last_priority == priority + { + last.b = b; + } else { + strokes.push((StrokeSeg { a, b, stroke: stroke.clone() }, priority)); + } + *previous = Some(source); +} + +/// Visit each displayed grid boundary once, including boundaries between repeated headers / +/// footers and nonadjacent source body rows. Interior lines within a merged owner are omitted. +fn fragment_strokes( + table: &Table, + rows: &[(usize, f64, bool)], + heights: &[f64], + colx: &[f64], + x0: f64, + bottom: f64, + owners: &[(usize, usize)], +) -> Vec { + let nc = table.ncols(); + let right = x0 + colx.last().copied().unwrap_or(0.0); + let rtl = table.options.direction == designcraft_doc::TextDirection::RightToLeft; + let owner = |r: usize, c: usize| { + let &(r, c) = owners.get(r.checked_mul(nc)?.checked_add(c)?)?; + Some(((r, c), table.cell(r, c)?)) + }; + let x = |c: usize| colx.get(c).map(|&offset| if rtl { right - offset } else { x0 + offset }); + // Uniform active groups do not depend on which shared edge starts the pattern. Keep + // nonuniform and skipped patterns intact in the model until their phase is verified. + let row_stroke = table.options.row_strokes.as_ref().filter(|p| p.skip_first == 0 && p.skip_last == 0).and_then(|p| p.uniform_stroke()); + let column_stroke = table.options.column_strokes.as_ref().filter(|p| p.skip_first == 0 && p.skip_last == 0).and_then(|p| p.uniform_stroke()); + let body = table.body_rows(); + let mut strokes = Vec::new(); + for boundary in 0..=rows.len() { + let before = boundary.checked_sub(1).and_then(|r| rows.get(r)); + let after = rows.get(boundary); + let y = after.map_or(bottom, |row| row.1); + let pattern = + if before.is_some_and(|row| body.contains(&row.0)) && after.is_some_and(|row| body.contains(&row.0)) { row_stroke } else { None }; + let mut previous = None; + for physical_col in 0..nc { + let c = if rtl { nc - physical_col - 1 } else { physical_col }; + let above = before.and_then(|row| owner(row.0, c)); + let below = after.and_then(|row| owner(row.0, c)); + let pair = (above.map(|v| v.0), below.map(|v| v.0)); + if pair.0 == pair.1 { + previous = None; + continue; + } + let (Some(a), Some(b)) = (x(c), x(c + 1)) else { continue }; + let edge = boundary_stroke(table, above, below, 2, 0, pattern); + push_boundary_stroke(&mut strokes, &mut previous, Point::new(a.min(b), y), Point::new(a.max(b), y), edge); + } + } + for boundary in 0..=nc { + let Some(x) = x(boundary) else { continue }; + let mut previous = None; + for &(r, y, _) in rows { + let before = boundary.checked_sub(1).and_then(|c| owner(r, c)); + let after = (boundary < nc).then(|| owner(r, boundary)).flatten(); + let (left, right) = if rtl { (after, before) } else { (before, after) }; + let pair = (left.map(|v| v.0), right.map(|v| v.0)); + if pair.0 == pair.1 { + previous = None; + continue; + } + let Some(&height) = heights.get(r) else { continue }; + let edge = boundary_stroke(table, left, right, 3, 1, if body.contains(&r) { column_stroke } else { None }); + push_boundary_stroke(&mut strokes, &mut previous, Point::new(x, y), Point::new(x, y + height), edge); + } + } + // Shared edges have a single winner; retain the documented priority at crossings too. + strokes.sort_by_key(|(_, priority)| *priority); + strokes.into_iter().map(|(stroke, _)| stroke).collect() +} + // ---------- hit testing and carets in cells ---------- /// The cell under `p` (frame inner space) in frame `fi`: (table, row, col, story byte in the cell). @@ -477,6 +644,283 @@ pub fn cell_caret(cs: &ComposedStory, table: u64, row: usize, col: usize, pos: u } } +#[cfg(test)] +mod height_tests { + use super::*; + use designcraft_doc::{AnchorPosition, AnchoredObject, CellRange, LayerId, ParaFormat, Position, SpreadRef, build::NewDocument}; + + fn text_cell(text: &str, bottom: f64) -> Cell { + let mut cell = Cell::with_text(text, ParaFormat::default()); + cell.insets = [8.5, 4.0, bottom, 4.0]; + cell.text.format_chars(0..cell.text.len(), |f| { + f.over.font_family = Some("Source Sans 3".into()); + f.over.font_style = Some("Regular".into()); + f.over.size = Some(11.0); + }); + cell + } + + fn measure(cell: &Cell, width: f64) -> CellLayout { + compose_cell(&Document::new(&NewDocument::default()), cell, width, &ComposeOptions::default(), false) + } + + fn compose_table(table: Table, height: f64) -> 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, 200.0, height), layer, "", ParaFormat::default()).unwrap(); + doc.story_mut(sid).unwrap().insert_table(0, table); + crate::compose_story(&doc, sid, &ComposeOptions::default()) + } + + fn single_table(cell: Cell) -> Table { + let mut table = Table::new(77, 1, 1, 0, 0, 100.0); + table.cells[0] = cell; + table + } + + fn object(position: AnchorPosition) -> AnchoredObject { + let path = designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 12.0, 30.0)); + AnchoredObject::new(designcraft_doc::Item::new(ItemId(0), LayerId(0), designcraft_doc::Shape::Rectangle, path), position) + } + + #[test] + fn cell_bottom_inset_contains_descenders_without_growing_every_row() { + let cell = text_cell("gyp", 8.5); + let layout = measure(&cell, 92.0); + let line = &layout.text.frames[0].lines[0]; + assert!(line.descent > 0.0 && line.descent < cell.insets[2]); + let expected = cell.insets[0] + line.baseline + cell.insets[2]; + let composed = compose_table(single_table(cell), 200.0); + let placed = composed.frames[0].tables[0].cell(0, 0).unwrap(); + assert!((placed.rect.height() - expected).abs() < 1e-6); + assert!(!placed.overset); + assert_eq!(placed.text.frames[0].lines.len(), 1); + assert!(placed.origin.y + line.baseline + line.descent <= placed.rect.y1); + // The shared frame composer keeps its full typographic height for non-cell users. + assert!((layout.text.frames[0].content_height - line.baseline - line.descent).abs() < 1e-6); + } + + #[test] + fn baseline_sized_rows_fit_the_frame_without_losing_the_final_rows() { + let cell = text_cell("gyp", 8.5); + let layout = measure(&cell, 92.0); + let row_height = 17.0 + layout.text.frames[0].lines[0].baseline; + let mut table = Table::new(77, 16, 1, 1, 0, 100.0); + table.cells.fill(cell); + let composed = compose_table(table, 17.0 * row_height + 25.0); + assert!(!composed.is_overset()); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.cells.len(), 17); + assert!(fragment.cell(16, 0).is_some()); + assert!((fragment.rect.height() - 17.0 * row_height).abs() < 1e-6); + } + + #[test] + fn multiline_cells_keep_leading_and_paragraph_spacing() { + let mut cell = text_cell("gyp\njqp", 8.5); + cell.text.format_paras(0..3, |p| p.para.space_after = Some(6.0)); + let layout = measure(&cell, 92.0); + let lines = &layout.text.frames[0].lines; + assert_eq!(lines.len(), 2); + assert!((lines[1].baseline - lines[0].baseline - lines[1].leading - 6.0).abs() < 1e-6); + let expected = 17.0 + lines[1].baseline; + let composed = compose_table(single_table(cell), 200.0); + let placed = composed.frames[0].tables[0].cell(0, 0).unwrap(); + assert_eq!(placed.text.frames[0].lines.len(), 2); + assert!((placed.rect.height() - expected).abs() < 1e-6); + let wrapped = measure(&text_cell("gyp gyp gyp gyp gyp gyp gyp gyp", 8.5), 30.0); + assert!(wrapped.text.frames[0].lines.len() > 2); + assert!((wrapped.content_h - wrapped.text.frames[0].lines.last().unwrap().baseline).abs() < 1e-6); + } + + #[test] + fn small_insets_still_contain_descenders_and_shifted_text() { + for bottom in [0.0, 1.0] { + for (shift, position) in [(0.0, Position::Normal), (-15.0, Position::Normal), (0.0, Position::Superscript), (0.0, Position::Subscript)] { + let mut cell = text_cell("gyp", bottom); + cell.text.format_chars(0..3, |f| { + f.over.baseline_shift = Some(shift); + f.over.position = Some(position); + }); + let layout = measure(&cell, 92.0); + let line = &layout.text.frames[0].lines[0]; + let expected = cell.insets[0] + line.baseline + bottom.max(line.descent); + let composed = compose_table(single_table(cell), 200.0); + let placed = composed.frames[0].tables[0].cell(0, 0).unwrap(); + assert!((placed.rect.height() - expected).abs() < 1e-6); + assert!(placed.origin.y + line.baseline + line.descent <= placed.rect.y1 + 1e-6); + assert!(!placed.overset); + } + } + } + + #[test] + fn exact_height_uses_the_same_fit_rule_and_keeps_only_a_prefix() { + let cell = text_cell("gyp\njqp", 8.5); + let layout = measure(&cell, 92.0); + let first_height = 17.0 + layout.text.frames[0].lines[0].baseline; + let full_height = 17.0 + layout.text.frames[0].lines[1].baseline; + for (height, count, overset) in [(full_height, 2, false), (first_height, 1, true), (first_height - 1.0, 0, true)] { + let mut table = single_table(cell.clone()); + table.rows[0].mode = RowHeightMode::Exactly; + table.rows[0].height = height; + let composed = compose_table(table, 200.0); + let placed = composed.frames[0].tables[0].cell(0, 0).unwrap(); + assert!((placed.rect.height() - height).abs() < 1e-6); + assert_eq!(placed.overset, overset); + assert_eq!(placed.text.frames[0].lines.len(), count); + if overset { + assert_eq!(placed.text.overset_at, Some(layout.text.frames[0].lines[count].range.start)); + } + } + } + + #[test] + fn exact_height_cannot_fit_a_descender_outside_the_physical_cell() { + let cell = text_cell("gyp", 0.0); + let layout = measure(&cell, 92.0); + let line = &layout.text.frames[0].lines[0]; + let mut table = single_table(cell); + table.rows[0].mode = RowHeightMode::Exactly; + table.rows[0].height = 8.5 + line.baseline + line.descent / 2.0; + let composed = compose_table(table, 200.0); + let placed = composed.frames[0].tables[0].cell(0, 0).unwrap(); + assert!(placed.overset); + assert!(placed.text.frames[0].lines.is_empty()); + } + + #[test] + fn earlier_lowered_line_controls_height_and_cannot_be_skipped_on_overset() { + let mut cell = text_cell("gyp\njqp", 8.5); + cell.text.format_chars(0..cell.text.len(), |f| f.over.leading = Some(designcraft_doc::Leading::Points(6.0))); + cell.text.format_chars(0..3, |f| f.over.baseline_shift = Some(-30.0)); + let layout = measure(&cell, 92.0); + let lines = &layout.text.frames[0].lines; + assert_eq!(lines.len(), 2); + let deep_bottom = lines[0].baseline + lines[0].descent; + assert!(deep_bottom > lines[1].baseline + lines[1].descent + 8.5); + let composed = compose_table(single_table(cell.clone()), 200.0); + let placed = composed.frames[0].tables[0].cell(0, 0).unwrap(); + assert!((placed.rect.height() - 8.5 - deep_bottom).abs() < 1e-6); + assert!(!placed.overset); + + let mut table = single_table(cell); + table.rows[0].mode = RowHeightMode::Exactly; + table.rows[0].height = 17.0 + lines[1].baseline; + let composed = compose_table(table, 200.0); + let placed = composed.frames[0].tables[0].cell(0, 0).unwrap(); + assert!(placed.overset); + assert!(placed.text.frames[0].lines.is_empty(), "a later fitting line cannot reappear after the first line oversets"); + } + + #[test] + fn empty_and_trailing_empty_paragraphs_keep_their_baseline_height() { + for text in ["", "gyp\n", "gyp\n\n"] { + let cell = text_cell(text, 8.5); + let layout = measure(&cell, 92.0); + let lines = &layout.text.frames[0].lines; + assert_eq!(lines.len(), text.matches('\n').count() + 1); + assert!(lines.last().unwrap().glyphs.is_empty()); + let expected = 17.0 + lines.last().unwrap().baseline; + let composed = compose_table(single_table(cell), 200.0); + let placed = composed.frames[0].tables[0].cell(0, 0).unwrap(); + assert!((placed.rect.height() - expected).abs() < 1e-6); + assert_eq!(placed.text.frames[0].lines.len(), lines.len()); + assert!(!placed.overset); + } + } + + #[test] + fn vertical_alignment_uses_baseline_fit_without_losing_descenders() { + for bottom in [0.0, 8.5] { + for (vj, expected_shift) in + [(VerticalJustification::Top, 0.0), (VerticalJustification::Center, 10.0), (VerticalJustification::Bottom, 20.0)] + { + let mut cell = text_cell("gyp", bottom); + cell.vj = vj; + let layout = measure(&cell, 92.0); + let required = cell.insets[0] + layout.content_h + bottom; + let mut table = single_table(cell); + table.rows[0].mode = RowHeightMode::Exactly; + table.rows[0].height = required + 20.0; + let composed = compose_table(table, 200.0); + let placed = composed.frames[0].tables[0].cell(0, 0).unwrap(); + let line = &placed.text.frames[0].lines[0]; + assert!((placed.origin.y - placed.clip.y0 - expected_shift).abs() < 1e-6); + assert!(placed.origin.y + line.baseline + line.descent <= placed.rect.y1 + 1e-6); + assert!(!placed.overset); + } + } + } + + #[test] + fn row_spans_grow_to_the_same_baseline_and_physical_bounds() { + let cell = text_cell("gyp\njqp\ngyp", 8.5); + let layout = measure(&cell, 92.0); + let expected = 17.0 + layout.text.frames[0].lines.last().unwrap().baseline; + let mut table = Table::new(77, 2, 1, 0, 0, 100.0); + table.merge(CellRange::new(0, 0, 1, 0)).unwrap(); + table.rows[0].mode = RowHeightMode::Exactly; + table.rows[0].height = 3.0; + table.cells[0] = Cell { row_span: 2, ..cell }; + let composed = compose_table(table, 200.0); + let placed = composed.frames[0].tables[0].cell(0, 0).unwrap(); + assert!((placed.rect.height() - expected).abs() < 1e-6); + assert_eq!(placed.text.frames[0].lines.len(), 3); + assert!(!placed.overset); + } + + #[test] + fn lowered_inline_objects_are_contained_but_custom_anchors_stay_out_of_flow() { + let mut cell = text_cell("gyp", 8.5); + cell.text.insert_object(0, object(AnchorPosition::Inline { y_offset: -40.0 })); + let composed = compose_table(single_table(cell), 200.0); + let placed = composed.frames[0].tables[0].cell(0, 0).unwrap(); + let object = &placed.text.frames[0].objects[0]; + assert!((placed.origin.y + object.origin.y + object.size.1 - placed.rect.y1).abs() < 1e-6); + assert!(!placed.overset); + + let mut cell = text_cell("gyp", 8.5); + let original = measure(&cell, 92.0).content_h; + cell.text.insert_object( + 0, + self::object(AnchorPosition::Custom { + x_relative: Default::default(), + y_relative: Default::default(), + x_offset: 0.0, + y_offset: 100.0, + object_point: 0, + ref_point: 0, + keep_within_column: false, + }), + ); + assert!((measure(&cell, 92.0).content_h - original).abs() < 1e-6); + } + + #[test] + fn overset_lines_cannot_leave_visible_objects_or_nested_tables_behind() { + let plain = text_cell("gyp", 8.5); + let first_height = 17.0 + measure(&plain, 92.0).text.frames[0].lines[0].baseline; + for nested in [false, true] { + let mut cell = text_cell("gyp\nHidden", 8.5); + if nested { + cell.text.insert_table(4, single_table(text_cell("nested", 8.5))); + } else { + cell.text.insert_object(4, object(AnchorPosition::Inline { y_offset: 0.0 })); + } + let mut table = single_table(cell); + table.rows[0].mode = RowHeightMode::Exactly; + table.rows[0].height = first_height; + let composed = compose_table(table, 200.0); + let placed = composed.frames[0].tables[0].cell(0, 0).unwrap(); + assert!(placed.overset); + assert_eq!(placed.text.frames[0].lines.len(), 1); + assert!(placed.text.frames[0].objects.is_empty()); + assert!(placed.text.frames[0].tables.is_empty()); + } + } +} + #[cfg(test)] mod border_tests { use super::*; @@ -524,4 +968,437 @@ mod border_tests { assert_eq!(fragment.cells.len(), 1); assert!(fragment.strokes.is_empty()); } + + fn rule_table(body: usize, cols: usize, header: usize, footer: usize) -> Table { + let mut table = Table::new(77, body, cols, header, footer, 100.0); + table.options.border = CellStroke::none(); + for row in &mut table.rows { + row.height = 20.0; + row.mode = RowHeightMode::Exactly; + } + for cell in &mut table.cells { + cell.insets = [0.0; 4]; + cell.strokes = std::array::from_fn(|_| CellStroke::none()); + } + table + } + + fn set_rule(table: &mut Table, row: usize, col: usize, side: usize, weight: f64, priority: i32) { + let cell = table.cell_mut(row, col).unwrap(); + cell.strokes[side] = CellStroke { weight, ..Default::default() }; + cell.stroke_defined[side] = true; + cell.stroke_priorities[side] = priority; + } + + fn assert_no_overlapping_rules(fragment: &TableFrag) { + for (i, a) in fragment.strokes.iter().enumerate() { + for b in &fragment.strokes[i + 1..] { + let horizontal_overlap = a.a.y == a.b.y && a.a.y == b.a.y && b.a.y == b.b.y && a.a.x.max(b.a.x) < a.b.x.min(b.b.x); + let vertical_overlap = a.a.x == a.b.x && a.a.x == b.a.x && b.a.x == b.b.x && a.a.y.max(b.a.y) < a.b.y.min(b.b.y); + assert!(!horizontal_overlap && !vertical_overlap, "duplicate shared edge: {a:?}, {b:?}"); + } + } + } + + #[test] + fn header_bottom_only_style_survives_zero_body_edges() { + // Synthetic equivalent of a header with a one-point bottom rule and zero on all + // remaining header/body edges. Both sides supply style formatting at priority zero. + let mut table = rule_table(2, 3, 1, 0); + for cell in &mut table.cells { + cell.stroke_defined = [true; 4]; + cell.border_overrides = [true; 4]; + } + for col in 0..3 { + set_rule(&mut table, 0, col, 2, 1.0, 0); + } + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + let y = fragment.cell(0, 0).unwrap().rect.y1; + assert_eq!(fragment.strokes.len(), 3); + assert!(fragment.strokes.iter().all(|s| s.a.y == y && s.b.y == y && s.stroke.weight == 1.0)); + assert_eq!(fragment.strokes.iter().map(|s| s.b.x - s.a.x).sum::(), 100.0); + assert_no_overlapping_rules(fragment); + } + + #[test] + fn shared_rules_choose_numeric_priority_once_and_preserve_drawing_order() { + let mut table = rule_table(2, 2, 0, 0); + set_rule(&mut table, 0, 0, 2, 3.0, 7); + set_rule(&mut table, 1, 0, 0, 1.0, 4); + set_rule(&mut table, 0, 0, 3, 4.0, 3); + set_rule(&mut table, 0, 1, 1, 2.0, 9); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2); + let horizontal = &fragment.strokes[0]; + assert_eq!(horizontal.a.y, horizontal.b.y); + assert_eq!(horizontal.stroke.weight, 3.0); + let vertical = &fragment.strokes[1]; + assert_eq!(vertical.a.x, vertical.b.x); + assert_eq!(vertical.stroke.weight, 2.0, "higher priority draws last at a crossing"); + assert_no_overlapping_rules(fragment); + } + + #[test] + fn signed_negative_priorities_resolve_and_draw_in_numeric_order() { + let mut table = rule_table(2, 2, 0, 0); + set_rule(&mut table, 0, 0, 2, 1.0, -2); + set_rule(&mut table, 1, 0, 0, 3.0, -1); + set_rule(&mut table, 0, 0, 3, 2.0, -3); + set_rule(&mut table, 0, 1, 1, 4.0, -2); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2); + assert_eq!(fragment.strokes[0].stroke.weight, 4.0, "priority -2 draws before -1"); + assert_eq!(fragment.strokes[0].a.x, fragment.strokes[0].b.x); + assert_eq!(fragment.strokes[1].stroke.weight, 3.0, "priority -1 wins over -2"); + assert_eq!(fragment.strokes[1].a.y, fragment.strokes[1].b.y); + assert_no_overlapping_rules(fragment); + } + + #[test] + fn default_zero_priority_beats_a_negative_priority_rule() { + let mut table = rule_table(2, 1, 0, 0); + set_rule(&mut table, 0, 0, 2, 3.0, -1); + assert_eq!(table.cell(1, 0).unwrap().stroke_priorities, [0; 4]); + let composed = compose_table(table.clone()); + assert!(composed.frames[0].tables[0].strokes.is_empty(), "priority zero None suppresses priority -1"); + table.cell_mut(1, 0).unwrap().strokes[0] = CellStroke::default(); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 1); + assert_eq!(fragment.strokes[0].stroke.weight, 1.0, "the priority-zero native rule wins over priority -1"); + } + + #[test] + fn higher_priority_zero_or_none_suppresses_a_visible_shared_rule() { + for invisible in [CellStroke::none(), CellStroke { color: designcraft_color::swatch::NONE.into(), ..Default::default() }] { + let mut table = rule_table(2, 2, 0, 0); + set_rule(&mut table, 0, 0, 2, 3.0, 7); + set_rule(&mut table, 1, 0, 0, 0.0, 8); + table.cell_mut(1, 0).unwrap().strokes[0] = invisible.clone(); + set_rule(&mut table, 0, 0, 3, 3.0, 7); + set_rule(&mut table, 0, 1, 1, 0.0, 8); + table.cell_mut(0, 1).unwrap().strokes[1] = invisible; + let composed = compose_table(table); + assert!(composed.frames[0].tables[0].strokes.is_empty()); + } + } + + #[test] + fn supplied_zero_shared_edge_beats_missing_native_default() { + let mut table = Table::new(77, 2, 1, 0, 0, 100.0); + table.options.border = CellStroke::none(); + let upper = table.cell_mut(0, 0).unwrap(); + upper.strokes[2] = CellStroke::none(); + upper.stroke_defined[2] = true; + let composed = compose_table(table.clone()); + assert!(composed.frames[0].tables[0].strokes.is_empty(), "explicit zero beats a missing edge's one-point default"); + table.cell_mut(0, 0).unwrap().stroke_defined[2] = false; + let composed = compose_table(table); + assert_eq!(composed.frames[0].tables[0].strokes.len(), 1, "a missing zero cannot suppress the opposite visible default"); + } + + #[test] + fn specified_zero_priority_outer_edges_keep_per_side_table_borders() { + for direction in [designcraft_doc::TextDirection::LeftToRight, designcraft_doc::TextDirection::RightToLeft] { + let mut table = rule_table(1, 1, 0, 0); + table.options.direction = direction; + table.options.borders = std::array::from_fn(|i| Some(CellStroke { weight: (i + 1) as f64, ..Default::default() })); + table.cell_mut(0, 0).unwrap().stroke_defined = [true; 4]; + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 4); + for rule in &fragment.strokes { + let expected = if rule.a.y == rule.b.y { + if rule.a.y == fragment.rect.y0 { 1.0 } else { 3.0 } + } else if rule.a.x == fragment.rect.x0 { + 2.0 + } else { + 4.0 + }; + assert_eq!(rule.stroke.weight, expected, "priority-zero cell values do not override perimeter formatting"); + } + assert_no_overlapping_rules(fragment); + } + } + + fn uniform_pattern(weight: f64) -> designcraft_doc::AltStrokes { + let stroke = CellStroke { weight, ..Default::default() }; + designcraft_doc::AltStrokes { first: 1, next: 1, first_stroke: stroke.clone(), next_stroke: stroke, skip_first: 0, skip_last: 0 } + } + + #[test] + fn uniform_zero_table_patterns_suppress_missing_grid_edges_but_not_perimeter() { + let mut table = Table::new(77, 2, 2, 0, 0, 100.0); + table.options.row_strokes = Some(uniform_pattern(0.0)); + table.options.column_strokes = Some(uniform_pattern(0.0)); + table.options.border.weight = 2.0; + let composed = compose_table(table.clone()); + let fragment = &composed.frames[0].tables[0]; + assert!(!fragment.strokes.is_empty(), "table perimeter remains visible"); + assert!(fragment.strokes.iter().all(|s| { + s.stroke.weight == 2.0 + && ((s.a.x == s.b.x && (s.a.x == fragment.rect.x0 || s.a.x == fragment.rect.x1)) + || (s.a.y == s.b.y && (s.a.y == fragment.rect.y0 || s.a.y == fragment.rect.y1))) + })); + assert_no_overlapping_rules(fragment); + table.options.border = CellStroke::none(); + set_rule(&mut table, 0, 0, 2, 3.0, 0); + set_rule(&mut table, 0, 1, 1, 4.0, 0); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2, "supplied cell strokes override the table patterns"); + assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 3.0)); + assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 4.0)); + assert_no_overlapping_rules(fragment); + } + + #[test] + fn uniform_visible_table_patterns_supply_continuous_rules_below_explicit_none() { + let mut table = Table::new(77, 2, 2, 0, 0, 100.0); + table.options.border = CellStroke::none(); + table.options.row_strokes = Some(uniform_pattern(2.0)); + table.options.column_strokes = Some(uniform_pattern(3.0)); + let composed = compose_table(table.clone()); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2, "a table pattern stays continuous across cell boundaries"); + assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 2.0 && s.b.x - s.a.x == 100.0)); + assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 3.0 && s.b.y - s.a.y == fragment.rect.height())); + for cell in &mut table.cells { + cell.strokes = std::array::from_fn(|_| CellStroke::none()); + cell.stroke_defined = [true; 4]; + } + let composed = compose_table(table); + assert!(composed.frames[0].tables[0].strokes.is_empty(), "explicit cell None is stronger than table patterns"); + } + + #[test] + fn uniform_table_patterns_leave_header_and_footer_edges_unchanged() { + let mut table = Table::new(77, 2, 2, 1, 1, 100.0); + table.options.border = CellStroke::none(); + table.options.row_strokes = Some(uniform_pattern(0.0)); + table.options.column_strokes = Some(uniform_pattern(0.0)); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + let header = fragment.cell(0, 0).unwrap().rect; + let body = fragment.cell(1, 0).unwrap().rect; + let footer = fragment.cell(3, 0).unwrap().rect; + assert!(fragment.strokes.iter().any(|s| s.a == Point::new(header.x1, header.y0) && s.b == Point::new(header.x1, header.y1))); + assert!(fragment.strokes.iter().any(|s| s.a == Point::new(footer.x1, footer.y0) && s.b == Point::new(footer.x1, footer.y1))); + assert!(!fragment.strokes.iter().any(|s| s.a.y == body.y1 && s.b.y == body.y1)); + assert!(!fragment.strokes.iter().any(|s| s.a == Point::new(body.x1, body.y0) && s.b == Point::new(body.x1, body.y1))); + assert_no_overlapping_rules(fragment); + } + + #[test] + fn unverified_nonuniform_or_skipped_pattern_phase_keeps_native_edges() { + for skipped in [false, true] { + let mut table = Table::new(77, 2, 2, 0, 0, 100.0); + table.options.border = CellStroke::none(); + let mut pattern = uniform_pattern(2.0); + if skipped { + pattern.skip_first = 1; + } else { + pattern.next_stroke.weight = 3.0; + } + table.options.row_strokes = Some(pattern.clone()); + table.options.column_strokes = Some(pattern); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 4); + assert!(fragment.strokes.iter().all(|s| s.stroke.weight == 1.0)); + } + } + + #[test] + fn metadata_free_native_and_legacy_edges_keep_top_and_left_suppression() { + let mut table = Table::new(77, 2, 2, 0, 0, 100.0); + table.options.border = CellStroke::none(); + let composed = compose_table(table.clone()); + assert_eq!(composed.frames[0].tables[0].strokes.len(), 4, "unmodified native grid control"); + // The metadata arrays also default to zero/false when reading legacy JSON. Older + // native table.setCell code changed these strokes without setting any metadata. + table.cell_mut(1, 0).unwrap().strokes[0] = CellStroke::none(); + table.cell_mut(0, 1).unwrap().strokes[1].color = designcraft_color::swatch::NONE.into(); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2); + let hidden_top = fragment.cell(1, 0).unwrap().rect; + let hidden_left = fragment.cell(0, 1).unwrap().rect; + assert!(!fragment.strokes.iter().any(|s| s.a == Point::new(hidden_top.x0, hidden_top.y0) && s.b == Point::new(hidden_top.x1, hidden_top.y0))); + assert!( + !fragment.strokes.iter().any(|s| s.a == Point::new(hidden_left.x0, hidden_left.y0) && s.b == Point::new(hidden_left.x0, hidden_left.y1)) + ); + assert_no_overlapping_rules(fragment); + } + + #[test] + fn equal_priority_visible_rules_keep_top_and_left_ownership() { + let mut table = rule_table(2, 2, 0, 0); + set_rule(&mut table, 0, 0, 2, 5.0, 0); + set_rule(&mut table, 1, 0, 0, 2.0, 0); + set_rule(&mut table, 0, 0, 3, 6.0, 0); + set_rule(&mut table, 0, 1, 1, 3.0, 0); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2); + assert_eq!(fragment.strokes[0].stroke.weight, 2.0); + assert_eq!(fragment.strokes[1].stroke.weight, 3.0); + assert_no_overlapping_rules(fragment); + } + + #[test] + fn row_spanning_edge_resolves_each_neighbor_without_internal_rules() { + let mut table = rule_table(2, 2, 0, 0); + table.merge(CellRange::new(0, 0, 1, 0)).unwrap(); + set_rule(&mut table, 0, 0, 3, 1.0, 5); + set_rule(&mut table, 0, 1, 1, 2.0, 6); + set_rule(&mut table, 1, 1, 1, 3.0, 4); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2); + let upper = fragment.strokes.iter().find(|s| s.stroke.weight == 2.0).unwrap(); + let lower = fragment.strokes.iter().find(|s| s.stroke.weight == 1.0).unwrap(); + assert_eq!(upper.a, Point::new(50.0, fragment.rect.y0)); + assert_eq!(upper.b, lower.a); + assert_eq!(lower.b, Point::new(50.0, fragment.rect.y1)); + assert_no_overlapping_rules(fragment); + } + + #[test] + fn column_spanning_edge_resolves_each_neighbor_without_internal_rules() { + let mut table = rule_table(2, 2, 0, 0); + table.merge(CellRange::new(0, 0, 0, 1)).unwrap(); + set_rule(&mut table, 0, 0, 2, 1.0, 5); + set_rule(&mut table, 1, 0, 0, 2.0, 6); + set_rule(&mut table, 1, 1, 0, 3.0, 4); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2); + let left = fragment.strokes.iter().find(|s| s.stroke.weight == 2.0).unwrap(); + let right = fragment.strokes.iter().find(|s| s.stroke.weight == 1.0).unwrap(); + let y = fragment.cell(0, 0).unwrap().rect.y1; + assert_eq!(left.a, Point::new(fragment.rect.x0, y)); + assert_eq!(left.b, right.a); + assert_eq!(right.b, Point::new(fragment.rect.x1, y)); + assert_no_overlapping_rules(fragment); + } + + #[test] + fn merged_winning_dashed_edge_stays_continuous_across_losing_neighbors() { + let mut table = rule_table(2, 2, 0, 0); + table.columns.iter_mut().for_each(|column| column.width = 45.0); + table.merge(CellRange::new(1, 0, 1, 1)).unwrap(); + set_rule(&mut table, 1, 0, 0, 1.0, 1); + table.cell_mut(1, 0).unwrap().strokes[0].kind = designcraft_doc::StrokeType::Dashed { pattern: vec![6.0, 4.0] }; + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 1, "one winning source edge must retain one dash phase"); + let rule = &fragment.strokes[0]; + let merged = fragment.cell(1, 0).unwrap().rect; + assert_eq!(rule.a, Point::new(merged.x0, merged.y0)); + assert_eq!(rule.b, Point::new(merged.x1, merged.y0)); + assert_eq!(rule.b.x - rule.a.x, 90.0); + assert_no_overlapping_rules(fragment); + } + + #[test] + fn merged_perimeter_keeps_four_continuous_strokes() { + let mut table = rule_table(2, 2, 0, 0); + table.merge(CellRange::new(0, 0, 1, 1)).unwrap(); + table.options.border.weight = 2.0; + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 4); + assert_eq!(fragment.strokes.iter().filter(|s| s.a.y == s.b.y).count(), 2); + assert_eq!(fragment.strokes.iter().filter(|s| s.a.x == s.b.x).count(), 2); + assert_no_overlapping_rules(fragment); + } + + #[test] + fn rtl_shared_rules_use_physical_edges_and_outer_borders() { + let mut table = rule_table(1, 2, 0, 0); + table.options.direction = designcraft_doc::TextDirection::RightToLeft; + table.columns[0].width = 30.0; + table.columns[1].width = 70.0; + set_rule(&mut table, 0, 0, 1, 2.0, 9); + set_rule(&mut table, 0, 1, 3, 4.0, 2); + set_rule(&mut table, 0, 0, 3, 5.0, 3); + table.cell_mut(0, 0).unwrap().border_overrides[3] = true; + set_rule(&mut table, 0, 1, 1, 6.0, 4); + table.cell_mut(0, 1).unwrap().border_overrides[1] = true; + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 3); + for (x, weight) in [(0.0, 6.0), (70.0, 2.0), (100.0, 5.0)] { + let rule = fragment.strokes.iter().find(|s| s.a.x == x && s.b.x == x).unwrap(); + assert_eq!(rule.stroke.weight, weight); + } + assert_no_overlapping_rules(fragment); + } + + fn compose_two_fragments(table: Table, height: f64) -> ComposedStory { + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + let (first, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 120.0, height), layer, "", ParaFormat::default()).unwrap(); + let (second, _) = + doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 200.0, 120.0, 200.0 + height), layer, "", ParaFormat::default()).unwrap(); + doc.thread(first, second).unwrap(); + doc.story_mut(sid).unwrap().insert_table(0, table); + let composed = crate::compose_story(&doc, sid, &ComposeOptions::default()); + assert!(!composed.is_overset(), "overset at {:?}", composed.overset_at); + assert_eq!(composed.frames[0].tables.len(), 1); + assert_eq!(composed.frames[1].tables.len(), 1); + composed + } + + #[test] + fn repeated_header_and_footer_rules_resolve_displayed_fragment_neighbors() { + let mut table = rule_table(4, 2, 1, 1); + table.merge(CellRange::new(0, 0, 0, 1)).unwrap(); + table.merge(CellRange::new(5, 0, 5, 1)).unwrap(); + set_rule(&mut table, 0, 0, 2, 1.0, 2); + set_rule(&mut table, 5, 0, 0, 2.0, 3); + // Keep 19 points for the anchor's trailing empty paragraph, still below the + // 100 points needed to fit a third body row plus the header and footer. + let composed = compose_two_fragments(table, 99.0); + for (i, frame) in composed.frames.iter().enumerate() { + let fragment = &frame.tables[0]; + let header = fragment.cell(0, 0).unwrap(); + let footer = fragment.cell(5, 0).unwrap(); + assert_eq!(header.repeated, i == 1); + assert_eq!(footer.repeated, i == 0); + assert_eq!(fragment.strokes.len(), 2); + let header_rules: Vec<_> = fragment.strokes.iter().filter(|s| s.a.y == header.rect.y1 && s.b.y == header.rect.y1).collect(); + let footer_rules: Vec<_> = fragment.strokes.iter().filter(|s| s.a.y == footer.rect.y0 && s.b.y == footer.rect.y0).collect(); + assert_eq!(header_rules.len(), 1); + assert!(header_rules.iter().all(|s| s.stroke.weight == 1.0)); + assert_eq!(footer_rules.len(), 1); + assert!(footer_rules.iter().all(|s| s.stroke.weight == 2.0)); + assert_no_overlapping_rules(fragment); + } + assert!(composed.frames[0].tables[0].cell(1, 0).is_some()); + assert!(composed.frames[1].tables[0].cell(3, 0).is_some()); + } + + #[test] + fn fragment_boundary_does_not_resolve_against_an_undisplayed_neighbor() { + let mut table = rule_table(4, 1, 0, 0); + set_rule(&mut table, 1, 0, 2, 3.0, 5); + table.cell_mut(1, 0).unwrap().border_overrides[2] = true; + set_rule(&mut table, 2, 0, 0, 4.0, 6); + table.cell_mut(2, 0).unwrap().border_overrides[0] = true; + // Two rows fit in each frame; leave room for the trailing empty paragraph. + let composed = compose_two_fragments(table, 59.0); + let first = &composed.frames[0].tables[0]; + let second = &composed.frames[1].tables[0]; + assert_eq!(first.strokes.len(), 1); + assert_eq!(first.strokes[0].stroke.weight, 3.0); + assert_eq!(first.strokes[0].a.y, first.rect.y1); + assert_eq!(second.strokes.len(), 1); + assert_eq!(second.strokes[0].stroke.weight, 4.0); + assert_eq!(second.strokes[0].a.y, second.rect.y0); + } } diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 7a700fb..e348ff2 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -148,6 +148,236 @@ impl Default for Styles { pub const NO_CELL_STYLE: &str = "[None]"; pub const BASIC_TABLE: &str = "[Basic Table]"; +pub const NO_TABLE_STYLE: &str = "[No table style]"; + +/// Partial edge formatting. Missing attributes inherit; zero weight and the None swatch +/// are explicit values, rather than a request to use the parent/default edge. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct CellStrokeAttrs { + #[serde(skip_serializing_if = "Option::is_none")] + pub weight: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, +} + +impl CellStrokeAttrs { + pub fn is_empty(&self) -> bool { + self.weight.is_none() && self.color.is_none() && self.tint.is_none() && self.kind.is_none() + } + + pub fn apply_to(&self, stroke: &mut crate::table::CellStroke) { + if let Some(v) = self.weight { + stroke.weight = v; + } + if let Some(v) = &self.color { + stroke.color = v.clone(); + } + if let Some(v) = self.tint { + stroke.tint = v; + } + if let Some(v) = &self.kind { + stroke.kind = v.clone(); + } + } + + fn merge(&mut self, other: &Self) { + if other.weight.is_some() { + self.weight = other.weight; + } + if other.color.is_some() { + self.color = other.color.clone(); + } + if other.tint.is_some() { + self.tint = other.tint; + } + if other.kind.is_some() { + self.kind = other.kind.clone(); + } + } + + fn from_stroke(stroke: &crate::table::CellStroke) -> Self { + Self { weight: Some(stroke.weight), color: Some(stroke.color.clone()), tint: Some(stroke.tint), kind: Some(stroke.kind.clone()) } + } +} + +fn no_edge_attrs(edges: &[CellStrokeAttrs; 4]) -> bool { + edges.iter().all(CellStrokeAttrs::is_empty) +} + +fn no_inset_attrs(insets: &[Option; 4]) -> bool { + insets.iter().all(Option::is_none) +} + +/// Partial alternating-fill settings, retained separately from the legacy complete value. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct AltFillsAttrs { + #[serde(skip_serializing_if = "Option::is_none")] + pub first: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub first_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub first_tint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_tint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub skip_first: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub skip_last: Option, +} + +impl AltFillsAttrs { + pub fn is_empty(&self) -> bool { + self == &Self::default() + } + + /// Overlay supplied attributes, retaining explicit zero counts and None swatches. + pub fn merge(&mut self, other: &Self) { + if other.first.is_some() { + self.first = other.first; + } + if other.first_color.is_some() { + self.first_color = other.first_color.clone(); + } + if other.first_tint.is_some() { + self.first_tint = other.first_tint; + } + if other.next.is_some() { + self.next = other.next; + } + if other.next_color.is_some() { + self.next_color = other.next_color.clone(); + } + if other.next_tint.is_some() { + self.next_tint = other.next_tint; + } + if other.skip_first.is_some() { + self.skip_first = other.skip_first; + } + if other.skip_last.is_some() { + self.skip_last = other.skip_last; + } + } + + fn from_fills(fills: &crate::table::AltFills) -> Self { + Self { + first: Some(fills.first), + first_color: Some(fills.first_color.clone()), + first_tint: Some(fills.first_tint), + next: Some(fills.next), + next_color: Some(fills.next_color.clone()), + next_tint: Some(fills.next_tint), + skip_first: Some(fills.skip_first), + skip_last: Some(fills.skip_last), + } + } + + pub fn apply_to(&self, fills: &mut Option) { + if self.is_empty() { + return; + } + let mut value = fills.clone().unwrap_or_default(); + if let Some(v) = self.first { + value.first = v; + } + if let Some(v) = &self.first_color { + value.first_color = v.clone(); + } + if let Some(v) = self.first_tint { + value.first_tint = v; + } + if let Some(v) = self.next { + value.next = v; + } + if let Some(v) = &self.next_color { + value.next_color = v.clone(); + } + if let Some(v) = self.next_tint { + value.next_tint = v; + } + if let Some(v) = self.skip_first { + value.skip_first = v; + } + if let Some(v) = self.skip_last { + value.skip_last = v; + } + *fills = (value.first > 0).then_some(value); + } +} + +/// Partial table alternating strokes; missing attributes inherit independently. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct AltStrokesAttrs { + #[serde(skip_serializing_if = "Option::is_none")] + pub first: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next: Option, + #[serde(skip_serializing_if = "CellStrokeAttrs::is_empty")] + pub first_stroke: CellStrokeAttrs, + #[serde(skip_serializing_if = "CellStrokeAttrs::is_empty")] + pub next_stroke: CellStrokeAttrs, + #[serde(skip_serializing_if = "Option::is_none")] + pub skip_first: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub skip_last: Option, +} + +impl AltStrokesAttrs { + pub fn is_empty(&self) -> bool { + self == &Self::default() + } + + pub fn merge(&mut self, other: &Self) { + if other.first.is_some() { + self.first = other.first; + } + if other.next.is_some() { + self.next = other.next; + } + self.first_stroke.merge(&other.first_stroke); + self.next_stroke.merge(&other.next_stroke); + if other.skip_first.is_some() { + self.skip_first = other.skip_first; + } + if other.skip_last.is_some() { + self.skip_last = other.skip_last; + } + } + + pub fn apply_to(&self, strokes: &mut Option) { + if self.is_empty() { + return; + } + let mut value = strokes.clone().unwrap_or_default(); + if let Some(v) = self.first { + value.first = v; + } + if let Some(v) = self.next { + value.next = v; + } + self.first_stroke.apply_to(&mut value.first_stroke); + self.next_stroke.apply_to(&mut value.next_stroke); + if let Some(v) = self.skip_first { + value.skip_first = v; + } + if let Some(v) = self.skip_last { + value.skip_last = v; + } + // Zero counts disable rendering, but the remaining settings still belong to + // the document (and are reused if the pattern is enabled later). + *strokes = Some(value); + } +} /// A cell style: the cell attributes it sets (unset = left as is) and a paragraph style for the /// cell's text. @@ -156,16 +386,24 @@ pub const BASIC_TABLE: &str = "[Basic Table]"; pub struct CellStyle { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] + pub based_on: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub fill: Option, #[serde(skip_serializing_if = "Option::is_none")] pub fill_tint: Option, #[serde(skip_serializing_if = "Option::is_none")] pub insets: Option<[f64; 4]>, + /// Per-side values override `insets`; top, left, bottom, right. + #[serde(skip_serializing_if = "no_inset_attrs")] + pub inset_overrides: [Option; 4], #[serde(skip_serializing_if = "Option::is_none")] pub vj: Option, /// All four edges. #[serde(skip_serializing_if = "Option::is_none")] pub stroke: Option, + /// Per-edge attributes override the legacy uniform `stroke`. + #[serde(skip_serializing_if = "no_edge_attrs")] + pub strokes: [CellStrokeAttrs; 4], #[serde(skip_serializing_if = "Option::is_none")] pub paragraph_style: Option, } @@ -176,6 +414,16 @@ pub struct CellStyle { pub struct TableStyle { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] + pub based_on: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub header_same_as_body: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub footer_same_as_body: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub left_column_same_as_body: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub right_column_same_as_body: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub header: Option, #[serde(skip_serializing_if = "Option::is_none")] pub body: Option, @@ -187,8 +435,18 @@ pub struct TableStyle { pub right_column: Option, #[serde(skip_serializing_if = "Option::is_none")] pub border: Option, + #[serde(skip_serializing_if = "no_edge_attrs")] + pub borders: [CellStrokeAttrs; 4], #[serde(skip_serializing_if = "Option::is_none")] pub alt_rows: Option, + #[serde(skip_serializing_if = "AltFillsAttrs::is_empty")] + pub row_fills: AltFillsAttrs, + #[serde(skip_serializing_if = "AltFillsAttrs::is_empty")] + pub column_fills: AltFillsAttrs, + #[serde(skip_serializing_if = "AltStrokesAttrs::is_empty")] + pub row_strokes: AltStrokesAttrs, + #[serde(skip_serializing_if = "AltStrokesAttrs::is_empty")] + pub column_strokes: AltStrokesAttrs, #[serde(skip_serializing_if = "Option::is_none")] pub space_before: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -207,11 +465,25 @@ impl CellStyle { if let Some(v) = self.insets { cell.insets = v; } + for (slot, value) in cell.insets.iter_mut().zip(self.inset_overrides) { + if let Some(value) = value { + *slot = value; + } + } if let Some(v) = self.vj { cell.vj = v; } if let Some(v) = &self.stroke { cell.strokes = [v.clone(), v.clone(), v.clone(), v.clone()]; + cell.border_overrides = [true; 4]; + cell.stroke_defined = [true; 4]; + } + for (i, edge) in self.strokes.iter().enumerate() { + edge.apply_to(&mut cell.strokes[i]); + if !edge.is_empty() { + cell.border_overrides[i] = true; + cell.stroke_defined[i] = true; + } } if let Some(ps) = &self.paragraph_style { for p in &mut cell.text.paras { @@ -224,14 +496,33 @@ impl CellStyle { } impl TableStyle { - /// Write this style into `t`: options, then each cell's region style. + /// Write the resolved table options, then region cell styles in precedence order: + /// body, left/right columns, header/footer. Explicit cell formatting is applied later. pub fn apply_to(&self, t: &mut crate::table::Table, cells: &[CellStyle]) { if let Some(b) = &self.border { t.options.border = b.clone(); + t.options.borders = Default::default(); + } + for (i, attrs) in self.borders.iter().enumerate() { + if !attrs.is_empty() { + let mut edge = t.options.border_for(i).clone(); + attrs.apply_to(&mut edge); + t.options.borders[i] = Some(edge); + } + } + if self.borders.iter().any(|edge| !edge.is_empty()) { + // Keep the legacy uniform accessor representative without changing absent sides. + let borders = std::array::from_fn::<_, 4, _>(|i| t.options.border_for(i).clone()); + t.options.border = borders[0].clone(); + t.options.borders = borders.map(Some); } if self.alt_rows.is_some() { t.options.alt_rows = self.alt_rows.clone(); } + self.row_fills.apply_to(&mut t.options.alt_rows); + self.column_fills.apply_to(&mut t.options.alt_cols); + self.row_strokes.apply_to(&mut t.options.row_strokes); + self.column_strokes.apply_to(&mut t.options.column_strokes); if let Some(v) = self.space_before { t.options.space_before = v; } @@ -243,27 +534,148 @@ impl TableStyle { let kind = t.rows[r].kind; for c in 0..nc { let visual_column = if t.options.direction == crate::TextDirection::RightToLeft { nc - 1 - c } else { c }; - let region = match kind { - crate::table::RowKind::Header => self.header.as_ref(), - crate::table::RowKind::Footer => self.footer.as_ref(), - _ if visual_column == 0 && self.left_column.is_some() => self.left_column.as_ref(), - _ if visual_column + 1 == nc && self.right_column.is_some() => self.right_column.as_ref(), - _ => self.body.as_ref(), + let same_or_region = |same, region: &Option| { + if same == Some(true) { self.body.clone() } else { region.clone() } }; - if let (Some(name), Some(cell)) = (region, t.cells.get_mut(r * nc + c)) - && let Some(cs) = cells.iter().find(|s| s.name == *name) - { - cs.apply_to(cell); + let left = same_or_region(self.left_column_same_as_body, &self.left_column); + let column = if visual_column == 0 && left.is_some() { + left + } else if visual_column + 1 == nc { + same_or_region(self.right_column_same_as_body, &self.right_column) + } else { + None + }; + let row = match kind { + crate::table::RowKind::Header => same_or_region(self.header_same_as_body, &self.header), + crate::table::RowKind::Footer => same_or_region(self.footer_same_as_body, &self.footer), + crate::table::RowKind::Body => None, + }; + if let Some(cell) = t.cells.get_mut(r * nc + c) { + for name in [self.body.as_ref(), column.as_ref(), row.as_ref()].into_iter().flatten() { + if name != NO_CELL_STYLE { + resolve_cell_style(cells, name).apply_to(cell); + } + } } } } t.style = if self.name == BASIC_TABLE { String::new() } else { self.name.clone() }; } + + fn merge(&mut self, other: &Self) { + if other.header.is_some() { + self.header = other.header.clone(); + } + if other.body.is_some() { + self.body = other.body.clone(); + } + if other.footer.is_some() { + self.footer = other.footer.clone(); + } + if other.left_column.is_some() { + self.left_column = other.left_column.clone(); + } + if other.right_column.is_some() { + self.right_column = other.right_column.clone(); + } + if other.header_same_as_body.is_some() { + self.header_same_as_body = other.header_same_as_body; + } + if other.footer_same_as_body.is_some() { + self.footer_same_as_body = other.footer_same_as_body; + } + if other.left_column_same_as_body.is_some() { + self.left_column_same_as_body = other.left_column_same_as_body; + } + if other.right_column_same_as_body.is_some() { + self.right_column_same_as_body = other.right_column_same_as_body; + } + if let Some(border) = &other.border { + self.borders = std::array::from_fn(|_| CellStrokeAttrs::from_stroke(border)); + } + for (edge, value) in self.borders.iter_mut().zip(&other.borders) { + edge.merge(value); + } + if let Some(fills) = &other.alt_rows { + self.row_fills = AltFillsAttrs::from_fills(fills); + } + self.row_fills.merge(&other.row_fills); + self.column_fills.merge(&other.column_fills); + self.row_strokes.merge(&other.row_strokes); + self.column_strokes.merge(&other.column_strokes); + if other.space_before.is_some() { + self.space_before = other.space_before; + } + if other.space_after.is_some() { + self.space_after = other.space_after; + } + } +} + +/// Resolve a cell style with the same 32-level/cycle limit as paragraph styles. +pub fn resolve_cell_style(cells: &[CellStyle], name: &str) -> CellStyle { + let mut chain = Vec::new(); + let mut cur = cells.iter().find(|s| s.name == name); + while let Some(style) = cur { + if chain.len() >= MAX_DEPTH || chain.iter().any(|s: &&CellStyle| s.name == style.name) { + break; + } + chain.push(style); + cur = style.based_on.as_deref().and_then(|base| cells.iter().find(|s| s.name == base)); + } + let mut out = CellStyle { name: name.into(), ..Default::default() }; + for style in chain.into_iter().rev() { + if style.fill.is_some() { + out.fill = style.fill.clone(); + } + if style.fill_tint.is_some() { + out.fill_tint = style.fill_tint; + } + if let Some(insets) = style.insets { + out.inset_overrides = insets.map(Some); + } + for (slot, value) in out.inset_overrides.iter_mut().zip(style.inset_overrides) { + if value.is_some() { + *slot = value; + } + } + if style.vj.is_some() { + out.vj = style.vj; + } + if let Some(stroke) = &style.stroke { + out.strokes = std::array::from_fn(|_| CellStrokeAttrs::from_stroke(stroke)); + } + for (slot, attrs) in out.strokes.iter_mut().zip(&style.strokes) { + slot.merge(attrs); + } + if style.paragraph_style.is_some() { + out.paragraph_style = style.paragraph_style.clone(); + } + } + out } const MAX_DEPTH: usize = 32; impl Styles { + /// Resolve table style inheritance without recursion; repeated/missing bases stop the chain. + pub fn resolve_table_style(&self, name: &str) -> TableStyle { + let mut chain = Vec::new(); + let mut cur = self.table.iter().find(|s| s.name == name); + while let Some(style) = cur { + if chain.len() >= MAX_DEPTH || chain.iter().any(|s: &&TableStyle| s.name == style.name) { + break; + } + chain.push(style); + cur = style.based_on.as_deref().and_then(|base| self.table.iter().find(|s| s.name == base)); + } + let mut out = TableStyle { name: name.into(), ..Default::default() }; + for style in chain.into_iter().rev() { + out.merge(style); + } + out + } + pub fn para(&self, name: &str) -> Option<&ParagraphStyle> { self.paragraph.iter().find(|s| s.name == name) } @@ -411,6 +823,104 @@ mod tests { assert!(!st.para_based_on_cycles(BASIC_PARAGRAPH, NO_PARA_STYLE)); } + #[test] + fn legacy_cell_and_table_style_json_keep_uniform_values() { + let cell: CellStyle = + serde_json::from_str(r#"{"name":"Legacy","insets":[1,2,3,4],"stroke":{"weight":2,"color":"Brand","tint":0.5,"kind":{"kind":"solid"}}}"#) + .unwrap(); + assert!(cell.based_on.is_none()); + assert!(no_edge_attrs(&cell.strokes)); + assert!(no_inset_attrs(&cell.inset_overrides)); + let resolved = resolve_cell_style(std::slice::from_ref(&cell), "Legacy"); + let mut target = crate::table::Cell::default(); + resolved.apply_to(&mut target); + assert_eq!(target.insets, [1.0, 2.0, 3.0, 4.0]); + assert!(target.strokes.iter().all(|s| s.weight == 2.0 && s.color == "Brand" && s.tint == 0.5)); + let json = serde_json::to_value(&cell).unwrap(); + assert!(json.get("strokes").is_none()); + assert_eq!(serde_json::from_value::(json).unwrap(), cell); + + let table: TableStyle = + serde_json::from_str(r#"{"name":"Legacy","body":"Legacy","border":{"weight":3},"altRows":{"first":2,"firstColor":"Brand"}}"#).unwrap(); + let styles = Styles { cell: vec![cell], table: vec![table.clone()], ..Styles::default() }; + let mut target = crate::table::Table::new(1, 1, 2, 0, 0, 100.0); + styles.resolve_table_style("Legacy").apply_to(&mut target, &styles.cell); + assert_eq!(target.options.border_for(3).weight, 3.0); + assert_eq!(target.options.alt_rows.as_ref().unwrap().first, 2); + assert_eq!(target.cell(0, 0).unwrap().strokes[0].weight, 2.0); + assert_eq!(serde_json::from_str::(&serde_json::to_string(&table).unwrap()).unwrap(), table); + } + + #[test] + fn table_region_flags_cover_footer_right_column_and_rtl() { + let cells = [ + CellStyle { name: "Body".into(), fill: Some("Body".into()), ..Default::default() }, + CellStyle { name: "Footer".into(), fill: Some("Footer".into()), ..Default::default() }, + CellStyle { name: "Right".into(), fill: Some("Right".into()), ..Default::default() }, + ]; + let mut style = TableStyle { + body: Some("Body".into()), + footer: Some("Footer".into()), + right_column: Some("Right".into()), + footer_same_as_body: Some(false), + right_column_same_as_body: Some(false), + ..Default::default() + }; + for direction in [crate::TextDirection::LeftToRight, crate::TextDirection::RightToLeft] { + let mut table = crate::table::Table::new(1, 1, 3, 0, 1, 100.0); + table.options.direction = direction; + let right = if direction == crate::TextDirection::LeftToRight { 2 } else { 0 }; + style.apply_to(&mut table, &cells); + assert_eq!(table.cell(0, right).unwrap().fill, "Right"); + assert_eq!(table.cell(1, right).unwrap().fill, "Footer"); + } + style.footer_same_as_body = Some(true); + style.right_column_same_as_body = Some(true); + let mut table = crate::table::Table::new(1, 1, 3, 0, 1, 100.0); + style.apply_to(&mut table, &cells); + assert!(table.cells.iter().all(|c| c.fill == "Body")); + } + + #[test] + fn single_column_tables_use_a_configured_right_region_when_left_is_absent() { + let cells = [CellStyle { name: "Right".into(), fill: Some("Right".into()), ..Default::default() }]; + let style = TableStyle { right_column: Some("Right".into()), ..Default::default() }; + let mut table = crate::table::Table::new(1, 1, 1, 0, 0, 100.0); + style.apply_to(&mut table, &cells); + assert_eq!(table.cell(0, 0).unwrap().fill, "Right"); + } + + #[test] + fn cell_and_table_style_resolution_stop_at_the_depth_limit() { + let cells: Vec<_> = (0..40) + .map(|i| CellStyle { + name: i.to_string(), + based_on: Some((i + 1).to_string()), + fill: (i == 32).then(|| "Too deep".into()), + fill_tint: (i == 31).then_some(0.5), + ..Default::default() + }) + .collect(); + let resolved = resolve_cell_style(&cells, "0"); + assert!(resolved.fill.is_none()); + assert_eq!(resolved.fill_tint, Some(0.5)); + let styles = Styles { + table: (0..40) + .map(|i| TableStyle { + name: i.to_string(), + based_on: Some((i + 1).to_string()), + space_before: (i == 32).then_some(123.0), + space_after: (i == 31).then_some(15.0), + ..Default::default() + }) + .collect(), + ..Styles::default() + }; + let resolved = styles.resolve_table_style("0"); + assert!(resolved.space_before.is_none()); + assert_eq!(resolved.space_after, Some(15.0)); + } + #[test] fn character_style_attrs_include_its_based_on_chain_only() { let mut st = Styles::default(); diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index ff7844b..7fe385e 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -79,6 +79,58 @@ impl CellStroke { } } +/// Table-level alternating row/column strokes. Retained independently of cell formatting +/// so an explicit cell style or local edge continues to take precedence. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct AltStrokes { + pub first: u32, + pub next: u32, + pub first_stroke: CellStroke, + pub next_stroke: CellStroke, + pub skip_first: u32, + pub skip_last: u32, +} + +impl Default for AltStrokes { + fn default() -> Self { + Self { + first: 0, + next: 0, + first_stroke: CellStroke::default(), + next_stroke: CellStroke { weight: 0.25, ..Default::default() }, + skip_first: 0, + skip_last: 0, + } + } +} + +impl AltStrokes { + /// A phase-independent stroke for unskipped, visually uniform patterns. Unequal + /// patterns and skip settings are preserved for round trips until their boundary + /// phase is supported; they must not be rendered with a guessed phase. + pub fn uniform_stroke(&self) -> Option<&CellStroke> { + if self.skip_first > 0 || self.skip_last > 0 { + return None; + } + match (self.first > 0, self.next > 0) { + (true, false) => Some(&self.first_stroke), + (false, true) => Some(&self.next_stroke), + (true, true) if self.first_stroke == self.next_stroke || (!self.first_stroke.is_visible() && !self.next_stroke.is_visible()) => { + Some(&self.first_stroke) + } + _ => None, + } + } +} + +fn no_flags(flags: &[bool; 4]) -> bool { + !flags.iter().any(|v| *v) +} +fn no_priorities(priorities: &[i32; 4]) -> bool { + priorities.iter().all(|v| *v == 0) +} + fn one() -> u32 { 1 } @@ -120,6 +172,12 @@ pub struct Cell { /// top, left, bottom, right order as `strokes`. Older documents keep their table border. #[serde(default)] pub border_overrides: [bool; 4], + /// Whether an edge has supplied cell/style attributes, independently of perimeter priority. + #[serde(default, skip_serializing_if = "no_flags")] + pub stroke_defined: [bool; 4], + /// IDML edge-conflict priorities, in top, left, bottom, right order. + #[serde(default, skip_serializing_if = "no_priorities")] + pub stroke_priorities: [i32; 4], /// Applied cell style ("" = [None]). #[serde(default, skip_serializing_if = "String::is_empty")] pub style: String, @@ -142,6 +200,8 @@ impl Default for Cell { rotation: 0.0, strokes: Default::default(), border_overrides: [false; 4], + stroke_priorities: [0; 4], + stroke_defined: [false; 4], style: String::new(), graphic: None, } @@ -200,12 +260,12 @@ impl AltFills { /// Fill for the `i`-th of `n` body rows/columns, if the pattern paints it. pub fn fill_for(&self, i: usize, n: usize) -> Option<(&str, f32)> { let (sf, sl) = (self.skip_first as usize, self.skip_last as usize); - if i < sf || i + sl >= n { + if i < sf || i >= n.saturating_sub(sl) { return None; } - let period = (self.first + self.next).max(1) as usize; - let k = (i - sf) % period; - let (c, t) = if k < self.first as usize { (&self.first_color, self.first_tint) } else { (&self.next_color, self.next_tint) }; + let period = (u64::from(self.first) + u64::from(self.next)).max(1); + let k = (i - sf) as u64 % period; + let (c, t) = if k < u64::from(self.first) { (&self.first_color, self.first_tint) } else { (&self.next_color, self.next_tint) }; (c != designcraft_color::swatch::NONE).then_some((c.as_str(), t)) } } @@ -217,6 +277,9 @@ pub struct TableOptions { pub direction: crate::TextDirection, /// Outer border (overrides the outer cell edges). pub border: CellStroke, + /// Per-side borders; absent sides use the legacy uniform `border`. + #[serde(skip_serializing_if = "no_borders")] + pub borders: [Option; 4], pub space_before: f64, pub space_after: f64, /// Header rows repeat at the top of every text column / frame the table continues into. @@ -226,6 +289,20 @@ pub struct TableOptions { pub alt_rows: Option, #[serde(skip_serializing_if = "Option::is_none")] pub alt_cols: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub row_strokes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub column_strokes: Option, +} + +fn no_borders(borders: &[Option; 4]) -> bool { + borders.iter().all(Option::is_none) +} + +impl TableOptions { + pub fn border_for(&self, side: usize) -> &CellStroke { + self.borders.get(side).and_then(Option::as_ref).unwrap_or(&self.border) + } } impl Default for TableOptions { @@ -233,12 +310,15 @@ impl Default for TableOptions { TableOptions { direction: crate::TextDirection::LeftToRight, border: CellStroke::default(), + borders: Default::default(), space_before: 4.0, space_after: -4.0, repeat_header: true, repeat_footer: true, alt_rows: None, alt_cols: None, + row_strokes: None, + column_strokes: None, } } } @@ -1097,6 +1177,16 @@ mod tests { assert!(b.fill_for(3, 9).is_none()); } + #[test] + fn alternating_fill_counts_cannot_overflow() { + let fills = AltFills { first: u32::MAX, next: 1, ..Default::default() }; + assert!(fills.fill_for(0, 2).is_some()); + assert!(fills.fill_for(1, 2).is_some()); + let skipped = AltFills { skip_last: u32::MAX, ..fills }; + assert!(skipped.fill_for(1, 2).is_none()); + assert!(skipped.fill_for(usize::MAX, usize::MAX).is_none()); + } + #[test] fn serde_roundtrip() { let mut s = story("x"); @@ -1118,8 +1208,22 @@ mod tests { old.as_object_mut().unwrap().remove("borderOverrides"); let back: Cell = serde_json::from_value(old).unwrap(); assert_eq!(back.border_overrides, [false; 4]); + assert_eq!(back.stroke_defined, [false; 4]); + assert_eq!(back.stroke_priorities, [0; 4]); + let legacy_options: TableOptions = serde_json::from_str(r#"{"border":{"weight":3}}"#).unwrap(); + assert!(legacy_options.borders.iter().all(Option::is_none)); + assert!(legacy_options.row_strokes.is_none()); + assert!(legacy_options.column_strokes.is_none()); + for side in 0..4 { + assert_eq!(legacy_options.border_for(side).weight, 3.0); + } - let explicit = Cell { border_overrides: [true, false, true, false], ..cell }; + let explicit = Cell { + border_overrides: [true, false, true, false], + stroke_defined: [true, true, false, false], + stroke_priorities: [3, 0, 7, 0], + ..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); diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 6836d1b..028971e 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -650,6 +650,11 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { edit_table(s, &g, "table.setCell", |t| { let owners = t.owners(); let nc = t.ncols(); + let nr = t.nrows(); + let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft; + let borders: [CellStroke; 4] = std::array::from_fn(|side| t.options.border_for(side).clone()); + // Local edits outrank previously imported edges, including explicit zero/None. + let priority = if stroke.is_some() { next_cell_stroke_priority(t) } else { 0 }; let rg = g.range; for r in rg.r0..=rg.r1 { for c in rg.c0..=rg.c1 { @@ -675,40 +680,57 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { } if let Some(sv) = &stroke { let edges = str_param(sv, "edges").unwrap_or("all"); + let at_bottom = r.saturating_add(cell.row_span.max(1) as usize); + let at_right = c.saturating_add(cell.col_span.max(1) as usize); + let perimeter = [r == 0, if rtl { at_right == nc } else { c == 0 }, at_bottom == nr, if rtl { c == 0 } else { at_right == nc }]; for (i, on) in [ ("top", r == rg.r0), - ("left", c == rg.c0), - ("bottom", r + cell.row_span as usize - 1 == rg.r1), - ("right", c + cell.col_span as usize - 1 == rg.c1), + ("left", if rtl { at_right.saturating_sub(1) == rg.c1 } else { c == rg.c0 }), + ("bottom", at_bottom.saturating_sub(1) == rg.r1), + ("right", if rtl { c == rg.c0 } else { at_right.saturating_sub(1) == rg.c1 }), ] .iter() .enumerate() .map(|(i, (name, outer))| (i, edges == "all" || edges == *name || (edges == "outer" && *outer) || (edges == "inner" && !*outer))) { if on { - cell.strokes[i] = parse_stroke(sv, cell.strokes[i].clone()); + let base = if perimeter[i] && !cell.border_overrides[i] { &borders[i] } else { &cell.strokes[i] }; + cell.strokes[i] = parse_stroke(sv, base.clone()); + cell.stroke_defined[i] = true; + cell.stroke_priorities[i] = priority; + cell.border_overrides[i] = true; } } } } } - // Neighbouring cells share edges: mirror the edges along the range boundary. + // Keep both copies of unmerged shared boundaries consistent. Left/right name + // physical sides, also in RTL tables. if let Some(sv) = &stroke { let edges = str_param(sv, "edges").unwrap_or("all"); - if matches!(edges, "all" | "outer" | "bottom") && rg.r1 + 1 < t.nrows() { + let selected = |side: &str| edges == "all" || edges == "outer" || edges == side; + if selected("top") + && let Some(row) = rg.r0.checked_sub(1) + { for c in rg.c0..=rg.c1 { - let s0 = t.cell(rg.r1, c).map(|x| x.strokes[2].clone()); - if let (Some(s0), Some(n)) = (s0, t.cell_mut(rg.r1 + 1, c)) { - n.strokes[0] = s0; - } + mirror_cell_edge(t, &owners, (rg.r0, c), 0, (row, c), 2); } } - if matches!(edges, "all" | "outer" | "right") && rg.c1 + 1 < t.ncols() { + if selected("bottom") && rg.r1.saturating_add(1) < nr { + for c in rg.c0..=rg.c1 { + mirror_cell_edge(t, &owners, (rg.r1, c), 2, (rg.r1 + 1, c), 0); + } + } + if selected(if rtl { "right" } else { "left" }) + && let Some(col) = rg.c0.checked_sub(1) + { for r in rg.r0..=rg.r1 { - let s0 = t.cell(r, rg.c1).map(|x| x.strokes[3].clone()); - if let (Some(s0), Some(n)) = (s0, t.cell_mut(r, rg.c1 + 1)) { - n.strokes[1] = s0; - } + mirror_cell_edge(t, &owners, (r, rg.c0), if rtl { 3 } else { 1 }, (r, col), if rtl { 1 } else { 3 }); + } + } + if selected(if rtl { "left" } else { "right" }) && rg.c1.saturating_add(1) < nc { + for r in rg.r0..=rg.r1 { + mirror_cell_edge(t, &owners, (r, rg.c1), if rtl { 1 } else { 3 }, (r, rg.c1 + 1), if rtl { 3 } else { 1 }); } } } @@ -716,6 +738,53 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { }) } +/// Only saturation needs rebasing. Preserve every distinct priority's order and keep +/// zero/negative priorities untouched; the work is bounded by the table's edges. +fn next_cell_stroke_priority(t: &mut Table) -> i32 { + let highest = t.cells.iter().flat_map(|cell| cell.stroke_priorities).max().unwrap_or(0).max(0); + if highest < i32::MAX { + return highest + 1; + } + let values: std::collections::BTreeSet<_> = t.cells.iter().flat_map(|cell| cell.stroke_priorities).filter(|p| *p > 0).collect(); + let mut ranks = std::collections::BTreeMap::new(); + let mut highest = 0i32; + for value in values { + highest = highest.saturating_add(1); + ranks.insert(value, highest); + } + for cell in &mut t.cells { + for priority in &mut cell.stroke_priorities { + if let Some(rank) = ranks.get(priority) { + *priority = *rank; + } + } + } + highest.saturating_add(1) +} + +fn mirror_cell_edge(t: &mut Table, owners: &[(usize, usize)], from: (usize, usize), edge: usize, to: (usize, usize), opposite: usize) { + let owner = |pos: (usize, usize)| owners.get(pos.0.checked_mul(t.ncols())?.checked_add(pos.1)?).copied(); + if owner(from) != Some(from) || owner(to) != Some(to) { + return; + } + // A merged cell's edge may border several separately editable cells. Mirroring + // a segment onto that whole edge would change unselected neighbors; priorities + // instead resolve each segment without altering the merged cell's formatting. + let merged = |cell: &designcraft_doc::Cell| cell.row_span > 1 || cell.col_span > 1; + if t.cell(from.0, from.1).is_some_and(merged) || t.cell(to.0, to.1).is_some_and(merged) { + return; + } + let source = t + .cell(from.0, from.1) + .map(|cell| (cell.strokes[edge].clone(), cell.stroke_defined[edge], cell.stroke_priorities[edge], cell.border_overrides[edge])); + if let (Some((stroke, defined, priority, border_override)), Some(neighbor)) = (source, t.cell_mut(to.0, to.1)) { + neighbor.strokes[opposite] = stroke; + neighbor.stroke_defined[opposite] = defined; + neighbor.stroke_priorities[opposite] = priority; + neighbor.border_overrides[opposite] = border_override; + } +} + fn set_row_height(s: &mut Session, p: &Value) -> Result { let g = target(s, p, "table.setRowHeight")?; let h = p.get("height").and_then(Value::as_f64).map(|h| h.clamp(0.0, 10000.0)); @@ -846,6 +915,7 @@ fn options(s: &mut Session, p: &Value) -> Result { } if let Some(b) = p.get("border") { o.border = parse_stroke(b, o.border.clone()); + o.borders = Default::default(); } o.space_before = f64_or(&p, "spaceBefore", o.space_before); o.space_after = f64_or(&p, "spaceAfter", o.space_after); @@ -982,6 +1052,9 @@ fn stroke_param(v: Option<&Value>) -> Option { /// Fill in the cell style fields given in `p`. fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, cmd: &str) -> Result<()> { + if let Some(v) = p.get("basedOn") { + cs.based_on = v.as_str().map(str::to_string); + } if let Some(f) = color_param(p, "fill") { cs.fill = Some(f); } @@ -989,10 +1062,14 @@ fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, cmd: &str) cs.fill_tint = Some(t.clamp(0.0, 1.0) as f32); } match p.get("insets") { - Some(Value::Number(n)) => cs.insets = n.as_f64().map(|v| [v.max(0.0); 4]), + Some(Value::Number(n)) => { + cs.insets = n.as_f64().map(|v| [v.max(0.0); 4]); + cs.inset_overrides = Default::default(); + } Some(Value::Array(a)) if a.len() == 4 => { let v: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0).max(0.0)).collect(); cs.insets = Some([v[0], v[1], v[2], v[3]]); + cs.inset_overrides = Default::default(); } _ => {} } @@ -1001,6 +1078,7 @@ fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, cmd: &str) } if let Some(st) = stroke_param(p.get("stroke")) { cs.stroke = Some(st); + cs.strokes = Default::default(); } if let Some(ps) = str_param(p, "paragraphStyle") { cs.paragraph_style = Some(ps.to_string()); @@ -1037,15 +1115,11 @@ fn cell_style_create(s: &mut Session, p: &Value) -> Result { fn cell_style_apply(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.cell.apply", "missing name"))?.to_string(); - let cs = s - .doc()? - .doc - .styles - .cell - .iter() - .find(|c| c.name == name) - .cloned() - .ok_or_else(|| bad("style.cell.apply", format!("no cell style `{name}`")))?; + let styles = &s.doc()?.doc.styles.cell; + if !styles.iter().any(|c| c.name == name) { + return Err(bad("style.cell.apply", format!("no cell style `{name}`"))); + } + let cs = designcraft_doc::resolve_cell_style(styles, &name); let g = target(s, p, "style.cell.apply")?; edit_table(s, &g, "style.cell.apply", |t| { let owners = t.owners(); @@ -1067,7 +1141,7 @@ fn cell_style_apply(s: &mut Session, p: &Value) -> Result { /// Re-apply cell styles named in `names` to every cell using them (after a style edit). fn reapply_cell_styles(d: &mut Document, names: &[String]) { - let styles = d.styles.cell.clone(); + let styles: Vec<_> = names.iter().map(|name| designcraft_doc::resolve_cell_style(&d.styles.cell, name)).collect(); for sid in d.stories.keys().copied().collect::>() { let Some(st) = d.story_mut(sid) else { continue }; for t in st.tables.values_mut() { @@ -1086,18 +1160,42 @@ fn reapply_cell_styles(d: &mut Document, names: &[String]) { } } +/// Include descendants when editing an inherited style, with the resolver's depth limit. +fn dependent_styles<'a>(name: &str, styles: impl Iterator)>) -> Vec { + let styles: Vec<_> = styles.collect(); + styles + .iter() + .filter(|(candidate, _)| { + let mut current = Some(*candidate); + for _ in 0..32 { + let Some(current_name) = current else { return false }; + if current_name == name { + return true; + } + current = styles.iter().find(|(n, _)| *n == current_name).and_then(|(_, base)| *base); + } + false + }) + .map(|(name, _)| (*name).to_string()) + .collect() +} + fn cell_style_edit(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.cell.edit", "missing name"))?.to_string(); let p = p.clone(); s.edit(|d, _| { let cs = d.styles_mut().cell.iter_mut().find(|c| c.name == name).ok_or_else(|| bad("style.cell.edit", format!("no cell style `{name}`")))?; cell_style_fields(cs, &p, "style.cell.edit")?; - reapply_cell_styles(d, std::slice::from_ref(&name)); + let names = dependent_styles(&name, d.styles.cell.iter().map(|cs| (cs.name.as_str(), cs.based_on.as_deref()))); + reapply_cell_styles(d, &names); ok() }) } fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value) { + if let Some(v) = p.get("basedOn") { + ts.based_on = v.as_str().map(str::to_string); + } for (k, f) in [ ("header", &mut ts.header), ("body", &mut ts.body), @@ -1111,6 +1209,7 @@ fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value) { } if let Some(b) = stroke_param(p.get("border")) { ts.border = Some(b); + ts.borders = Default::default(); } if let Some(a) = p.get("altRows").and_then(Value::as_object) { let n = |k: &str, d: u32| a.get(k).and_then(Value::as_u64).map_or(d, |v| v as u32); @@ -1150,7 +1249,10 @@ fn table_style_create(s: &mut Session, p: &Value) -> Result { fn table_style_apply(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.table.apply", "missing name"))?.to_string(); let doc = &s.doc()?.doc; - let ts = doc.styles.table.iter().find(|c| c.name == name).cloned().ok_or_else(|| bad("style.table.apply", format!("no table style `{name}`")))?; + if !doc.styles.table.iter().any(|c| c.name == name) { + return Err(bad("style.table.apply", format!("no table style `{name}`"))); + } + let ts = doc.styles.resolve_table_style(&name); let cells = doc.styles.cell.clone(); let g = target(s, p, "style.table.apply")?; edit_table(s, &g, "style.table.apply", |t| { @@ -1166,11 +1268,13 @@ fn table_style_edit(s: &mut Session, p: &Value) -> Result { let ts = d.styles_mut().table.iter_mut().find(|c| c.name == name).ok_or_else(|| bad("style.table.edit", format!("no table style `{name}`")))?; table_style_fields(ts, &p); - let (ts, cells) = (ts.clone(), d.styles.cell.clone()); + let names = dependent_styles(&name, d.styles.table.iter().map(|ts| (ts.name.as_str(), ts.based_on.as_deref()))); + let styles: Vec<_> = names.iter().map(|name| d.styles.resolve_table_style(name)).collect(); + let cells = d.styles.cell.clone(); for sid in d.stories.keys().copied().collect::>() { let Some(st) = d.story_mut(sid) else { continue }; for t in st.tables.values_mut() { - if t.style == name { + if let Some(ts) = styles.iter().find(|ts| ts.name == t.style) { ts.apply_to(std::sync::Arc::make_mut(t), &cells); } } diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 8f128ad..4586134 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -176,3 +176,226 @@ fn file_roundtrip_keeps_tables() { let st = back.story(designcraft_doc::StoryId(sid)).unwrap(); assert_eq!(st.tables.values().next().unwrap().cells[0].text.text, "A1"); } + +fn table_for(s: &Session, sid: u64, tid: u64) -> &doc::Table { + s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().tables.get(&tid).unwrap() +} + +#[test] +fn explicit_cell_edges_win_imported_priorities_and_keep_undo_and_file_roundtrip() { + for (direction, neighbors) in [ + (doc::TextDirection::LeftToRight, [(0, 1, 2), (1, 0, 3), (1, 2, 1), (2, 1, 0)]), + (doc::TextDirection::RightToLeft, [(0, 1, 2), (1, 0, 1), (1, 2, 3), (2, 1, 0)]), + ] { + for imported_priority in [-7, 41, i32::MAX] { + let (mut s, sid, _) = session_with_frame(); + let r = s.execute("table.insert", &json!({"rows": 3, "cols": 3})).unwrap(); + let tid = r["table"].as_u64().unwrap(); + s.edit(|d, _| { + let t = d.story_mut(doc::StoryId(sid)).unwrap().table_mut(tid).unwrap(); + t.options.border = doc::CellStroke::none(); + t.options.direction = direction; + for cell in &mut t.cells { + cell.strokes = std::array::from_fn(|_| doc::CellStroke::none()); + } + t.cell_mut(0, 0).unwrap().stroke_priorities[0] = -9; + for (row, col, edge) in neighbors { + let cell = t.cell_mut(row, col).unwrap(); + cell.strokes[edge] = doc::CellStroke { weight: 7.0, ..Default::default() }; + cell.stroke_defined[edge] = true; + cell.stroke_priorities[edge] = imported_priority; + } + Ok(()) + }) + .unwrap(); + let before = table_for(&s, sid, tid).clone(); + s.execute("table.setCell", &json!({"row": 1, "col": 1, "stroke": {"weight": 0, "edges": "all"}})).unwrap(); + let after = table_for(&s, sid, tid).clone(); + assert_eq!(after.cell(0, 0).unwrap().stroke_priorities[0], -9, "unrelated signed priorities are preserved"); + let edited = after.cell(1, 1).unwrap(); + let priority = if imported_priority == i32::MAX { 2 } else { imported_priority.max(0) + 1 }; + assert_eq!(edited.stroke_defined, [true; 4]); + assert_eq!(edited.border_overrides, [true; 4]); + assert_eq!(edited.stroke_priorities, [priority; 4]); + for (row, col, edge) in neighbors { + let neighbor = after.cell(row, col).unwrap(); + assert_eq!(neighbor.strokes[edge].weight, 0.0); + assert!(neighbor.stroke_defined[edge]); + assert!(neighbor.border_overrides[edge]); + assert_eq!(neighbor.stroke_priorities[edge], priority); + } + let composed = s.cache.get(&s.doc().unwrap().doc, doc::StoryId(sid), None); + assert!(composed.frames.iter().flat_map(|f| &f.tables).all(|t| t.strokes.is_empty())); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(table_for(&s, sid, tid), &before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(table_for(&s, sid, tid), &after); + let bytes = crate::cmd::file_bytes(&s.doc().unwrap().doc); + let back = crate::cmd::file_from(&bytes).unwrap(); + assert_eq!(back.story(doc::StoryId(sid)).unwrap().tables.get(&tid).unwrap().as_ref(), &after); + check(&s); + } + } +} + +#[test] +fn perimeter_edits_override_imported_sides_and_uniform_border_clears_side_options() { + let (mut s, sid, _) = session_with_frame(); + let r = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + let tid = r["table"].as_u64().unwrap(); + s.edit(|d, _| { + let t = d.story_mut(doc::StoryId(sid)).unwrap().table_mut(tid).unwrap(); + t.options.border.weight = 4.0; + t.options.borders = std::array::from_fn(|_| Some(doc::CellStroke { weight: 9.0, ..Default::default() })); + Ok(()) + }) + .unwrap(); + s.execute("table.setCell", &json!({"stroke": {"tint": 0.25, "edges": "left"}})).unwrap(); + let t = table_for(&s, sid, tid); + let cell = t.cell(0, 0).unwrap(); + assert_eq!(cell.stroke_defined, [false, true, false, false]); + assert_eq!(cell.border_overrides, [false, true, false, false]); + assert_eq!(cell.strokes[1].weight, 9.0, "partial edits preserve the visible perimeter base"); + assert_eq!(cell.strokes[1].tint, 0.25); + let before = t.clone(); + s.execute("table.options", &json!({"border": {"weight": 3}})).unwrap(); + let t = table_for(&s, sid, tid); + assert!(t.options.borders.iter().all(Option::is_none)); + assert_eq!(t.options.border.weight, 3.0); + let composed = s.cache.get(&s.doc().unwrap().doc, doc::StoryId(sid), None); + let frag = composed.frames.iter().flat_map(|f| &f.tables).next().unwrap(); + assert_eq!(frag.strokes.iter().filter(|s| s.stroke.weight == 3.0).count(), 3); + assert_eq!(frag.strokes.iter().filter(|s| s.stroke.weight == 9.0 && s.stroke.tint == 0.25).count(), 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(table_for(&s, sid, tid), &before); +} + +#[test] +fn inherited_cell_and_table_styles_apply_and_follow_base_edits() { + let (mut s, sid, _) = session_with_frame(); + let r = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap(); + let tid = r["table"].as_u64().unwrap(); + s.execute("style.cell.create", &json!({"name": "Cell Base", "insets": 7, "stroke": {"weight": 2}})).unwrap(); + s.execute("style.cell.create", &json!({"name": "Cell Child", "basedOn": "Cell Base", "fill": "[Paper]"})).unwrap(); + s.execute("style.cell.apply", &json!({"name": "Cell Child", "table": tid})).unwrap(); + let t = table_for(&s, sid, tid); + assert!(t.cells.iter().all(|cell| cell.style == "Cell Child" && cell.insets == [7.0; 4] && cell.strokes[0].weight == 2.0)); + s.execute("style.cell.edit", &json!({"name": "Cell Base", "insets": 5})).unwrap(); + assert!(table_for(&s, sid, tid).cells.iter().all(|cell| cell.insets == [5.0; 4])); + s.execute("style.table.create", &json!({"name": "Table Base", "body": "Cell Child", "border": {"weight": 6}, "spaceBefore": 8})).unwrap(); + s.execute("style.table.create", &json!({"name": "Table Child", "basedOn": "Table Base", "spaceAfter": 3})).unwrap(); + s.execute("style.table.apply", &json!({"name": "Table Child"})).unwrap(); + let t = table_for(&s, sid, tid); + assert_eq!(t.style, "Table Child"); + assert_eq!(t.options.border_for(0).weight, 6.0); + assert_eq!(t.options.space_before, 8.0); + assert_eq!(t.options.space_after, 3.0); + assert!(t.cells.iter().all(|cell| cell.style == "Cell Child" && cell.insets == [5.0; 4])); + s.execute("style.table.edit", &json!({"name": "Table Base", "border": {"weight": 4}, "spaceBefore": 10})).unwrap(); + let t = table_for(&s, sid, tid); + assert_eq!(t.options.border_for(0).weight, 4.0); + assert_eq!(t.options.space_before, 10.0); + assert_eq!(t.options.space_after, 3.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(table_for(&s, sid, tid).options.border_for(0).weight, 6.0); +} + +#[test] +fn uniform_style_edits_replace_imported_per_side_attributes() { + let (mut s, sid, _) = session_with_frame(); + let r = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + let tid = r["table"].as_u64().unwrap(); + s.edit(|d, _| { + d.styles_mut().cell.push(doc::CellStyle { + name: "Imported Cell".into(), + strokes: std::array::from_fn(|_| doc::CellStrokeAttrs { weight: Some(9.0), ..Default::default() }), + inset_overrides: [Some(12.0); 4], + ..Default::default() + }); + d.styles_mut().table.push(doc::TableStyle { + name: "Imported Table".into(), + borders: std::array::from_fn(|_| doc::CellStrokeAttrs { weight: Some(10.0), ..Default::default() }), + ..Default::default() + }); + Ok(()) + }) + .unwrap(); + s.execute("style.cell.apply", &json!({"name": "Imported Cell"})).unwrap(); + s.execute("style.cell.edit", &json!({"name": "Imported Cell", "stroke": {"weight": 2}, "insets": 3})).unwrap(); + let cell = table_for(&s, sid, tid).cell(0, 0).unwrap(); + assert!(cell.strokes.iter().all(|edge| edge.weight == 2.0)); + assert_eq!(cell.insets, [3.0; 4]); + s.execute("style.table.apply", &json!({"name": "Imported Table"})).unwrap(); + s.execute("style.table.edit", &json!({"name": "Imported Table", "border": {"weight": 4}})).unwrap(); + let t = table_for(&s, sid, tid); + assert!((0..4).all(|edge| t.options.border_for(edge).weight == 4.0)); +} + +#[test] +fn rtl_cell_edge_selection_uses_physical_sides() { + let (mut s, sid, _) = session_with_frame(); + let r = s.execute("table.insert", &json!({"rows": 1, "cols": 3})).unwrap(); + let tid = r["table"].as_u64().unwrap(); + s.execute("table.options", &json!({"direction": "rightToLeft", "border": {"weight": 0}})).unwrap(); + s.execute("table.setCell", &json!({"cols": [0, 2], "stroke": {"weight": 0}})).unwrap(); + s.execute("table.setCell", &json!({"row": 0, "col": 0, "stroke": {"weight": 2, "edges": "left"}})).unwrap(); + let t = table_for(&s, sid, tid); + assert_eq!(t.cell(0, 0).unwrap().strokes[1].weight, 2.0); + assert_eq!(t.cell(0, 1).unwrap().strokes[3].weight, 2.0); + assert_eq!(t.cell(0, 1).unwrap().strokes[1].weight, 0.0); + s.execute("table.setCell", &json!({"cols": [0, 2], "stroke": {"weight": 5, "edges": "outer"}})).unwrap(); + let t = table_for(&s, sid, tid); + assert_eq!(t.cell(0, 0).unwrap().strokes[3].weight, 5.0); + assert_eq!(t.cell(0, 2).unwrap().strokes[1].weight, 5.0); + assert_eq!(t.cell(0, 0).unwrap().strokes[1].weight, 2.0); + assert_eq!(t.cell(0, 2).unwrap().strokes[3].weight, 0.0); + s.execute("table.setCell", &json!({"cols": [0, 2], "stroke": {"weight": 0, "edges": "inner"}})).unwrap(); + let t = table_for(&s, sid, tid); + assert_eq!(t.cell(0, 0).unwrap().strokes[3].weight, 5.0); + assert_eq!(t.cell(0, 2).unwrap().strokes[1].weight, 5.0); + assert_eq!(t.cell(0, 0).unwrap().strokes[1].weight, 0.0); + assert_eq!(t.cell(0, 1).unwrap().strokes[3].weight, 0.0); +} + +#[test] +fn editing_beside_a_merged_cell_only_changes_the_selected_edge_segment() { + for direction in [doc::TextDirection::LeftToRight, doc::TextDirection::RightToLeft] { + for imported_priority in [41, i32::MAX] { + let (mut s, sid, _) = session_with_frame(); + let r = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); + let tid = r["table"].as_u64().unwrap(); + s.execute("table.merge", &json!({"row": 0, "cols": [0, 1]})).unwrap(); + s.edit(|d, _| { + let t = d.story_mut(doc::StoryId(sid)).unwrap().table_mut(tid).unwrap(); + t.options.direction = direction; + t.options.border = doc::CellStroke::none(); + for cell in &mut t.cells { + cell.strokes = std::array::from_fn(|_| doc::CellStroke::none()); + } + let merged = t.cell_mut(0, 0).unwrap(); + merged.strokes[2] = doc::CellStroke { weight: 7.0, ..Default::default() }; + merged.stroke_defined[2] = true; + merged.stroke_priorities[2] = imported_priority; + t.cell_mut(1, 1).unwrap().stroke_priorities[2] = 7; + Ok(()) + }) + .unwrap(); + let before = table_for(&s, sid, tid).clone(); + s.execute("table.setCell", &json!({"row": 1, "col": 0, "stroke": {"weight": 0, "edges": "top"}})).unwrap(); + let t = table_for(&s, sid, tid); + assert_eq!(t.cell(0, 0).unwrap().strokes[2].weight, 7.0, "the merged edge itself must not be erased"); + let priorities = + [t.cell(1, 1).unwrap().stroke_priorities[2], t.cell(0, 0).unwrap().stroke_priorities[2], t.cell(1, 0).unwrap().stroke_priorities[0]]; + assert_eq!(priorities, if imported_priority == i32::MAX { [1, 2, 3] } else { [7, 41, 42] }); + let composed = s.cache.get(&s.doc().unwrap().doc, doc::StoryId(sid), None); + let frag = composed.frames.iter().flat_map(|f| &f.tables).next().unwrap(); + let unselected = frag.cell(1, 1).unwrap().rect; + let visible: Vec<_> = frag.strokes.iter().filter(|s| s.stroke.weight == 7.0).collect(); + assert_eq!(visible.len(), 1); + assert_eq!(visible[0].a, designcraft_geom::Point::new(unselected.x0, unselected.y0)); + assert_eq!(visible[0].b, designcraft_geom::Point::new(unselected.x1, unselected.y0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(table_for(&s, sid, tid), &before); + } + } +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index ab54177..2758a6e 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -886,6 +886,11 @@ impl<'a> Ex<'a> { el.set(k, num(v)); } } + for (key, value) in ["TopInset", "LeftInset", "BottomInset", "RightInset"].iter().zip(cs.inset_overrides) { + if let Some(value) = value { + el.set(key, num(value)); + } + } if let Some(vj) = cs.vj { el.set("VerticalJustification", names::vj_out(vj)); } @@ -894,20 +899,32 @@ impl<'a> Ex<'a> { self.cell_stroke_attrs(&mut el, side, st); } } + for (side, attrs) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().zip(&cs.strokes) { + self.partial_cell_stroke_attrs(&mut el, side, attrs); + } if let Some(ps) = &cs.paragraph_style { el.set("AppliedParagraphStyle", names::style_self("ParagraphStyle", PARA_BUILTINS, ps)); } - cells = cells.child(with_props(el, vec![p("BasedOn", "string", "$ID/[None]")])); + let base = cs.based_on.as_deref().unwrap_or(designcraft_doc::NO_CELL_STYLE); + let based_on = if base == designcraft_doc::NO_CELL_STYLE { + p("BasedOn", "string", "$ID/[None]") + } else { + p("BasedOn", "object", names::style_self("CellStyle", names::CELL_BUILTINS, base)) + }; + cells = cells.child(with_props(el, vec![based_on])); } root.push(cells); - let mut tables = El::new("RootTableStyleGroup") - .attr("Self", self.fresh()) - .child(El::new("TableStyle").attr("Self", "TableStyle/$ID/[No table style]").attr("Name", "$ID/[No table style]")) - .child(with_props( + let mut tables = El::new("RootTableStyleGroup").attr("Self", self.fresh()); + if !styles.table.iter().any(|s| s.name == designcraft_doc::NO_TABLE_STYLE) { + tables.push(El::new("TableStyle").attr("Self", "TableStyle/$ID/[No table style]").attr("Name", "$ID/[No table style]")); + } + if !styles.table.iter().any(|s| s.name == designcraft_doc::BASIC_TABLE) { + tables.push(with_props( El::new("TableStyle").attr("Self", "TableStyle/$ID/[Basic Table]").attr("Name", "$ID/[Basic Table]"), vec![p("BasedOn", "string", "$ID/[No table style]")], )); - for ts in styles.table.iter().filter(|t| t.name != designcraft_doc::BASIC_TABLE) { + } + for ts in &styles.table { let mut el = El::new("TableStyle") .attr("Self", names::style_self("TableStyle", names::TABLE_BUILTINS, &ts.name)) .attr("Name", names::style_name_out(names::TABLE_BUILTINS, &ts.name)); @@ -922,11 +939,24 @@ impl<'a> Ex<'a> { el.set(k, names::style_self("CellStyle", names::CELL_BUILTINS, n)); } } + for (key, value) in [ + ("HeaderRegionSameAsBodyRegion", ts.header_same_as_body), + ("FooterRegionSameAsBodyRegion", ts.footer_same_as_body), + ("LeftColumnRegionSameAsBodyRegion", ts.left_column_same_as_body), + ("RightColumnRegionSameAsBodyRegion", ts.right_column_same_as_body), + ] { + if let Some(value) = value { + el.set(key, value); + } + } if let Some(b) = &ts.border { for side in ["Top", "Left", "Bottom", "Right"] { self.cell_stroke_attrs(&mut el, &format!("{side}Border"), b); } } + for (side, attrs) in ["TopBorder", "LeftBorder", "BottomBorder", "RightBorder"].iter().zip(&ts.borders) { + self.partial_cell_stroke_attrs(&mut el, side, attrs); + } if let Some(a) = &ts.alt_rows { el.set("StartRowFillColor", self.sw(&a.first_color)); el.set("StartRowFillCount", a.first); @@ -935,13 +965,26 @@ impl<'a> Ex<'a> { el.set("EndRowFillCount", a.next); el.set("EndRowFillTint", pct(a.next_tint as f64)); } + self.partial_alt_fill_attrs(&mut el, "Row", &ts.row_fills); + self.partial_alt_fill_attrs(&mut el, "Column", &ts.column_fills); + self.partial_alt_stroke_attrs(&mut el, "Row", &ts.row_strokes); + self.partial_alt_stroke_attrs(&mut el, "Column", &ts.column_strokes); if let Some(v) = ts.space_before { el.set("SpaceBefore", num(v)); } if let Some(v) = ts.space_after { el.set("SpaceAfter", num(v)); } - tables = tables.child(with_props(el, vec![p("BasedOn", "string", "$ID/[Basic Table]")])); + let mut props = Vec::new(); + if ts.name != designcraft_doc::NO_TABLE_STYLE { + let base = ts.based_on.as_deref().unwrap_or(designcraft_doc::NO_TABLE_STYLE); + props.push(if base == designcraft_doc::NO_TABLE_STYLE { + p("BasedOn", "string", "$ID/[No table style]") + } else { + p("BasedOn", "object", names::style_self("TableStyle", names::TABLE_BUILTINS, base)) + }); + } + tables = tables.child(with_props(el, props)); } root.push(tables); // Named numbered lists. @@ -2137,6 +2180,71 @@ impl<'a> Ex<'a> { el.set(&format!("{prefix}StrokeType"), names::stroke_type_out(&s.kind)); } + fn partial_cell_stroke_attrs(&self, el: &mut El, prefix: &str, attrs: &designcraft_doc::CellStrokeAttrs) { + if let Some(value) = attrs.weight { + el.set(&format!("{prefix}StrokeWeight"), num(value)); + } + if let Some(value) = &attrs.color { + el.set(&format!("{prefix}StrokeColor"), self.sw(value)); + } + if let Some(value) = attrs.tint { + el.set(&format!("{prefix}StrokeTint"), pct(value as f64)); + } + if let Some(value) = &attrs.kind { + el.set(&format!("{prefix}StrokeType"), names::stroke_type_out(value)); + } + } + + fn partial_alt_fill_attrs(&self, el: &mut El, kind: &str, attrs: &designcraft_doc::AltFillsAttrs) { + if let Some(value) = attrs.first { + el.set(&format!("Start{kind}FillCount"), value); + } + if let Some(value) = &attrs.first_color { + el.set(&format!("Start{kind}FillColor"), self.sw(value)); + } + if let Some(value) = attrs.first_tint { + el.set(&format!("Start{kind}FillTint"), pct(value as f64)); + } + if let Some(value) = attrs.next { + el.set(&format!("End{kind}FillCount"), value); + } + if let Some(value) = &attrs.next_color { + el.set(&format!("End{kind}FillColor"), self.sw(value)); + } + if let Some(value) = attrs.next_tint { + el.set(&format!("End{kind}FillTint"), pct(value as f64)); + } + if let Some(value) = attrs.skip_first { + el.set(&format!("SkipFirstAlternatingFill{kind}s"), value); + } + if let Some(value) = attrs.skip_last { + el.set(&format!("SkipLastAlternatingFill{kind}s"), value); + } + } + + fn partial_alt_stroke_attrs(&self, el: &mut El, kind: &str, attrs: &designcraft_doc::AltStrokesAttrs) { + if let Some(value) = attrs.first { + el.set(&format!("Start{kind}StrokeCount"), value); + } + if let Some(value) = attrs.next { + el.set(&format!("End{kind}StrokeCount"), value); + } + self.partial_cell_stroke_attrs(el, &format!("Start{kind}"), &attrs.first_stroke); + self.partial_cell_stroke_attrs(el, &format!("End{kind}"), &attrs.next_stroke); + if kind == "Column" + && let Some(value) = &attrs.next_stroke.kind + { + el.attrs.retain(|(key, _)| key != "EndColumnStrokeType"); + el.set("EndColumnLineStyle", names::stroke_type_out(value)); + } + if let Some(value) = attrs.skip_first { + el.set(&format!("SkipFirstAlternatingStroke{kind}s"), value); + } + if let Some(value) = attrs.skip_last { + el.set(&format!("SkipLastAlternatingStroke{kind}s"), value); + } + } + /// `` with rows, columns and cells (cell names are `column:row`). fn table_el(&mut self, t: &designcraft_doc::Table) -> El { let id = uid(t.id); @@ -2159,8 +2267,8 @@ impl<'a> Ex<'a> { .attr("SpaceAfter", num(t.options.space_after)) .attr("HeaderBehavior", if t.options.repeat_header { "RepeatOnEachTextColumn" } else { "RepeatOnce" }) .attr("FooterBehavior", if t.options.repeat_footer { "RepeatOnEachTextColumn" } else { "RepeatOnce" }); - for side in ["Top", "Left", "Bottom", "Right"] { - self.cell_stroke_attrs(&mut el, &format!("{side}Border"), &t.options.border); + for (i, side) in ["Top", "Left", "Bottom", "Right"].iter().enumerate() { + self.cell_stroke_attrs(&mut el, &format!("{side}Border"), t.options.border_for(i)); } for (kind, alt) in [("Row", &t.options.alt_rows), ("Column", &t.options.alt_cols)] { if let Some(a) = alt { @@ -2172,6 +2280,27 @@ impl<'a> Ex<'a> { el.set(&format!("End{kind}FillTint"), pct(a.next_tint as f64)); el.set(&format!("SkipFirstAlternatingFill{kind}s"), a.skip_first); el.set(&format!("SkipLastAlternatingFill{kind}s"), a.skip_last); + } else { + // A local off value must override any enabled pattern in the named style. + el.set(&format!("Start{kind}FillCount"), 0); + el.set(&format!("End{kind}FillCount"), 0); + } + } + for (kind, strokes) in [("Row", &t.options.row_strokes), ("Column", &t.options.column_strokes)] { + if let Some(strokes) = strokes { + el.set(&format!("Start{kind}StrokeCount"), strokes.first); + el.set(&format!("End{kind}StrokeCount"), strokes.next); + self.cell_stroke_attrs(&mut el, &format!("Start{kind}"), &strokes.first_stroke); + self.cell_stroke_attrs(&mut el, &format!("End{kind}"), &strokes.next_stroke); + if kind == "Column" { + el.attrs.retain(|(key, _)| key != "EndColumnStrokeType"); + el.set("EndColumnLineStyle", names::stroke_type_out(&strokes.next_stroke.kind)); + } + el.set(&format!("SkipFirstAlternatingStroke{kind}s"), strokes.skip_first); + el.set(&format!("SkipLastAlternatingStroke{kind}s"), strokes.skip_last); + } else { + el.set(&format!("Start{kind}StrokeCount"), 0); + el.set(&format!("End{kind}StrokeCount"), 0); } } for (r, row) in t.rows.iter().enumerate() { @@ -2223,9 +2352,21 @@ impl<'a> Ex<'a> { .attr("VerticalJustification", vj) .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); + // Do not turn absent native defaults into explicit shared-edge candidates. + if cell.stroke_defined[i] + || cell.border_overrides[i] + || cell.stroke_priorities[i] != 0 + || cell.strokes[i] != designcraft_doc::CellStroke::default() + { + self.cell_stroke_attrs(&mut ce, side, &cell.strokes[i]); + } + let priority = if cell.border_overrides[i] && !cell.stroke_defined[i] { + cell.stroke_priorities[i].max(1) + } else { + cell.stroke_priorities[i] + }; + if priority != 0 { + ce.set(&format!("{side}StrokePriority"), priority); } } for psr in self.story_paras(&cell.text) { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index f815fd2..8f96be6 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -7,11 +7,10 @@ use std::sync::Arc; use designcraft_color::cms::lab; use designcraft_color::{Color, ColorType, Gradient, GradientKind, GradientStop, Swatch, SwatchValue, swatch}; use designcraft_doc::{ - AltFills, Asset, AssetId, BaselineGrid, Cell, CellRange, CellStroke, CharAttrs, CharFormat, CharRun, CharacterStyle, Columns, ColumnsKind, - Content, DocSettings, Document, DropShadow, Effects, Fill, GridAlign, GridRelative, Guide, Item, ItemId, Kerning, LAYER_COLORS, Layer, LayerId, - ListType, Margins, ObjectStyle, Page, PageId, PageSide, ParaAttrs, ParaFormat, ParagraphStyle, ParentInfo, RowHeightMode, Rule, Section, Shape, - SpanColumns, Spread, SpreadId, Story, StoryId, Stroke, Styles, TabStop, Table, TextFrame, TextFrameOptions, TextWrap, VerticalJustification, - story as st, + Asset, AssetId, BaselineGrid, CellRange, CharAttrs, CharFormat, CharRun, CharacterStyle, Columns, ColumnsKind, Content, DocSettings, Document, + DropShadow, Effects, Fill, GridAlign, GridRelative, Guide, Item, ItemId, Kerning, LAYER_COLORS, Layer, LayerId, ListType, Margins, ObjectStyle, + Page, PageId, PageSide, ParaAttrs, ParaFormat, ParagraphStyle, ParentInfo, RowHeightMode, Rule, Section, Shape, SpanColumns, Spread, SpreadId, + Story, StoryId, Stroke, Styles, TabStop, Table, TextFrame, TextFrameOptions, TextWrap, story as st, }; use designcraft_geom::corners::{Corner, CornerOptions}; use designcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath}; @@ -20,6 +19,16 @@ use crate::names::{self, CHAR_BUILTINS, OBJECT_BUILTINS, PARA_BUILTINS, unescape use crate::xml::{El, Node, parse}; use crate::{IdmlError, MIMETYPE, Result, base64_decode, sniff_image}; +/// Preserve the legacy uniform field only when all four sides explicitly specify every +/// property. Partial records must stay sparse so they can inherit from a parent style. +fn uniform_cell_stroke(edges: &[designcraft_doc::CellStrokeAttrs; 4]) -> Option { + let first = edges.first()?; + if edges.iter().any(|edge| edge != first) { + return None; + } + Some(designcraft_doc::CellStroke { weight: first.weight?, color: first.color.clone()?, tint: first.tint?, kind: first.kind.clone()? }) +} + /// Import an IDML package. Linked images are read from disk when available (not on wasm). pub fn import_idml(bytes: &[u8]) -> Result { import_idml_with(bytes, &default_reader) @@ -109,6 +118,8 @@ struct Importer<'r> { char_names: HashMap, lists: Vec, object_names: HashMap, + cell_names: HashMap, + table_names: HashMap, /// Object style Self → element (attribute fallback for page items). object_els: HashMap, layers: Vec, @@ -193,6 +204,8 @@ impl<'r> Importer<'r> { char_names: HashMap::new(), lists: Vec::new(), object_names: HashMap::new(), + cell_names: HashMap::new(), + table_names: HashMap::new(), object_els: HashMap::new(), layers: Vec::new(), layer_ids: HashMap::new(), @@ -815,30 +828,31 @@ impl<'r> Importer<'r> { None => self.styles.character.push(s), } } - // Cell and table styles (the built-ins stay as they are). - let cell_ref = |r: &str| names::style_name_in(names::CELL_BUILTINS, &unescape_id(r.trim_start_matches("CellStyle/"))); + // Keep built-ins as well: [Basic Table] is editable, and [No table style] + // supplies the defaults used by named styles in ordinary IDML files. + for (id, e) in &cell_styles { + self.cell_names.insert(id.clone(), names::style_name_in(names::CELL_BUILTINS, e.get("Name").unwrap_or(""))); + } + for (id, e) in &table_styles { + self.table_names.insert(id.clone(), names::style_name_in(names::TABLE_BUILTINS, e.get("Name").unwrap_or(""))); + } for (_, e) in &cell_styles { let name = names::style_name_in(names::CELL_BUILTINS, e.get("Name").unwrap_or("")); - if name == designcraft_doc::NO_CELL_STYLE { - continue; - } let mut cs = designcraft_doc::CellStyle { name: name.clone(), ..Default::default() }; - cs.fill = e.get("FillColor").map(|c| self.swatch_ref(c)); - cs.fill_tint = e.num("FillTint").and_then(|v| tint(Some(v))); - let ins: Vec> = ["TopInset", "LeftInset", "BottomInset", "RightInset"].iter().map(|k| e.num(k)).collect(); - if ins.iter().all(Option::is_some) { - cs.insets = Some([ins[0].unwrap_or(0.0), ins[1].unwrap_or(0.0), ins[2].unwrap_or(0.0), ins[3].unwrap_or(0.0)]); + cs.based_on = + e.prop("BasedOn").map(|r| self.cell_style_ref(&r)).or_else(|| Some(designcraft_doc::NO_CELL_STYLE.into())).filter(|n| *n != name); + cs.fill = e.prop("FillColor").map(|c| self.swatch_ref(&c)); + cs.fill_tint = tint(e.num("FillTint")); + cs.inset_overrides = ["TopInset", "LeftInset", "BottomInset", "RightInset"].map(|k| e.num(k).map(|v| v.max(0.0))); + if let [Some(top), Some(left), Some(bottom), Some(right)] = cs.inset_overrides { + cs.insets = Some([top, left, bottom, right]); + cs.inset_overrides = [None; 4]; } cs.vj = e.get("VerticalJustification").map(names::vj_in); - if e.get("TopEdgeStrokeWeight").is_some() || e.get("TopEdgeStrokeColor").is_some() { - let mut st = CellStroke::default(); - if let Some(w) = e.num("TopEdgeStrokeWeight") { - st.weight = w.max(0.0); - } - if let Some(c) = e.get("TopEdgeStrokeColor") { - st.color = self.swatch_ref(c); - } - cs.stroke = Some(st); + cs.strokes = ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].map(|side| self.cell_stroke_attrs(e, side)); + if let Some(stroke) = uniform_cell_stroke(&cs.strokes) { + cs.stroke = Some(stroke); + cs.strokes = Default::default(); } cs.paragraph_style = e.get("AppliedParagraphStyle").map(|r| self.para_style_ref(r)).filter(|n| n != designcraft_doc::NO_PARA_STYLE); match self.styles.cell.iter_mut().find(|c| c.name == name) { @@ -848,37 +862,29 @@ impl<'r> Importer<'r> { } for (_, e) in &table_styles { let name = names::style_name_in(names::TABLE_BUILTINS, e.get("Name").unwrap_or("")); - if name == designcraft_doc::BASIC_TABLE || name == "[No table style]" { - continue; - } let mut ts = designcraft_doc::TableStyle { name: name.clone(), ..Default::default() }; - ts.header = e.get("HeaderRegionCellStyle").map(cell_ref).filter(|n| n != designcraft_doc::NO_CELL_STYLE); - ts.body = e.get("BodyRegionCellStyle").map(cell_ref).filter(|n| n != designcraft_doc::NO_CELL_STYLE); - ts.footer = e.get("FooterRegionCellStyle").map(cell_ref).filter(|n| n != designcraft_doc::NO_CELL_STYLE); - ts.left_column = e.get("LeftColumnRegionCellStyle").map(cell_ref).filter(|n| n != designcraft_doc::NO_CELL_STYLE); - ts.right_column = e.get("RightColumnRegionCellStyle").map(cell_ref).filter(|n| n != designcraft_doc::NO_CELL_STYLE); - if e.get("TopBorderStrokeWeight").is_some() { - let mut b = CellStroke::default(); - if let Some(w) = e.num("TopBorderStrokeWeight") { - b.weight = w.max(0.0); - } - if let Some(c) = e.get("TopBorderStrokeColor") { - b.color = self.swatch_ref(c); - } - ts.border = Some(b); - } - if let Some(first) = e.num("StartRowFillCount").filter(|n| *n > 0.0) { - ts.alt_rows = Some(AltFills { - first: first as u32, - first_color: e.get("StartRowFillColor").map(|c| self.swatch_ref(c)).unwrap_or_else(|| swatch::NONE.into()), - first_tint: tint(e.num("StartRowFillTint")).unwrap_or(1.0), - next: e.num("EndRowFillCount").unwrap_or(1.0) as u32, - next_color: e.get("EndRowFillColor").map(|c| self.swatch_ref(c)).unwrap_or_else(|| swatch::NONE.into()), - next_tint: tint(e.num("EndRowFillTint")).unwrap_or(1.0), - skip_first: 0, - skip_last: 0, - }); + ts.based_on = + e.prop("BasedOn").map(|r| self.table_style_ref(&r)).or_else(|| Some(designcraft_doc::NO_TABLE_STYLE.into())).filter(|n| *n != name); + ts.header = e.get("HeaderRegionCellStyle").map(|r| if r == "n" { designcraft_doc::NO_CELL_STYLE.into() } else { self.cell_style_ref(r) }); + ts.body = e.get("BodyRegionCellStyle").map(|r| if r == "n" { designcraft_doc::NO_CELL_STYLE.into() } else { self.cell_style_ref(r) }); + ts.footer = e.get("FooterRegionCellStyle").map(|r| if r == "n" { designcraft_doc::NO_CELL_STYLE.into() } else { self.cell_style_ref(r) }); + ts.left_column = + e.get("LeftColumnRegionCellStyle").map(|r| if r == "n" { designcraft_doc::NO_CELL_STYLE.into() } else { self.cell_style_ref(r) }); + ts.right_column = + e.get("RightColumnRegionCellStyle").map(|r| if r == "n" { designcraft_doc::NO_CELL_STYLE.into() } else { self.cell_style_ref(r) }); + ts.header_same_as_body = e.boolean("HeaderRegionSameAsBodyRegion"); + ts.footer_same_as_body = e.boolean("FooterRegionSameAsBodyRegion"); + ts.left_column_same_as_body = e.boolean("LeftColumnRegionSameAsBodyRegion"); + ts.right_column_same_as_body = e.boolean("RightColumnRegionSameAsBodyRegion"); + ts.borders = ["TopBorder", "LeftBorder", "BottomBorder", "RightBorder"].map(|side| self.cell_stroke_attrs(e, side)); + if let Some(border) = uniform_cell_stroke(&ts.borders) { + ts.border = Some(border); + ts.borders = Default::default(); } + ts.row_fills = self.alt_fill_attrs(e, "Row"); + ts.column_fills = self.alt_fill_attrs(e, "Column"); + ts.row_strokes = self.alt_stroke_attrs(e, "Row"); + ts.column_strokes = self.alt_stroke_attrs(e, "Column"); ts.space_before = e.num("SpaceBefore"); ts.space_after = e.num("SpaceAfter"); match self.styles.table.iter_mut().find(|c| c.name == name) { @@ -1386,12 +1392,73 @@ impl<'r> Importer<'r> { Some(r) } + fn cell_style_ref(&self, reference: &str) -> String { + let reference = reference.trim(); + self.cell_names + .get(reference) + .cloned() + .unwrap_or_else(|| names::style_name_in(names::CELL_BUILTINS, &unescape_id(reference.trim_start_matches("CellStyle/")))) + } + + fn table_style_ref(&self, reference: &str) -> String { + let reference = reference.trim(); + self.table_names + .get(reference) + .cloned() + .unwrap_or_else(|| names::style_name_in(names::TABLE_BUILTINS, &unescape_id(reference.trim_start_matches("TableStyle/")))) + } + + fn cell_stroke_attrs(&mut self, el: &El, prefix: &str) -> designcraft_doc::CellStrokeAttrs { + designcraft_doc::CellStrokeAttrs { + weight: el.num(&format!("{prefix}StrokeWeight")).map(|v| v.max(0.0)), + color: el.prop(&format!("{prefix}StrokeColor")).map(|v| self.swatch_ref(&v)), + tint: tint(el.num(&format!("{prefix}StrokeTint"))), + kind: el.prop(&format!("{prefix}StrokeType")).map(|v| names::stroke_type_in(&v)), + } + } + + fn alt_fill_attrs(&mut self, el: &El, kind: &str) -> designcraft_doc::AltFillsAttrs { + designcraft_doc::AltFillsAttrs { + first: el.num(&format!("Start{kind}FillCount")).map(|v| v.max(0.0) as u32), + first_color: el.prop(&format!("Start{kind}FillColor")).map(|v| self.swatch_ref(&v)), + first_tint: tint(el.num(&format!("Start{kind}FillTint"))), + next: el.num(&format!("End{kind}FillCount")).map(|v| v.max(0.0) as u32), + next_color: el.prop(&format!("End{kind}FillColor")).map(|v| self.swatch_ref(&v)), + next_tint: tint(el.num(&format!("End{kind}FillTint"))), + skip_first: el.num(&format!("SkipFirstAlternatingFill{kind}s")).map(|v| v.max(0.0) as u32), + skip_last: el.num(&format!("SkipLastAlternatingFill{kind}s")).map(|v| v.max(0.0) as u32), + } + } + + fn alt_stroke_attrs(&mut self, el: &El, kind: &str) -> designcraft_doc::AltStrokesAttrs { + let first_stroke = self.cell_stroke_attrs(el, &format!("Start{kind}")); + let mut next_stroke = self.cell_stroke_attrs(el, &format!("End{kind}")); + // IDML uses LineStyle for this one attribute (rather than StrokeType). + if kind == "Column" + && let Some(value) = el.prop("EndColumnLineStyle") + { + next_stroke.kind = Some(names::stroke_type_in(&value)); + } + designcraft_doc::AltStrokesAttrs { + first: el.num(&format!("Start{kind}StrokeCount")).map(|v| v.max(0.0) as u32), + next: el.num(&format!("End{kind}StrokeCount")).map(|v| v.max(0.0) as u32), + first_stroke, + next_stroke, + skip_first: el.num(&format!("SkipFirstAlternatingStroke{kind}s")).map(|v| v.max(0.0) as u32), + skip_last: el.num(&format!("SkipLastAlternatingStroke{kind}s")).map(|v| v.max(0.0) as u32), + } + } + // ---------- stories ---------- fn story(&mut self, id: StoryId, e: &El) -> Story { + self.story_with_para(id, e, ParaFormat::default()) + } + + fn story_with_para(&mut self, id: StoryId, e: &El, default_para: ParaFormat) -> Story { let mut b = StoryBuilder { text: String::new(), - paras: vec![ParaFormat::default()], + paras: vec![default_para.clone()], runs: Vec::new(), fresh: true, last: CharFormat::default(), @@ -1403,7 +1470,7 @@ impl<'r> Importer<'r> { index_refs: Vec::new(), objects: Vec::new(), }; - self.walk_story(e, &mut b, &ParaFormat::default(), &CharAttrs::default(), &CharFormat::default(), None); + self.walk_story(e, &mut b, &default_para, &CharAttrs::default(), &CharFormat::default(), None); let StoryBuilder { text, mut paras, runs, last, tables: tbls, notes, anchors, xrefs, index_refs, objects, .. } = b; let mut chars: Vec = runs.into_iter().filter(|r| r.len > 0).collect(); if chars.is_empty() { @@ -1490,50 +1557,39 @@ impl<'r> Importer<'r> { if h.saturating_add(f) < nr { t.set_header_footer(h, f); } - let stroke = |me: &mut Self, el: &El, prefix: &str, base: &CellStroke| -> CellStroke { - let mut s = base.clone(); - if let Some(w) = el.num(&format!("{prefix}StrokeWeight")) { - s.weight = w.max(0.0); - } - if let Some(c) = el.get(&format!("{prefix}StrokeColor")) { - s.color = me.swatch_ref(c); - } - if let Some(v) = el.num(&format!("{prefix}StrokeTint")) { - s.tint = tint(Some(v)).unwrap_or(1.0); - } - if let Some(v) = el.get(&format!("{prefix}StrokeType")) { - s.kind = names::stroke_type_in(v); - } - s - }; - t.options.border = stroke(self, e, "TopBorder", &t.options.border); + let style_name = e.get("AppliedTableStyle").map(|r| self.table_style_ref(r)).unwrap_or_else(|| designcraft_doc::BASIC_TABLE.into()); + let style = self.styles.resolve_table_style(&style_name); + style.apply_to(&mut t, &self.styles.cell); + // IDML distinguishes an explicit AppliedCellStyle from the effective region style. + for cell in &mut t.cells { + cell.style.clear(); + } + // Table-local options override the named table style. Cell styles are already + // materialized independently, so their edges retain their higher precedence. + let mut borders = std::array::from_fn::<_, 4, _>(|i| t.options.border_for(i).clone()); + for (side, border) in ["TopBorder", "LeftBorder", "BottomBorder", "RightBorder"].iter().zip(&mut borders) { + self.cell_stroke_attrs(e, side).apply_to(border); + } + t.options.border = borders[0].clone(); + t.options.borders = borders.map(Some); t.options.space_before = e.num("SpaceBefore").unwrap_or(t.options.space_before); t.options.space_after = e.num("SpaceAfter").unwrap_or(t.options.space_after); t.options.repeat_header = e.get("HeaderBehavior") != Some("RepeatOnce"); t.options.repeat_footer = e.get("FooterBehavior") != Some("RepeatOnce"); - for kind in ["Row", "Column"] { - let Some(first) = e.num(&format!("Start{kind}FillCount")).filter(|n| *n > 0.0) else { continue }; - let alt = AltFills { - first: first as u32, - first_color: e.get(&format!("Start{kind}FillColor")).map(|c| self.swatch_ref(c)).unwrap_or_else(|| swatch::NONE.into()), - first_tint: tint(e.num(&format!("Start{kind}FillTint"))).unwrap_or(1.0), - next: e.num(&format!("End{kind}FillCount")).unwrap_or(1.0) as u32, - next_color: e.get(&format!("End{kind}FillColor")).map(|c| self.swatch_ref(c)).unwrap_or_else(|| swatch::NONE.into()), - next_tint: tint(e.num(&format!("End{kind}FillTint"))).unwrap_or(1.0), - skip_first: e.num(&format!("SkipFirstAlternatingFill{kind}s")).unwrap_or(0.0) as u32, - skip_last: e.num(&format!("SkipLastAlternatingFill{kind}s")).unwrap_or(0.0) as u32, - }; - if kind == "Row" { - t.options.alt_rows = Some(alt); - } else { - t.options.alt_cols = Some(alt); - } - } - t.style = e - .get("AppliedTableStyle") - .map(|r| names::style_name_in(names::TABLE_BUILTINS, &unescape_id(r.trim_start_matches("TableStyle/")))) - .filter(|n| n != designcraft_doc::BASIC_TABLE && n != "[No table style]") - .unwrap_or_default(); + // A local count may enable a pattern disabled by its parent style. Merge before + // materialization so the parent's colors/tints are still available in that case. + let mut row_fills = style.row_fills.clone(); + row_fills.merge(&self.alt_fill_attrs(e, "Row")); + row_fills.apply_to(&mut t.options.alt_rows); + let mut column_fills = style.column_fills.clone(); + column_fills.merge(&self.alt_fill_attrs(e, "Column")); + column_fills.apply_to(&mut t.options.alt_cols); + let mut row_strokes = style.row_strokes.clone(); + row_strokes.merge(&self.alt_stroke_attrs(e, "Row")); + row_strokes.apply_to(&mut t.options.row_strokes); + let mut column_strokes = style.column_strokes.clone(); + column_strokes.merge(&self.alt_stroke_attrs(e, "Column")); + column_strokes.apply_to(&mut t.options.column_strokes); let mut regions = Vec::new(); for ce in e.find_all("Cell") { let Some((c, r)) = @@ -1544,36 +1600,39 @@ impl<'r> Importer<'r> { if r >= nr || c >= nc { continue; } - let rs = ce.num("RowSpan").unwrap_or(1.0).max(1.0) as usize; - let cs = ce.num("ColumnSpan").unwrap_or(1.0).max(1.0) as usize; - let text = self.story(StoryId(0), ce); - let mut cell = Cell { text, ..Default::default() }; - cell.style = ce - .get("AppliedCellStyle") - .map(|r| names::style_name_in(names::CELL_BUILTINS, &unescape_id(r.trim_start_matches("CellStyle/")))) - .filter(|n| n != designcraft_doc::NO_CELL_STYLE) - .unwrap_or_default(); + let rs = (ce.num("RowSpan").unwrap_or(1.0).max(1.0) as usize).min(nr - r); + let cs = (ce.num("ColumnSpan").unwrap_or(1.0).max(1.0) as usize).min(nc - c); + let mut cell = t.cell(r, c).cloned().unwrap_or_default(); + let cell_style = ce.get("AppliedCellStyle").map(|r| self.cell_style_ref(r)).unwrap_or_else(|| designcraft_doc::NO_CELL_STYLE.into()); + if cell_style != designcraft_doc::NO_CELL_STYLE { + designcraft_doc::resolve_cell_style(&self.styles.cell, &cell_style).apply_to(&mut cell); + } + cell.style = if cell_style == designcraft_doc::NO_CELL_STYLE { String::new() } else { cell_style }; + let default_para = cell.text.paras.first().cloned().unwrap_or_default(); + cell.text = self.story_with_para(StoryId(0), ce, default_para); if let Some(fc) = ce.get("FillColor") { cell.fill = self.swatch_ref(fc); } - cell.fill_tint = tint(ce.num("FillTint")).unwrap_or(1.0); + if let Some(tint) = tint(ce.num("FillTint")) { + cell.fill_tint = tint; + } for (i, k) in ["TopInset", "LeftInset", "BottomInset", "RightInset"].iter().enumerate() { if let Some(v) = ce.num(k) { cell.insets[i] = v.max(0.0); } } - cell.vj = match ce.get("VerticalJustification") { - Some("CenterAlign") => VerticalJustification::Center, - Some("BottomAlign") => VerticalJustification::Bottom, - Some("JustifyAlign") => VerticalJustification::Justify, - _ => VerticalJustification::Top, - }; + if let Some(vj) = ce.get("VerticalJustification") { + cell.vj = names::vj_in(vj); + } + cell.rotation = ce.num("RotationAngle").unwrap_or(cell.rotation); 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); + let attrs = self.cell_stroke_attrs(ce, side); + attrs.apply_to(&mut cell.strokes[i]); + cell.stroke_defined[i] |= !attrs.is_empty(); + let priority = ce.prop(&format!("{side}StrokePriority")).and_then(|v| v.trim().parse::().ok()).unwrap_or(0); + cell.stroke_priorities[i] = priority; + // Preserve style-defined edges and #102's positive-priority local overrides. + cell.border_overrides[i] |= priority > 0; } if let Some(slot) = t.cell_mut(r, c) { *slot = cell; @@ -1593,7 +1652,7 @@ impl<'r> Importer<'r> { match n { Node::El(c) => match c.local() { "ParagraphStyleRange" => { - let style = c.get("AppliedParagraphStyle").map(|r| self.para_style_ref(r)).unwrap_or_else(|| st::BASIC_PARAGRAPH.into()); + let style = c.get("AppliedParagraphStyle").map(|r| self.para_style_ref(r)).unwrap_or_else(|| pf.style.clone()); let para = self.para_attrs(c); let chars = self.char_attrs(c); let npf = ParaFormat { style, para, chars: CharAttrs::default(), table: None }; diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index ae2805b..682dabe 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -85,7 +85,7 @@ pub fn unescape_id(s: &str) -> String { pub const PARA_BUILTINS: &[(&str, &str)] = &[("[No Paragraph Style]", "$ID/[No paragraph style]"), ("[Basic Paragraph]", "$ID/NormalParagraphStyle")]; pub const CHAR_BUILTINS: &[(&str, &str)] = &[("[None]", "$ID/[No character style]")]; pub const CELL_BUILTINS: &[(&str, &str)] = &[("[None]", "$ID/[None]")]; -pub const TABLE_BUILTINS: &[(&str, &str)] = &[("[Basic Table]", "$ID/[Basic Table]")]; +pub const TABLE_BUILTINS: &[(&str, &str)] = &[("[Basic Table]", "$ID/[Basic Table]"), ("[No table style]", "$ID/[No table style]")]; pub const OBJECT_BUILTINS: &[(&str, &str)] = &[("[None]", "$ID/[None]"), ("[Basic Graphics Frame]", "$ID/[Normal Graphics Frame]"), ("[Basic Text Frame]", "$ID/[Normal Text Frame]")]; diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 6aa3d74..d7308cd 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -928,6 +928,336 @@ fn arabic_story_and_table_directions_survive_idml_export() { assert_eq!(st.direction, designcraft_doc::TextDirection::RightToLeft); assert_eq!(st.tables.values().next().unwrap().options.direction, designcraft_doc::TextDirection::RightToLeft); } +/// Fully synthetic style-only table, including sparse based-on attributes and local overrides. +const CASCADE_STYLES: &str = r#" + + + + cs-base + cs-body + + cs-body + + + + + + + + $ID/[No table style] + + + ts-base + + + ts-child + +"#; + +const CASCADE_STORY: &str = r#" +
+ + +Header left +Explicit + + Local + +Left +Body +Body right +
"#; + +fn fixture_with_table_styles(styles: &str, story: &str) -> Vec { + zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", styles), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", SPREAD), + ("Stories/Story_s1.xml", story), + ]) +} + +#[test] +fn table_style_cascade_preserves_sparse_edges_and_local_overrides() { + let d = import_idml(&fixture_with_table_styles(CASCADE_STYLES, CASCADE_STORY)).unwrap(); + d.check().unwrap(); + let table = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert_eq!((table.nrows(), table.ncols()), (2, 3)); + assert_eq!(table.style, "Table"); + assert_eq!(table.options.space_before, 19.0); + assert_eq!(std::array::from_fn::<_, 4, _>(|i| table.options.border_for(i).weight), [7.0, 8.0, 9.0, 10.0]); + assert_eq!(table.options.border_for(3).color, "Brand"); + let fills = table.options.alt_rows.as_ref().unwrap(); + assert_eq!((fills.first, fills.next, fills.first_tint), (1, 1, 0.2)); + assert_eq!(fills.first_color, "Brand"); + let header = table.cell(0, 0).unwrap(); + assert_eq!(header.fill, designcraft_color::swatch::PAPER, "header takes precedence over left region"); + assert!(header.style.is_empty(), "a table region does not become AppliedCellStyle"); + assert_eq!(header.insets, [2.0, 3.0, 4.0, 5.0]); + assert_eq!(header.vj, designcraft_doc::VerticalJustification::Center); + assert_eq!(header.text.paras[0].style, "CellParagraph"); + assert_eq!(header.strokes[0].weight, 2.0); + assert_eq!(header.strokes[0].tint, 0.4); + assert_ne!(header.strokes[0].kind, designcraft_doc::StrokeType::Solid); + assert_eq!(header.strokes[2].weight, 6.0); + assert_eq!(header.border_overrides, [true; 4]); + assert_eq!(header.stroke_defined, [true; 4]); + assert_eq!(header.stroke_priorities, [0; 4]); + let explicit = table.cell(0, 1).unwrap(); + assert_eq!(explicit.fill, "Brand"); + assert_eq!(explicit.fill_tint, 0.75); + assert_eq!(explicit.insets, [0.0, 3.0, 4.0, 5.0]); + assert_eq!(explicit.strokes[0].weight, 2.0, "color-only override inherits weight"); + assert_eq!(explicit.strokes[0].color, designcraft_color::swatch::NONE); + assert_eq!(explicit.strokes[3].weight, 5.0); + assert_eq!(explicit.strokes[3].tint, 0.3); + let local = table.cell(0, 2).unwrap(); + assert_eq!(local.fill, designcraft_color::swatch::NONE); + assert_eq!(local.fill_tint, 0.0); + assert_eq!(local.insets[1], 0.0); + assert_eq!(local.vj, designcraft_doc::VerticalJustification::Bottom); + assert_eq!(local.strokes[0].weight, 0.0); + assert_eq!(local.strokes[3].color, designcraft_color::swatch::NONE); + assert_eq!(local.stroke_priorities, [0, 0, 0, 7]); + assert_eq!(local.text.paras[0].style, st::BASIC_PARAGRAPH, "explicit paragraph style wins"); + assert_eq!(table.cell(1, 0).unwrap().fill, designcraft_color::swatch::BLACK); + assert_eq!(table.cell(1, 0).unwrap().strokes[1].weight, 0.0); + assert_eq!(table.cell(1, 1).unwrap().fill_tint, 0.5); + assert_eq!(table.cell(1, 2).unwrap().fill, "Brand"); + + let back = import_idml(&export_idml(&d)).unwrap(); + let roundtrip = back.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + for (before, after) in table.cells.iter().zip(&roundtrip.cells) { + assert_eq!(after.strokes, before.strokes); + assert_eq!(after.border_overrides, before.border_overrides); + assert_eq!(after.stroke_priorities, before.stroke_priorities); + assert_eq!(after.style, before.style); + assert_eq!(after.fill, before.fill); + assert_eq!(after.insets, before.insets); + } + for i in 0..4 { + assert_eq!(roundtrip.options.border_for(i), table.options.border_for(i)); + } + let style = back.styles.cell.iter().find(|s| s.name == "Explicit").unwrap(); + assert_eq!(style.strokes[0].weight, None, "style export keeps absence distinct from zero"); + assert_eq!(style.strokes[0].color.as_deref(), Some(designcraft_color::swatch::NONE)); + assert_eq!(back.styles.table.iter().find(|s| s.name == "Table").unwrap().based_on.as_deref(), Some("BaseTable")); +} + +#[test] +fn table_style_same_as_body_flags_and_editable_basic_table_are_inherited() { + let styles = CASCADE_STYLES.replace( + "Name=\"Table\" SpaceBefore", + "Name=\"Table\" HeaderRegionSameAsBodyRegion=\"true\" LeftColumnRegionSameAsBodyRegion=\"true\" SpaceBefore", + ); + let story = CASCADE_STORY.replace("AppliedTableStyle=\"ts-child\"", "AppliedTableStyle=\"TableStyle/$ID/[Basic Table]\""); + let d = import_idml(&fixture_with_table_styles(&styles, &story)).unwrap(); + let table = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert!(table.style.is_empty()); + assert_eq!(table.options.space_after, 17.0); + assert_eq!(table.cell(0, 0).unwrap().fill, "Brand"); + assert_eq!(table.cell(0, 0).unwrap().strokes[2].weight, 4.0); + assert_eq!(table.cell(1, 0).unwrap().fill, "Brand"); + assert_eq!(table.cell(1, 0).unwrap().strokes[1].weight, 3.0); +} + +#[test] +fn table_style_cycles_and_missing_bases_terminate_without_losing_nearest_values() { + let styles = r#" + +CellStyle/B +CellStyle/A +CellStyle/Unknown + +TableStyle/B +ts-child +"#; + let d = import_idml(&fixture_with_table_styles(styles, CASCADE_STORY)).unwrap(); + let table = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert_eq!(table.options.space_after, 20.0); + assert_eq!(table.cell(1, 1).unwrap().strokes[0].weight, 0.0); + assert_eq!(table.cell(1, 1).unwrap().strokes[0].color, "Brand"); + let missing = designcraft_doc::resolve_cell_style(&d.styles.cell, "Missing"); + assert_eq!(missing.inset_overrides[1], Some(0.0)); +} + +#[test] +fn table_local_alternating_fill_off_survives_named_style_roundtrip() { + let styles = CASCADE_STYLES.replace( + "Name=\"Table\" SpaceBefore", + "Name=\"Table\" StartColumnFillCount=\"1\" EndColumnFillCount=\"1\" StartColumnFillColor=\"Color/Brand\" SpaceBefore", + ); + let story = CASCADE_STORY.replace("HeaderRowCount=\"1\"", "HeaderRowCount=\"1\" StartRowFillCount=\"0\" StartColumnFillCount=\"0\""); + let document = import_idml(&fixture_with_table_styles(&styles, &story)).unwrap(); + for document in [&document, &import_idml(&export_idml(&document)).unwrap()] { + let table = document.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert!(table.options.alt_rows.is_none()); + assert!(table.options.alt_cols.is_none()); + } +} + +#[test] +fn table_local_fill_counts_enable_inherited_disabled_pattern_colors() { + let story = + CASCADE_STORY.replace("AppliedTableStyle=\"ts-child\"", "AppliedTableStyle=\"ts-base\" StartRowFillCount=\"1\" EndRowFillCount=\"1\""); + let document = import_idml(&fixture_with_table_styles(CASCADE_STYLES, &story)).unwrap(); + let table = document.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + let fills = table.options.alt_rows.as_ref().unwrap(); + assert_eq!((fills.first, fills.next), (1, 1)); + assert_eq!(fills.first_color, "Brand"); + assert_eq!(fills.first_tint, 0.2); +} + +#[test] +fn table_absent_and_explicit_default_edges_remain_distinct_on_idml_roundtrip() { + let story = CASCADE_STORY + .replace("AppliedTableStyle=\"ts-child\"", "AppliedTableStyle=\"Missing\"") + .replace("Name=\"1:1\"", "Name=\"1:1\" TopEdgeStrokeWeight=\"1\" TopEdgeStrokePriority=\"0\""); + let document = import_idml(&fixture_with_table_styles(STYLES, &story)).unwrap(); + for document in [&document, &import_idml(&export_idml(&document)).unwrap()] { + let table = document.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert_eq!(table.cell(1, 0).unwrap().stroke_defined, [false; 4]); + assert_eq!(table.cell(1, 1).unwrap().stroke_defined, [true, false, false, false]); + assert_eq!(table.cell(1, 1).unwrap().border_overrides, [false; 4]); + assert_eq!(table.cell(1, 1).unwrap().stroke_priorities, [0; 4]); + } +} + +#[test] +fn multiline_table_and_cell_style_bases_resolve() { + let styles = CASCADE_STYLES.replace(">cs-base", ">\n cs-base \n").replace(">ts-base", ">\n ts-base \n"); + let document = import_idml(&fixture_with_table_styles(&styles, CASCADE_STORY)).unwrap(); + let table = document.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert_eq!(table.cell(1, 1).unwrap().strokes[0].weight, 2.0); + assert_eq!(table.options.border_for(0).weight, 7.0); +} + +#[test] +fn legacy_native_documents_export_both_builtin_table_styles_when_missing() { + let story = CASCADE_STORY.replace("AppliedTableStyle=\"ts-child\"", "AppliedTableStyle=\"TableStyle/$ID/[Basic Table]\""); + let mut document = import_idml(&fixture_with_story(&story)).unwrap(); + document.styles_mut().table.clear(); + let bytes = export_idml(&document); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + let mut styles = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("Resources/Styles.xml").unwrap(), &mut styles).unwrap(); + assert_eq!(styles.matches("Self=\"TableStyle/$ID/[Basic Table]\"").count(), 1); + assert_eq!(styles.matches("Self=\"TableStyle/$ID/[No table style]\"").count(), 1); + let back = import_idml(&bytes).unwrap(); + assert!(back.styles.table.iter().any(|s| s.name == designcraft_doc::BASIC_TABLE)); +} + +#[test] +fn hostile_table_spans_are_clamped_before_arithmetic() { + let story = CASCADE_STORY.replace("Name=\"1:1\"", "Name=\"1:1\" RowSpan=\"inf\" ColumnSpan=\"inf\""); + let document = import_idml(&fixture_with_table_styles(CASCADE_STYLES, &story)).unwrap(); + document.check().unwrap(); + let table = document.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert_eq!(table.cell(1, 1).unwrap().row_span, 1); + assert_eq!(table.cell(1, 1).unwrap().col_span, 2); +} + +#[test] +fn signed_idml_edge_priorities_roundtrip_without_clamping() { + let story = CASCADE_STORY.replace( + "Name=\"1:1\"", + "Name=\"1:1\" TopEdgeStrokePriority=\"-5\" LeftEdgeStrokePriority=\"-2147483648\" RightEdgeStrokePriority=\"2147483647\"", + ); + let document = import_idml(&fixture_with_table_styles(CASCADE_STYLES, &story)).unwrap(); + for document in [&document, &import_idml(&export_idml(&document)).unwrap()] { + let table = document.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert_eq!(table.cell(1, 1).unwrap().stroke_priorities, [-5, i32::MIN, 0, i32::MAX]); + } +} + +#[test] +fn alternating_table_strokes_inherit_and_roundtrip_without_flattening_cell_edges() { + let styles = CASCADE_STYLES.replace( + "Name=\"BaseTable\"", + "Name=\"BaseTable\" StartRowStrokeCount=\"1\" EndRowStrokeCount=\"1\" StartRowStrokeWeight=\"0\" EndRowStrokeWeight=\"0\" StartRowStrokeColor=\"Color/Paper\" EndRowStrokeColor=\"Color/Brand\" StartColumnStrokeCount=\"2\" EndColumnStrokeCount=\"1\" StartColumnStrokeWeight=\"1\" EndColumnStrokeWeight=\"2\" EndColumnLineStyle=\"StrokeStyle/$ID/Dotted\" SkipFirstAlternatingStrokeColumns=\"1\"", + ).replace("Name=\"Table\"", "Name=\"Table\" StartRowStrokeTint=\"25\""); + let story = CASCADE_STORY.replace("HeaderRowCount=\"1\"", "HeaderRowCount=\"1\" EndRowStrokeColor=\"Swatch/None\""); + let document = import_idml(&fixture_with_table_styles(&styles, &story)).unwrap(); + let before = document.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + let row = before.options.row_strokes.as_ref().unwrap(); + assert_eq!((row.first, row.next), (1, 1)); + assert_eq!(row.first_stroke.tint, 0.25); + assert_eq!(row.first_stroke.color, designcraft_color::swatch::PAPER); + assert_eq!(row.next_stroke.color, designcraft_color::swatch::NONE); + assert!(!row.uniform_stroke().unwrap().is_visible(), "zero patterns are uniform even when their colors differ"); + let column = before.options.column_strokes.as_ref().unwrap(); + assert_eq!((column.first, column.next, column.skip_first), (2, 1, 1)); + assert_eq!(column.next_stroke.kind, designcraft_doc::StrokeType::Dotted); + assert!(column.uniform_stroke().is_none(), "nonuniform/skipped patterns are preserved without guessing their phase"); + assert_eq!(before.cell(0, 0).unwrap().strokes[2].weight, 6.0, "cell styles remain higher-precedence edges"); + let back = import_idml(&export_idml(&document)).unwrap(); + let after = back.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert_eq!(after.options.row_strokes, before.options.row_strokes); + assert_eq!(after.options.column_strokes, before.options.column_strokes); + let style = back.styles.table.iter().find(|s| s.name == "Table").unwrap(); + assert_eq!(style.row_strokes.first_stroke.weight, None); + assert_eq!(style.row_strokes.first_stroke.tint, Some(0.25)); +} + +#[test] +fn alternating_table_strokes_local_off_overrides_named_style_on_roundtrip() { + let styles = CASCADE_STYLES.replace( + "Name=\"Table\"", + "Name=\"Table\" StartRowStrokeCount=\"1\" EndRowStrokeCount=\"1\" StartColumnStrokeCount=\"1\" EndColumnStrokeCount=\"1\"", + ); + let story = CASCADE_STORY.replace( + "HeaderRowCount=\"1\"", + "HeaderRowCount=\"1\" StartRowStrokeCount=\"0\" EndRowStrokeCount=\"0\" StartRowStrokeWeight=\"4\" EndRowStrokeColor=\"Color/Brand\" StartColumnStrokeCount=\"0\" EndColumnStrokeCount=\"0\" SkipFirstAlternatingStrokeColumns=\"3\" EndColumnLineStyle=\"StrokeStyle/$ID/Dotted\"", + ); + let document = import_idml(&fixture_with_table_styles(&styles, &story)).unwrap(); + for document in [&document, &import_idml(&export_idml(&document)).unwrap()] { + let table = document.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + let row = table.options.row_strokes.as_ref().unwrap(); + let column = table.options.column_strokes.as_ref().unwrap(); + assert_eq!((row.first, row.next, column.first, column.next), (0, 0, 0, 0)); + assert!(row.uniform_stroke().is_none()); + assert!(column.uniform_stroke().is_none()); + assert_eq!(row.first_stroke.weight, 4.0); + assert_eq!(row.next_stroke.color, "Brand"); + assert_eq!(column.skip_first, 3); + assert_eq!(column.next_stroke.kind, designcraft_doc::StrokeType::Dotted); + } +} + +#[test] +fn complete_uniform_native_style_fields_keep_legacy_identity_on_idml_roundtrip() { + let mut document = import_idml(&fixture()).unwrap(); + let stroke = designcraft_doc::CellStroke { weight: 3.0, tint: 0.5, ..Default::default() }; + document.styles_mut().cell.push(designcraft_doc::CellStyle { + name: "Uniform".into(), + insets: Some([5.0; 4]), + stroke: Some(stroke.clone()), + ..Default::default() + }); + document.styles_mut().table.push(designcraft_doc::TableStyle { name: "Uniform".into(), border: Some(stroke.clone()), ..Default::default() }); + let back = import_idml(&export_idml(&document)).unwrap(); + let cell = back.styles.cell.iter().find(|s| s.name == "Uniform").unwrap(); + assert_eq!(cell.insets, Some([5.0; 4])); + assert_eq!(cell.stroke.as_ref(), Some(&stroke)); + assert!(cell.strokes.iter().all(designcraft_doc::CellStrokeAttrs::is_empty)); + let table = back.styles.table.iter().find(|s| s.name == "Uniform").unwrap(); + assert_eq!(table.border.as_ref(), Some(&stroke)); + assert!(table.borders.iter().all(designcraft_doc::CellStrokeAttrs::is_empty)); +} #[test] fn imports_column_rules_from_frames_and_object_styles() { diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 3e9e28c..cebd620 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -171,11 +171,10 @@ pub fn check(crates: &[Crate]) -> Vec { match classify(&d.name) { // Unregistered deps are reported on their own entry. None => {} - Some(Class::Testkit) => { - if d.kind != DepKind::Dev { - out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }); - } + Some(Class::Testkit) if d.kind != DepKind::Dev => { + out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }); } + Some(Class::Testkit) => {} Some(dc) => { let to = dc.layer().unwrap_or(u8::MAX); if to >= layer && !(to == layer && intra_layer_allowed(&c.name, &d.name)) {