From 20d4178c43423de8547d7d80ac74bced1c9992fd Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 20:04:57 +0000 Subject: [PATCH 1/3] Tables: cell edge gap colour and tint, all stroke types on cell edges, inner horizontal/vertical edge choice CellStroke and cell/table style edge attributes carry gap colour and gap tint (IDML ...EdgeStrokeGapColor/GapTint, ...BorderStrokeGapColor/GapTint). The table renderer and PDF export draw every stroke type on cell edges, with the gap colour under dashes, dots and stripes. table.setCell validates stroke values, takes gapColor/gapTint and an edges list (top/left/bottom/right are the target range's outer sides; innerHorizontal and innerVertical the lines between its rows and columns). table.getCellStroke reports the shown values of the chosen edges, null where they differ. --- crates/doc/src/styles.rs | 32 +++- crates/doc/src/table.rs | 21 ++- crates/engine/src/cmd/table.rs | 303 ++++++++++++++++++++++++++----- crates/engine/src/tests_table.rs | 38 ++++ crates/idml/src/export.rs | 10 + crates/idml/src/import.rs | 12 +- crates/idml/src/tests.rs | 29 +++ crates/pdf/src/text.rs | 36 +++- crates/render/src/lib.rs | 30 +++ crates/render/src/text.rs | 29 ++- 10 files changed, 476 insertions(+), 64 deletions(-) diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index e348ff295..b6ced0d59 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -163,11 +163,20 @@ pub struct CellStrokeAttrs { pub tint: Option, #[serde(skip_serializing_if = "Option::is_none")] pub kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gap_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gap_tint: 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() + self.weight.is_none() + && self.color.is_none() + && self.tint.is_none() + && self.kind.is_none() + && self.gap_color.is_none() + && self.gap_tint.is_none() } pub fn apply_to(&self, stroke: &mut crate::table::CellStroke) { @@ -183,6 +192,12 @@ impl CellStrokeAttrs { if let Some(v) = &self.kind { stroke.kind = v.clone(); } + if let Some(v) = &self.gap_color { + stroke.gap_color = v.clone(); + } + if let Some(v) = self.gap_tint { + stroke.gap_tint = v; + } } fn merge(&mut self, other: &Self) { @@ -198,10 +213,23 @@ impl CellStrokeAttrs { if other.kind.is_some() { self.kind = other.kind.clone(); } + if other.gap_color.is_some() { + self.gap_color = other.gap_color.clone(); + } + if other.gap_tint.is_some() { + self.gap_tint = other.gap_tint; + } } 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()) } + Self { + weight: Some(stroke.weight), + color: Some(stroke.color.clone()), + tint: Some(stroke.tint), + kind: Some(stroke.kind.clone()), + gap_color: Some(stroke.gap_color.clone()), + gap_tint: Some(stroke.gap_tint), + } } } diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 7fe385e98..8752075c8 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -62,11 +62,30 @@ pub struct CellStroke { pub color: String, pub tint: f32, pub kind: StrokeType, + /// Painted in the gaps of dashed, dotted and striped types. + #[serde(skip_serializing_if = "is_no_swatch")] + pub gap_color: String, + #[serde(skip_serializing_if = "is_full")] + pub gap_tint: f32, +} + +fn is_no_swatch(s: &str) -> bool { + s == designcraft_color::swatch::NONE +} +fn is_full(t: &f32) -> bool { + *t == 1.0 } impl Default for CellStroke { fn default() -> Self { - CellStroke { weight: 1.0, color: designcraft_color::swatch::BLACK.into(), tint: 1.0, kind: StrokeType::Solid } + CellStroke { + weight: 1.0, + color: designcraft_color::swatch::BLACK.into(), + tint: 1.0, + kind: StrokeType::Solid, + gap_color: designcraft_color::swatch::NONE.into(), + gap_tint: 1.0, + } } } diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 028971e97..01cd5fb46 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -196,10 +196,19 @@ fn table_specs() -> Vec { "Cell Options", [], None, - "{fill?: swatch, tint?, insets?: n | [t,l,b,r], vj?: top|center|bottom|justify, text?, stroke?: {weight?, color?, tint?, type?: solid|dashed|dotted, edges?: all|outer|inner|top|left|bottom|right}}", + "{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj?: top|center|bottom|justify, text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns", in_table, set_cell ), + cmd!( + query "table.getCellStroke", + "Get Cell Stroke", + [], + None, + "{story?, table?, rows?, cols?, edges?} → {weight, color, tint, type, gapColor, gapTint, fill, fillTint, edges: count} of the chosen edges of the target cells; a value that differs between them is null", + in_table_or_ids, + get_cell_stroke + ), cmd!("table.setRowHeight", "Row Height", [], None, "{height, mode?: atLeast|exactly}", in_table, set_row_height), cmd!("table.setColumnWidth", "Column Width", [], None, "{width}", in_table, set_col_width), cmd!("table.distributeColumns", "Distribute Columns Evenly", ["Table"], None, "{}", in_table, distribute_cols), @@ -217,7 +226,7 @@ fn table_specs() -> Vec { "Table Options", [], None, - "{direction?: leftToRight|rightToLeft, border?: {weight?, color?, tint?, type?}, spaceBefore?, spaceAfter?, headerRows?, footerRows?, repeatHeader?, repeatFooter?, altRows?: {first, firstColor, firstTint, next, nextColor, nextTint, skipFirst, skipLast} | null, altCols?: … | null}", + "{direction?: leftToRight|rightToLeft, border?: {weight?, color?, tint?, type?, gapColor?, gapTint?}, spaceBefore?, spaceAfter?, headerRows?, footerRows?, repeatHeader?, repeatFooter?, altRows?: {first, firstColor, firstTint, next, nextColor, nextTint, skipFirst, skipLast} | null, altCols?: … | null}", in_table, options ), @@ -606,29 +615,174 @@ fn unmerge(s: &mut Session, p: &Value) -> Result { }) } -fn parse_stroke(v: &Value, mut base: CellStroke) -> CellStroke { - if let Some(w) = v.get("weight").and_then(Value::as_f64) { - base.weight = w.max(0.0); +/// Cell edges chosen for a stroke edit: the target range's four outer sides (physical left and +/// right) and the lines between its rows and columns. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct EdgeSel { + top: bool, + left: bool, + bottom: bool, + right: bool, + inner_h: bool, + inner_v: bool, +} + +impl EdgeSel { + const ALL: EdgeSel = EdgeSel { top: true, left: true, bottom: true, right: true, inner_h: true, inner_v: true }; + + fn parse(v: Option<&Value>, cmd: &str) -> Result { + let names: Vec<&Value> = match v { + None | Some(Value::Null) => return Ok(EdgeSel::ALL), + Some(Value::Array(a)) => a.iter().collect(), + Some(v) => vec![v], + }; + let mut e = EdgeSel::default(); + for n in names { + match n.as_str() { + Some("all") => e = EdgeSel::ALL, + Some("outer") => (e.top, e.left, e.bottom, e.right) = (true, true, true, true), + Some("inner") => (e.inner_h, e.inner_v) = (true, true), + Some("top") => e.top = true, + Some("left") => e.left = true, + Some("bottom") => e.bottom = true, + Some("right") => e.right = true, + Some("innerHorizontal") => e.inner_h = true, + Some("innerVertical") => e.inner_v = true, + _ => return Err(bad(cmd, format!("unknown edge {n}; use all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical"))), + } + } + if e == EdgeSel::default() { + return Err(bad(cmd, "no edges chosen")); + } + Ok(e) } - if let Some(c) = color_param(v, "color") { - base.color = c; + + /// Which sides (top, left, bottom, right) of the cell at `(r, c)` with `spans` are chosen + /// within `rg`. Left and right are physical sides, also in RTL tables. + fn sides(&self, rg: CellRange, (r, c): (usize, usize), spans: (u32, u32), rtl: bool) -> [bool; 4] { + let last_row = r.saturating_add((spans.0.max(1) - 1) as usize); + let last_col = c.saturating_add((spans.1.max(1) - 1) as usize); + let (start, end) = (c <= rg.c0, last_col >= rg.c1); + let (left_outer, right_outer) = if rtl { (end, start) } else { (start, end) }; + [ + if r <= rg.r0 { self.top } else { self.inner_h }, + if left_outer { self.left } else { self.inner_v }, + if last_row >= rg.r1 { self.bottom } else { self.inner_h }, + if right_outer { self.right } else { self.inner_v }, + ] } - if let Some(t) = v.get("tint").and_then(Value::as_f64) { - base.tint = t.clamp(0.0, 1.0) as f32; +} + +/// A finite number in `lo..=hi`, or an error naming `key`. +fn num_in(v: &Value, key: &str, lo: f64, hi: f64, cmd: &str) -> Result> { + match v.get(key) { + None | Some(Value::Null) => Ok(None), + Some(x) => match x.as_f64() { + Some(n) if n.is_finite() && (lo..=hi).contains(&n) => Ok(Some(n)), + _ => Err(bad(cmd, format!("{key} must be a number from {lo} to {hi}"))), + }, } - match str_param(v, "type") { - Some("dashed") => base.kind = StrokeType::Dashed { pattern: vec![base.weight.max(1.0) * 3.0, base.weight.max(1.0) * 2.0] }, - Some("dotted") => base.kind = StrokeType::Dotted, - Some("solid") => base.kind = StrokeType::Solid, - _ => {} +} + +/// A swatch of the document (or [None]), or an error naming `key`. +fn swatch_in(doc: &Document, v: &Value, keys: &[&str], cmd: &str) -> Result> { + let Some((key, x)) = keys.iter().find_map(|k| v.get(*k).filter(|x| !x.is_null()).map(|x| (*k, x))) else { return Ok(None) }; + let name = x.as_str().ok_or_else(|| bad(cmd, format!("{key} must be a swatch name")))?; + if name != designcraft_color::swatch::NONE && doc.swatch(name).is_none() { + return Err(bad(cmd, format!("no swatch `{name}`"))); } - base + Ok(Some(name.to_string())) +} + +/// The cell stroke type names of `type`. +const CELL_STROKE_TYPES: &[&str] = + &["solid", "dashed", "dotted", "thickThin", "thinThick", "thinThin", "thickThick", "thinThickThin", "thickThinThick", "wavy", "hashed"]; + +/// The attributes a stroke edit sets. A plain `"dashed"` gets a pattern scaled to the +/// resulting weight of each edge. +struct StrokeEdit { + attrs: designcraft_doc::CellStrokeAttrs, + scaled_dashes: bool, +} + +impl StrokeEdit { + fn parse(doc: &Document, v: &Value, cmd: &str) -> Result { + if !v.is_object() { + return Err(bad(cmd, "stroke must be an object")); + } + let mut attrs = designcraft_doc::CellStrokeAttrs { + weight: num_in(v, "weight", 0.0, 1000.0, cmd)?, + color: swatch_in(doc, v, &["color"], cmd)?, + tint: num_in(v, "tint", 0.0, 1.0, cmd)?.map(|t| t as f32), + gap_color: swatch_in(doc, v, &["gapColor", "gapSwatch"], cmd)?, + gap_tint: num_in(v, "gapTint", 0.0, 1.0, cmd)?.map(|t| t as f32), + kind: None, + }; + let mut scaled_dashes = false; + match v.get("type") { + None | Some(Value::Null) => {} + Some(Value::String(name)) if name == "dashed" => { + scaled_dashes = true; + attrs.kind = Some(StrokeType::Dashed { pattern: Vec::new() }); + } + Some(Value::String(name)) if CELL_STROKE_TYPES.contains(&name.as_str()) => { + attrs.kind = Some(serde_json::from_value(json!({"kind": name})).map_err(|e| bad(cmd, e.to_string()))?); + } + Some(t @ Value::Object(_)) => { + let kind: StrokeType = serde_json::from_value(t.clone()).map_err(|e| bad(cmd, format!("bad stroke type: {e}")))?; + let finite = |v: &[f64]| v.len() <= 64 && v.iter().all(|x| x.is_finite() && *x >= 0.0); + match &kind { + StrokeType::Dashed { pattern } if !finite(pattern) || pattern.iter().all(|x| *x == 0.0) => { + return Err(bad(cmd, "a dash pattern needs up to 64 non-negative lengths, not all zero")); + } + StrokeType::Stripes { bands } if bands.len() > 64 || !bands.iter().all(|(a, b)| finite(&[*a, *b])) => { + return Err(bad(cmd, "stripes need up to 64 bands of non-negative numbers")); + } + StrokeType::Style { name } if !doc.stroke_styles.iter().any(|s| s.name == *name) => { + return Err(bad(cmd, format!("no stroke style `{name}`"))); + } + _ => {} + } + attrs.kind = Some(kind); + } + Some(other) => return Err(bad(cmd, format!("unknown stroke type {other}"))), + } + Ok(StrokeEdit { attrs, scaled_dashes }) + } + + fn apply(&self, mut base: CellStroke) -> CellStroke { + self.attrs.apply_to(&mut base); + if self.scaled_dashes { + let w = base.weight.max(1.0); + base.kind = StrokeType::Dashed { pattern: vec![w * 3.0, w * 2.0] }; + } + base + } +} + +/// The stroke an edge shows: the table border on an unoverridden perimeter edge, else the cell's. +fn shown_edge<'a>(cell: &'a designcraft_doc::Cell, side: usize, perimeter: bool, borders: &'a [CellStroke; 4]) -> &'a CellStroke { + match borders.get(side) { + Some(border) if perimeter && !cell.border_overrides.get(side).copied().unwrap_or(false) => border, + _ => &cell.strokes[side.min(3)], + } +} + +/// Whether each side (top, left, bottom, right; physical) of the cell is on the table's perimeter. +fn perimeter(t: &Table, (r, c): (usize, usize), cell: &designcraft_doc::Cell) -> [bool; 4] { + let (nr, nc) = (t.nrows(), t.ncols()); + let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft; + 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); + [r == 0, if rtl { at_right >= nc } else { c == 0 }, at_bottom >= nr, if rtl { c == 0 } else { at_right >= nc }] } fn set_cell(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.setCell")?; - let fill = color_param(p, "fill"); - let tint = p.get("tint").and_then(Value::as_f64).map(|t| t.clamp(0.0, 1.0) as f32); + const ID: &str = "table.setCell"; + let g = target(s, p, ID)?; + let doc = &s.doc()?.doc; + let fill = swatch_in(doc, p, &["fill"], ID)?; + let tint = num_in(p, "tint", 0.0, 1.0, ID)?.map(|t| t as f32); let insets: Option<[f64; 4]> = match p.get("insets") { Some(Value::Number(n)) => n.as_f64().map(|v| [v.max(0.0); 4]), Some(Value::Array(a)) if a.len() == 4 => { @@ -642,12 +796,15 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { Some("center") => Some(VerticalJustification::Center), Some("bottom") => Some(VerticalJustification::Bottom), Some("justify") => Some(VerticalJustification::Justify), - Some(o) => return Err(bad("table.setCell", format!("unknown vj `{o}`"))), + Some(o) => return Err(bad(ID, format!("unknown vj `{o}`"))), None => None, }; let text = str_param(p, "text").map(str::to_string); - let stroke = p.get("stroke").cloned(); - edit_table(s, &g, "table.setCell", |t| { + let stroke = match p.get("stroke").filter(|v| !v.is_null()) { + Some(sv) => Some((StrokeEdit::parse(doc, sv, ID)?, EdgeSel::parse(sv.get("edges"), ID)?)), + None => None, + }; + edit_table(s, &g, ID, |t| { let owners = t.owners(); let nc = t.ncols(); let nr = t.nrows(); @@ -658,9 +815,13 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { let rg = g.range; for r in rg.r0..=rg.r1 { for c in rg.c0..=rg.c1 { - if owners[r * nc + c] != (r, c) { + if owners.get(r * nc + c) != Some(&(r, c)) { continue; } + let on_perimeter = match t.cell(r, c) { + Some(cell) => perimeter(t, (r, c), cell), + None => continue, + }; let Some(cell) = t.cell_mut(r, c) else { continue }; if let Some(f) = &fill { cell.fill = f.clone(); @@ -678,24 +839,12 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { let len = cell.text.len(); cell.text.replace(0..len, tx); } - 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", 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 let Some((edit, edges)) = &stroke { + let chosen = edges.sides(rg, (r, c), (cell.row_span, cell.col_span), rtl); + for (i, on) in chosen.into_iter().enumerate() { if on { - let base = if perimeter[i] && !cell.border_overrides[i] { &borders[i] } else { &cell.strokes[i] }; - cell.strokes[i] = parse_stroke(sv, base.clone()); + let base = shown_edge(cell, i, on_perimeter[i], &borders).clone(); + cell.strokes[i] = edit.apply(base); cell.stroke_defined[i] = true; cell.stroke_priorities[i] = priority; cell.border_overrides[i] = true; @@ -706,29 +855,27 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { } // 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"); - let selected = |side: &str| edges == "all" || edges == "outer" || edges == side; - if selected("top") + if let Some((_, edges)) = &stroke { + if edges.top && let Some(row) = rg.r0.checked_sub(1) { for c in rg.c0..=rg.c1 { mirror_cell_edge(t, &owners, (rg.r0, c), 0, (row, c), 2); } } - if selected("bottom") && rg.r1.saturating_add(1) < nr { + if edges.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" }) + if (if rtl { edges.right } else { edges.left }) && let Some(col) = rg.c0.checked_sub(1) { for r in rg.r0..=rg.r1 { 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 { + if (if rtl { edges.left } else { edges.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 }); } @@ -738,6 +885,62 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { }) } +/// The shown stroke attributes of the chosen edges of the target cells, and their fill; values +/// that differ are null. +fn get_cell_stroke(s: &mut Session, p: &Value) -> Result { + const ID: &str = "table.getCellStroke"; + let g = target(s, p, ID)?; + let edges = EdgeSel::parse(p.get("edges"), ID)?; + let st = s.doc()?; + let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; + let owners = t.owners(); + let nc = t.ncols(); + let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft; + let borders: [CellStroke; 4] = std::array::from_fn(|side| t.options.border_for(side).clone()); + // One value per key, or null once two edges (cells) disagree. + fn merge(slot: &mut Option, v: Value) { + match slot { + None => *slot = Some(v), + Some(cur) if *cur != v => *cur = Value::Null, + _ => {} + } + } + let keys = ["weight", "color", "tint", "type", "gapColor", "gapTint"]; + let mut stroke: [Option; 6] = Default::default(); + let (mut fill, mut fill_tint) = (None, None); + let mut count = 0usize; + for r in g.range.r0..=g.range.r1 { + for c in g.range.c0..=g.range.c1 { + if owners.get(r * nc + c) != Some(&(r, c)) { + continue; + } + let Some(cell) = t.cell(r, c) else { continue }; + merge(&mut fill, json!(cell.fill)); + merge(&mut fill_tint, json!(cell.fill_tint)); + let on_perimeter = perimeter(t, (r, c), cell); + for (i, on) in edges.sides(g.range, (r, c), (cell.row_span, cell.col_span), rtl).into_iter().enumerate() { + if !on { + continue; + } + count += 1; + let e = shown_edge(cell, i, on_perimeter[i], &borders); + let values = [json!(e.weight), json!(e.color), json!(e.tint), json!(e.kind), json!(e.gap_color), json!(e.gap_tint)]; + for (slot, v) in stroke.iter_mut().zip(values) { + merge(slot, v); + } + } + } + } + let mut out = serde_json::Map::new(); + for (k, v) in keys.iter().zip(stroke) { + out.insert((*k).into(), v.unwrap_or(Value::Null)); + } + out.insert("fill".into(), fill.unwrap_or(Value::Null)); + out.insert("fillTint".into(), fill_tint.unwrap_or(Value::Null)); + out.insert("edges".into(), json!(count)); + Ok(Value::Object(out)) +} + /// 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 { @@ -907,14 +1110,18 @@ fn parse_alt(v: &Value) -> Option { fn options(s: &mut Session, p: &Value) -> Result { let g = target(s, p, "table.options")?; + let border = match p.get("border").filter(|v| !v.is_null()) { + Some(b) => Some(StrokeEdit::parse(&s.doc()?.doc, b, "table.options")?), + None => None, + }; let p = p.clone(); edit_table(s, &g, "table.options", |t| { let o = &mut t.options; if let Some(v) = p.get("direction") { o.direction = serde_json::from_value(v.clone()).map_err(|e| bad("table.options", e.to_string()))?; } - if let Some(b) = p.get("border") { - o.border = parse_stroke(b, o.border.clone()); + if let Some(b) = &border { + o.border = b.apply(o.border.clone()); o.borders = Default::default(); } o.space_before = f64_or(&p, "spaceBefore", o.space_before); diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 45861347d..04620a402 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -399,3 +399,41 @@ fn editing_beside_a_merged_cell_only_changes_the_selected_edge_segment() { } } } + +#[test] +fn cell_stroke_gap_colour_on_inner_horizontal_edges_of_a_selection_undoes_and_saves() { + let (mut s, sid, _) = session_with_frame(); + let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + s.execute("table.select", &json!({"what": "table"})).unwrap(); + let before = table_for(&s, sid, tid).clone(); + let cyan = "C=100 M=0 Y=0 K=0"; + let stroke = json!({"weight": 3, "type": "dashed", "gapColor": cyan, "gapTint": 0.5, "edges": ["innerHorizontal"]}); + s.execute("table.setCell", &json!({ "stroke": stroke })).unwrap(); + let t = table_for(&s, sid, tid).clone(); + let edge = |r: usize, c: usize, side: usize| t.cell(r, c).unwrap().strokes[side].clone(); + for (r, c, side) in [(0, 0, 2), (0, 1, 2), (1, 0, 0), (1, 1, 0)] { + let e = edge(r, c, side); + assert_eq!((e.weight, e.gap_color.as_str(), e.gap_tint), (3.0, cyan, 0.5), "inner horizontal {r},{c} side {side}"); + assert!(matches!(e.kind, doc::StrokeType::Dashed { .. })); + } + for (r, c, side) in [(0, 0, 0), (0, 0, 1), (0, 0, 3), (1, 1, 2), (1, 1, 1), (0, 1, 3)] { + assert_eq!(&edge(r, c, side), &before.cell(r, c).unwrap().strokes[side], "{r},{c} side {side} keeps its stroke"); + } + // The query reports the chosen edges, and mixed values as null. + let inner = s.execute("table.getCellStroke", &json!({"edges": "innerHorizontal"})).unwrap(); + assert_eq!((inner["weight"].as_f64(), inner["gapColor"].as_str(), inner["edges"].as_u64()), (Some(3.0), Some(cyan), Some(4))); + let all = s.execute("table.getCellStroke", &json!({})).unwrap(); + assert!(all["weight"].is_null() && all["gapColor"].is_null()); + // Bad values are errors and change nothing. + for bad in + [json!({"gapTint": 2}), json!({"gapColor": "No Such Swatch"}), json!({"weight": -1}), json!({"type": "zigzag"}), json!({"edges": "middle"})] + { + assert!(s.execute("table.setCell", &json!({ "stroke": bad })).is_err(), "{bad}"); + assert_eq!(table_for(&s, sid, tid), &t); + } + 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(), &t); + 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 9d73562b9..8afcf714f 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -2192,6 +2192,10 @@ impl<'a> Ex<'a> { el.set(&format!("{prefix}StrokeColor"), self.sw(&s.color)); el.set(&format!("{prefix}StrokeTint"), pct(s.tint as f64)); el.set(&format!("{prefix}StrokeType"), names::stroke_type_out(&s.kind)); + if s.gap_color != designcraft_color::swatch::NONE || s.gap_tint != 1.0 { + el.set(&format!("{prefix}StrokeGapColor"), self.sw(&s.gap_color)); + el.set(&format!("{prefix}StrokeGapTint"), pct(s.gap_tint as f64)); + } } fn partial_cell_stroke_attrs(&self, el: &mut El, prefix: &str, attrs: &designcraft_doc::CellStrokeAttrs) { @@ -2207,6 +2211,12 @@ impl<'a> Ex<'a> { if let Some(value) = &attrs.kind { el.set(&format!("{prefix}StrokeType"), names::stroke_type_out(value)); } + if let Some(value) = &attrs.gap_color { + el.set(&format!("{prefix}StrokeGapColor"), self.sw(value)); + } + if let Some(value) = attrs.gap_tint { + el.set(&format!("{prefix}StrokeGapTint"), pct(value as f64)); + } } fn partial_alt_fill_attrs(&self, el: &mut El, kind: &str, attrs: &designcraft_doc::AltFillsAttrs) { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index d85d9ee02..1ba4decf7 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -26,7 +26,15 @@ fn uniform_cell_stroke(edges: &[designcraft_doc::CellStrokeAttrs; 4]) -> Option< 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()? }) + let mut stroke = designcraft_doc::CellStroke { + weight: first.weight?, + color: first.color.clone()?, + tint: first.tint?, + kind: first.kind.clone()?, + ..Default::default() + }; + first.apply_to(&mut stroke); + Some(stroke) } /// Import an IDML package. Linked images are read from disk when available (not on wasm). @@ -1427,6 +1435,8 @@ impl<'r> Importer<'r> { 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)), + gap_color: el.prop(&format!("{prefix}StrokeGapColor")).map(|v| self.swatch_ref(&v)), + gap_tint: tint(el.num(&format!("{prefix}StrokeGapTint"))), } } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 7b747587a..3d52def24 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1399,3 +1399,32 @@ fn list_numbering_format_expression_and_bullet_import_and_round_trip() { check(&d); check(&import_idml(&export_idml(&d)).unwrap()); } + +#[test] +fn cell_edge_gap_colours_and_tints_import_and_round_trip_per_edge() { + let styles = CASCADE_STYLES.replace( + r#"TopEdgeStrokeType="StrokeStyle/$ID/Dashed""#, + r#"TopEdgeStrokeType="StrokeStyle/$ID/Dashed" TopEdgeStrokeGapColor="Color/Paper" TopEdgeStrokeGapTint="60" BottomEdgeStrokeGapColor="Color/Brand""#, + ); + let story = CASCADE_STORY.replace( + r#""#, + r#""#, + ); + let gaps = |d: &Document| { + let base = d.styles.cell.iter().find(|s| s.name == "Base").unwrap().strokes.clone(); + let table = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + let cell = table.cell(1, 1).unwrap().strokes.clone(); + (base.map(|e| (e.gap_color, e.gap_tint)), cell.map(|e| (e.gap_color, e.gap_tint))) + }; + let d = import_idml(&fixture_with_table_styles(&styles, &story)).unwrap(); + d.check().unwrap(); + let (style, cell) = gaps(&d); + assert_eq!(style[0], (Some("[Paper]".to_string()), Some(0.6))); + assert_eq!(style[2], (Some("Brand".to_string()), None)); + assert_eq!((style[1].clone(), style[3].clone()), ((None, None), (None, None))); + assert_eq!(cell[1], ("Brand".to_string(), 0.3), "left"); + assert_eq!(cell[3], ("[Paper]".to_string(), 1.0), "right"); + assert_eq!(cell[0].0, "[Paper]", "the Base style's top gap colour reaches the cell"); + let back = import_idml(&export_idml(&d)).unwrap(); + assert_eq!(gaps(&back), (style, cell)); +} diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index bf59efded..bcc75b28e 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -212,7 +212,37 @@ impl Exporter<'_> { for seg in &t.strokes { let st = &seg.stroke; let Some(col) = self.swatch_color(&st.color, st.tint) else { continue }; - let (cap, dash) = match &st.kind { + let kind = self.doc.stroke_kind(&st.kind); + let mut bp = BezPath::new(); + bp.move_to(seg.a); + bp.line_to(seg.b); + let Some(p) = to_path(&bp) else { continue }; + // Gap colour under dashes, dots and stripes: the whole edge, solid. + if !matches!(kind, designcraft_doc::StrokeType::Solid) + && st.gap_color != designcraft_color::swatch::NONE + && let Some(gap) = self.swatch_color(&st.gap_color, st.gap_tint) + { + s.set_fill(None); + s.set_stroke(Some(Stroke { + paint: gap.into(), + width: st.weight as f32, + line_cap: krilla::paint::LineCap::Butt, + ..Default::default() + })); + s.draw_path(&p); + s.set_stroke(None); + } + // Stripes, wavy and hash types are fills along the edge. + if let Some(o) = kind.outline(&bp, st.weight, 0.05) + && let Some(op) = to_path(&o) + { + s.set_stroke(None); + s.set_fill(Some(solid_fill(col, 1.0))); + s.draw_path(&op); + s.set_fill(None); + continue; + } + let (cap, dash) = match &kind { designcraft_doc::StrokeType::Dashed { pattern } if pattern.iter().any(|v| *v > 0.0) => { let mut pat: Vec = pattern.iter().map(|v| v.max(0.0) as f32).collect(); if pat.len() % 2 == 1 { @@ -225,10 +255,6 @@ impl Exporter<'_> { } _ => (krilla::paint::LineCap::Square, None), }; - let mut bp = BezPath::new(); - bp.move_to(seg.a); - bp.line_to(seg.b); - let Some(p) = to_path(&bp) else { continue }; s.set_fill(None); s.set_stroke(Some(Stroke { paint: col.into(), width: st.weight as f32, line_cap: cap, dash, ..Default::default() })); s.draw_path(&p); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c1b288def..a9a7b09b2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1483,6 +1483,36 @@ mod tests { assert!(p[0] > 240 && p[1] < 200, "pink on screen: {p:?}"); } + #[test] + fn table_edges_paint_gap_colour_between_dashes_and_stripes() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 336.0, 400.0), lid, "", ParaFormat::default()).unwrap(); + let mut t = designcraft_doc::Table::new(1, 1, 1, 0, 0, 200.0); + t.rows[0].height = 60.0; + t.rows[0].mode = designcraft_doc::RowHeightMode::Exactly; + let gap = |kind| designcraft_doc::CellStroke { weight: 8.0, kind, gap_color: "C=100 M=0 Y=0 K=0".into(), ..Default::default() }; + let cell = t.cell_mut(0, 0).unwrap(); + cell.strokes[0] = gap(designcraft_doc::StrokeType::Dashed { pattern: vec![12.0, 6.0] }); + cell.strokes[2] = gap(designcraft_doc::StrokeType::ThickThin); + cell.border_overrides = [true; 4]; + cell.stroke_defined = [true; 4]; + d.story_mut(sid).unwrap().insert_table(0, t); + let cache = Cache::new(); + let rect = cache.get(&d, sid, None).frames[0].tables[0].cell(0, 0).unwrap().rect; + let mut r = Renderer::new(); + r.threads = 0; + let img = r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap(); + let ink = |p: [u8; 4]| p[0] < 80 && p[2] < 80; + let cyan = |p: [u8; 4]| p[0] < 80 && p[2] > 150; + let y = rect.y0.round() as u32; + let (dash, space) = (img.pixel((rect.x0 + 6.0) as u32, y), img.pixel((rect.x0 + 15.0) as u32, y)); + assert!(ink(dash), "dash {dash:?}"); + assert!(cyan(space), "gap {space:?}"); + let column: Vec<_> = (rect.y1 as u32 - 5..=rect.y1 as u32 + 5).map(|y| img.pixel(rect.center().x as u32, y)).collect(); + assert!(column.iter().any(|p| ink(*p)) && column.iter().any(|p| cyan(*p)), "stripes and gap: {column:?}"); + } + #[test] fn renders_tables() { let mut d = Document::new(&NewDocument::default()); diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 734ec275e..7c4420727 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -447,22 +447,37 @@ impl Renderer { ctx.set_transform(f.view * xf); for s in &t.strokes { let Some(col) = doc.resolve_color(&s.stroke.color, s.stroke.tint) else { continue }; + let kind = doc.stroke_kind(&s.stroke.kind); + let line = kurbo::Line::new(s.a, s.b).to_path(0.1); + // Gap colour under dashes, dots and stripes: the whole edge, solid. + if !matches!(kind, designcraft_doc::StrokeType::Solid) + && let Some(gap) = doc.resolve_color(&s.stroke.gap_color, s.stroke.gap_tint) + { + ctx.set_paint(color_of(&gap, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(s.stroke.weight).with_caps(kurbo::Cap::Butt)); + ctx.stroke_path(&line); + } ctx.set_paint(color_of(&col, 1.0)); - ctx.set_stroke(cell_stroke(&s.stroke)); - ctx.stroke_path(&kurbo::Line::new(s.a, s.b).to_path(0.1)); + // Stripes, wavy and hash types are fills along the edge. + if let Some(o) = kind.outline(&line, s.stroke.weight, 0.05 * f.px) { + ctx.fill_path(&o); + continue; + } + ctx.set_stroke(cell_stroke(&kind, s.stroke.weight)); + ctx.stroke_path(&line); } } } } -/// Kurbo stroke for a table edge. -fn cell_stroke(s: &designcraft_doc::CellStroke) -> kurbo::Stroke { - let st = kurbo::Stroke::new(s.weight).with_caps(kurbo::Cap::Square); - match &s.kind { +/// Kurbo stroke for a table edge of a resolved stroke type. +fn cell_stroke(kind: &designcraft_doc::StrokeType, weight: f64) -> kurbo::Stroke { + let st = kurbo::Stroke::new(weight).with_caps(kurbo::Cap::Square); + match kind { designcraft_doc::StrokeType::Dashed { pattern } if !pattern.is_empty() => { st.with_caps(kurbo::Cap::Butt).with_dashes(0.0, pattern.iter().copied()) } - designcraft_doc::StrokeType::Dotted => st.with_dashes(0.0, [0.0, s.weight * 2.0]).with_caps(kurbo::Cap::Round), + designcraft_doc::StrokeType::Dotted => st.with_dashes(0.0, [0.0, weight * 2.0]).with_caps(kurbo::Cap::Round), _ => st, } } From 57d9761c69106af42e5015b485e6e24afa08c31f Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 20:16:53 +0000 Subject: [PATCH 2/3] =?UTF-8?q?Tables:=20cell=20strokes=20in=20the=20Strok?= =?UTF-8?q?e=20panel=20and=20Cell=20Options=20=E2=80=BA=20Strokes=20and=20?= =?UTF-8?q?Fills?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With cells selected, or the text cursor in a cell, the Stroke panel edits the cell edges chosen on a drawn edge proxy: weight, type, colour, tint, gap colour and gap tint. A click toggles a line, a double-click chooses all outer or all inner lines, a triple-click chooses or clears all. Table › Cell Options › Strokes and Fills… has the same proxy and fields plus the cell fill colour and tint, and applies the changed values in one undoable step. Translations for the new strings in every interface language. --- crates/ui-egui/src/dialogs.rs | 131 +++++++++++ crates/ui-egui/src/i18n.rs | 16 ++ crates/ui-egui/src/i18n/ar.rs | 9 + crates/ui-egui/src/i18n/it.rs | 9 + crates/ui-egui/src/i18n/ja.rs | 10 + crates/ui-egui/src/i18n/pt_br.rs | 9 + crates/ui-egui/src/i18n/uk.rs | 10 + crates/ui-egui/src/menus.rs | 10 + crates/ui-egui/src/panels/cell_stroke.rs | 286 +++++++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/properties.rs | 4 + docs/target-app-parity.md | 1 + 12 files changed, 496 insertions(+) create mode 100644 crates/ui-egui/src/panels/cell_stroke.rs diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2ab7a3bad..18e9e4b4d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1125,6 +1125,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "frameSize" => crate::i18n::tr(&app.ui.language, "Rectangle"), "goToPage" => crate::i18n::tr(&app.ui.language, "Go to Page"), "insertTable" => crate::i18n::tr(&app.ui.language, "Create Table"), + "cellOptions" => crate::i18n::tr(&app.ui.language, "Cell Options"), "textFrameOptions" => crate::i18n::tr(&app.ui.language, "Text Frame Options"), "documentSetup" => crate::i18n::tr(&app.ui.language, "Document Setup"), "findChange" => crate::i18n::tr(&app.ui.language, "Find/Change"), @@ -1685,6 +1686,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ui.end_row(); }); } + "cellOptions" => cell_options(app, ui, &mut d), "textFrameOptions" => { seed_text_frame_options(app, &mut d); text_frame_options_dialog(app, ui, &mut d); @@ -1741,6 +1743,99 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } } +/// Cell Options keys (`table.getCellStroke` names) and whether each belongs to the stroke. +const CELL_OPTION_KEYS: [(&str, bool); 8] = + [("weight", true), ("type", true), ("color", true), ("tint", true), ("gapColor", true), ("gapTint", true), ("fill", false), ("fillTint", false)]; + +/// Table › Cell Options › Strokes and Fills… for the target cells. The fields start from the +/// cells' values (`seed`); OK applies the fields that differ from it. +pub fn open_cell_options(app: &mut DesignApp) -> Result { + let edges = crate::panels::cell_stroke::edges_param(&[true; 6]); + let cur = app.session.execute("table.getCellStroke", &json!({"edges": edges})).map_err(|e| e.to_string())?; + let mut fields = Map::new(); + for (k, _) in CELL_OPTION_KEYS { + fields.insert(k.into(), cur[k].clone()); + } + fields.insert("seed".into(), Value::Object(fields.clone())); + fields.insert("edges".into(), edges); + app.ui.dialog = Some(Dialog::new("cellOptions", Value::Object(fields))); + Ok(Value::Null) +} + +/// Cell Options › Strokes and Fills: the edge proxy and stroke fields, then the cell fill. +fn cell_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + use crate::panels::cell_stroke; + let lang = app.ui.language.clone(); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Cell Stroke")).font(semibold(12.0))); + let mut edges = cell_stroke::edges_from(d.fields.get("edges").unwrap_or(&Value::Null)); + if cell_stroke::edge_proxy(ui, "cell_options", &mut edges, &lang) { + d.fields.insert("edges".into(), cell_stroke::edges_param(&edges)); + // Fields the user hasn't changed follow the chosen edges. + let cur = cell_stroke::current(app, &edges); + let seed = d.fields.get("seed").cloned().unwrap_or(Value::Null); + let mut next = seed.clone(); + for (k, stroke) in CELL_OPTION_KEYS { + if stroke && d.fields.get(k) == seed.get(k) { + d.fields.insert(k.into(), cur[k].clone()); + next[k] = cur[k].clone(); + } + } + d.fields.insert("seed".into(), next); + } + ui.add_space(6.0); + let values = Value::Object(d.fields.clone()); + let any = edges.iter().any(|on| *on); + if let Some((k, v)) = ui.add_enabled_ui(any, |ui| cell_stroke::stroke_fields(app, ui, "cell_options", &values)).inner { + d.fields.insert(k.into(), v); + } + ui.add_space(10.0); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Cell Fill")).font(semibold(12.0))); + egui::Grid::new("cell_fill").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&lang, "Color:")); + let mut picked = None; + crate::panels::swatch_picker(app, ui, "cell_fill_color", values["fill"].as_str().map(str::to_string), |_, n| picked = Some(n)); + if let Some(n) = picked { + d.fields.insert("fill".into(), json!(n)); + } + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&lang, "Tint:")); + let tint = values["fillTint"].as_f64().map(|t| (t * 100.0).round()); + if let Some(t) = crate::widgets::NumField::number("cell_fill_tint", tint, "%", 0).width(60.0).range(0.0, 100.0).show(ui) { + d.fields.insert("fillTint".into(), json!(t / 100.0)); + } + ui.end_row(); + }); +} + +/// Apply the Cell Options fields that differ from the cells' values, as one `table.setCell`. +fn confirm_cell_options(app: &mut DesignApp, d: &Dialog) -> Result { + let seed = d.fields.get("seed").cloned().unwrap_or(Value::Null); + let mut stroke = Map::new(); + let mut params = Map::new(); + for (k, is_stroke) in CELL_OPTION_KEYS { + let Some(v) = d.fields.get(k).filter(|v| !v.is_null() && seed.get(k) != Some(*v)) else { continue }; + match (is_stroke, k) { + (true, _) => { + stroke.insert(k.into(), v.clone()); + } + (false, "fillTint") => { + params.insert("tint".into(), v.clone()); + } + _ => { + params.insert(k.into(), v.clone()); + } + } + } + if !stroke.is_empty() { + stroke.insert("edges".into(), d.fields.get("edges").cloned().unwrap_or(json!("all"))); + params.insert("stroke".into(), Value::Object(stroke)); + } + if params.is_empty() { + return Ok(Value::Null); + } + app.run("table.setCell", Value::Object(params)) +} + /// Apply the open dialog. pub fn confirm(app: &mut DesignApp) -> Result { let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) }; @@ -1788,6 +1883,15 @@ pub fn confirm(app: &mut DesignApp) -> Result { json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64, "headerRows": d.n("headerRows").unwrap_or(0.0).max(0.0) as u64, "footerRows": d.n("footerRows").unwrap_or(0.0).max(0.0) as u64}), ), + "cellOptions" => { + // On an error the dialog stays open to be corrected. + let r = confirm_cell_options(app, &d); + if let Err(e) = &r { + app.status(e.clone()); + app.ui.dialog = Some(d); + } + r + } "textFrameOptions" => { // On an error the dialog stays open to be corrected. let mut d = d; @@ -3741,6 +3845,33 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod tests { use super::*; + #[test] + fn cell_options_dialog_draws_and_applies_only_changed_values_to_the_chosen_edges() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "caret": true})).unwrap(); + let tid = app.run("table.insert", json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + app.run("table.select", json!({"what": "table"})).unwrap(); + app.run("app.cellOptionsDialog", json!({})).unwrap(); + let ctx = dialog_context(""); + dialog_frame(&mut app, &ctx, egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 700.0)), vec![]); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!(d.id, "cellOptions"); + assert_eq!(d.fields["weight"], json!(1.0)); + d.fields.insert("edges".into(), json!(["top", "bottom", "left", "right"])); + d.fields.insert("gapColor".into(), json!("[Paper]")); + d.fields.insert("fill".into(), json!("[Black]")); + d.fields.insert("fillTint".into(), json!(0.2)); + confirm(&mut app).unwrap(); + let st = app.session.active().unwrap(); + let t = st.doc.stories.values().find_map(|s| s.tables.get(&tid)).unwrap(); + let gap = |r: usize, c: usize, side: usize| t.cell(r, c).unwrap().strokes[side].gap_color.clone(); + assert_eq!([gap(0, 0, 0), gap(0, 0, 1), gap(1, 1, 2), gap(1, 1, 3)], ["[Paper]"; 4].map(String::from), "outer edges"); + assert_eq!([gap(0, 0, 2), gap(0, 0, 3)], ["[None]"; 2].map(String::from), "inner edges keep theirs"); + assert!(t.cells.iter().all(|c| c.fill == "[Black]" && (c.fill_tint - 0.2).abs() < 1e-6)); + assert!(t.cells.iter().all(|c| c.strokes.iter().all(|e| e.weight == 1.0)), "unchanged values are not applied"); + } + fn dialog_context(language: &str) -> egui::Context { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx, language); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e5e8a8d08..85cc122fc 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2811,6 +2811,22 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Rule Below", ["Linie unterhalb", "Filet après", "Filete después", "後境界線", "下方横线"]), ("Tint:", ["Farbton:", "Teinte:", "Matiz:", "濃淡:", "色调:"]), ("Column", ["Spalte", "Colonne", "Columna", "段", "栏"]), + // Cell strokes. + ("Gap Tint:", ["Lückenfarbton:", "Teinte de l'espace:", "Matiz del hueco:", "すき間の濃淡:", "间隙色调:"]), + ("Strokes and Fills…", ["Konturen und Flächen …", "Contours et fonds…", "Trazos y rellenos…", "線と塗り…", "描边和填色…"]), + ("Cell Stroke", ["Zellkontur", "Contour de cellule", "Trazo de celda", "セルの線", "单元格描边"]), + ("Cell Fill", ["Zellfläche", "Fond de cellule", "Relleno de celda", "セルの塗り", "单元格填色"]), + ("Cell edges", ["Zellkanten", "Bords de cellule", "Bordes de celda", "セルの境界線", "单元格边缘"]), + ( + "Click a line to include or exclude it. Double-click selects all outer or all inner lines; triple-click selects or clears all.", + [ + "Klicken Sie auf eine Linie, um sie ein- oder auszuschließen. Doppelklick wählt alle äußeren oder alle inneren Linien; Dreifachklick wählt alle aus oder hebt die Auswahl auf.", + "Cliquez sur une ligne pour l'inclure ou l'exclure. Un double-clic sélectionne toutes les lignes extérieures ou intérieures ; un triple-clic sélectionne ou désélectionne tout.", + "Haga clic en una línea para incluirla o excluirla. Doble clic selecciona todas las líneas exteriores o interiores; triple clic selecciona o borra todas.", + "線をクリックすると、含めるか除外するかを切り替えます。ダブルクリックで外側または内側の線をすべて選択し、トリプルクリックですべてを選択または解除します。", + "单击线条可将其包含或排除。双击选择所有外侧或所有内侧线条;三击选择或清除全部。", + ], + ), ]; /// Interface direction; independent of document binding and paragraph direction. diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 10e393cd2..663ededfa 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1469,4 +1469,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Rules…", "خطوط الفقرة…"), ("Paragraph Rules", "خطوط الفقرة"), ("Rule Below", "خط سفلي"), + ("Gap Tint:", "صبغة الفجوة:"), + ("Strokes and Fills…", "الحدود والتعبئة…"), + ("Cell Stroke", "حد الخلية"), + ("Cell Fill", "تعبئة الخلية"), + ("Cell edges", "حواف الخلية"), + ( + "Click a line to include or exclude it. Double-click selects all outer or all inner lines; triple-click selects or clears all.", + "انقر على خط لتضمينه أو استبعاده. النقر المزدوج يحدد كل الخطوط الخارجية أو كل الخطوط الداخلية؛ والنقر الثلاثي يحدد الكل أو يلغي تحديده.", + ), ]; diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index c91afd6ba..ff79159ed 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1492,4 +1492,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Rules", "Filetti paragrafo"), ("Rule Below", "Filetto sotto"), ("Tint:", "Tinta:"), + ("Gap Tint:", "Tinta spazio:"), + ("Strokes and Fills…", "Tracce e riempimenti…"), + ("Cell Stroke", "Traccia cella"), + ("Cell Fill", "Riempimento cella"), + ("Cell edges", "Bordi cella"), + ( + "Click a line to include or exclude it. Double-click selects all outer or all inner lines; triple-click selects or clears all.", + "Fai clic su una linea per includerla o escluderla. Il doppio clic seleziona tutte le linee esterne o interne; il triplo clic seleziona o deseleziona tutte.", + ), ]; diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index be6e2c4da..b927f0f48 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1670,6 +1670,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Vertical:", "垂直方向:"), ("Working CMYK", "作業用 CMYK"), ("Yellow", "イエロー"), + ("Gap Tint:", "すき間の濃淡:"), + ("Strokes and Fills…", "線と塗り…"), + ("Cell Stroke", "セルの線"), + ("Cell Fill", "セルの塗り"), + ("Cell edges", "セルの境界線"), + ( + "Click a line to include or exclude it. Double-click selects all outer or all inner lines; triple-click selects or clears all.", + "線をクリックすると、含めるか除外するかを切り替えます。ダブルクリックで外側または内側の線をすべて選択し、トリプルクリックですべてを選択または解除します。", + ), + ("Tint:", "濃淡:"), ]; #[cfg(test)] diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index d25717dc6..7ffe3986c 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1073,4 +1073,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Rules…", "Linhas de parágrafo…"), ("Paragraph Rules", "Linhas de parágrafo"), ("Rule Below", "Linha abaixo"), + ("Gap Tint:", "Matiz do espaço:"), + ("Strokes and Fills…", "Traçados e preenchimentos…"), + ("Cell Stroke", "Traçado da célula"), + ("Cell Fill", "Preenchimento da célula"), + ("Cell edges", "Bordas da célula"), + ( + "Click a line to include or exclude it. Double-click selects all outer or all inner lines; triple-click selects or clears all.", + "Clique em uma linha para incluí-la ou excluí-la. Clique duas vezes para selecionar todas as linhas externas ou internas; clique três vezes para selecionar ou limpar todas.", + ), ]; diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9a9fb89ba..b896a5759 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1455,6 +1455,16 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Selection", "Виділення"), ("All Documents", "Усі документи"), ("On", "Увімкнено"), + ("Gap Tint:", "Відтінок проміжку:"), + ("Strokes and Fills…", "Обведення та заливки…"), + ("Cell Stroke", "Обведення клітинки"), + ("Cell Fill", "Заливка клітинки"), + ("Cell edges", "Краї клітинки"), + ( + "Click a line to include or exclude it. Double-click selects all outer or all inner lines; triple-click selects or clears all.", + "Клацніть лінію, щоб увімкнути або вимкнути її. Подвійне клацання вибирає всі зовнішні або всі внутрішні лінії; потрійне — вибирає всі або знімає вибір.", + ), + ("Tint:", "Відтінок:"), ]; /// Contexts whose Ukrainian noun differs from the shared action/text-layout label. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 94e5c0971..6a462723d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -60,6 +60,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("window.resetShortcuts", "Reset Shortcuts", None, "{} — every command back to its default shortcut"), ("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"), ("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"), + ( + "app.cellOptionsDialog", + "Strokes and Fills…", + None, + "{} — Cell Options › Strokes and Fills of the target cells: edge proxy, stroke weight, type, colour, tint, gap colour, gap tint, and cell fill (table.setCell)", + ), ("app.footnoteOptionsDialog", "Document Footnote Options…", None, "{} — numbering, formatting and layout of footnotes"), ("app.rubyDialog", "Ruby…", None, "{} — the reading set over the selected text"), ( @@ -586,6 +592,9 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:table.selectTable", "<", "-", + ">Cell Options", + "ui:app.cellOptionsDialog", + "<", "cmd:table.merge", "cmd:table.unmerge", ">Convert Cell Type", @@ -779,6 +788,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option crate::dialogs::open_cell_options(app), "window.hideMenuItem" => { let Some(item) = p.get("item").and_then(Value::as_str) else { return Some(Err("missing item".into())) }; let hide = p.get("hidden").and_then(Value::as_bool).unwrap_or(true); diff --git a/crates/ui-egui/src/panels/cell_stroke.rs b/crates/ui-egui/src/panels/cell_stroke.rs new file mode 100644 index 000000000..d8b412ea2 --- /dev/null +++ b/crates/ui-egui/src/panels/cell_stroke.rs @@ -0,0 +1,286 @@ +//! Cell strokes: the edge proxy (a drawn 2 × 2 cell grid whose lines choose the cell edges a change +//! applies to) and the stroke fields shared by the Stroke panel and Cell Options › Strokes and Fills. +//! +//! The proxy's lines are the target range's top, bottom, left and right sides and the lines +//! between its rows (inner horizontal) and columns (inner vertical). A click toggles a line; a +//! double-click on a line chooses all outer or all inner lines; a triple-click chooses all lines, +//! or none when all were chosen. + +use designcraft_doc::StrokeType; +use egui::{Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::{Value, json}; + +use crate::DesignApp; +use crate::i18n::tr; +use crate::theme::Tokens; +use crate::widgets::{NumField, caption}; + +/// Edge names of the proxy lines, in proxy order; `table.setCell` takes them as `stroke.edges`. +pub const EDGE_NAMES: [&str; 6] = ["top", "bottom", "left", "right", "innerHorizontal", "innerVertical"]; + +/// Proxy line choice, in [`EDGE_NAMES`] order. +pub type Edges = [bool; 6]; + +pub const PROXY_SIZE: f32 = 64.0; +const PROXY_INSET: f32 = 8.0; + +const WEIGHTS: &[f64] = &[0.0, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 100.0]; + +/// Stroke types offered for cell edges (all of them draw on table edges). +const TYPES: &[&str] = + &["solid", "dashed", "dotted", "thickThin", "thinThick", "thinThin", "thickThick", "thinThickThin", "thickThinThick", "wavy", "hashed"]; + +/// Whether the selection is table cells (or the text cursor is in a cell). +pub fn in_cells(app: &DesignApp) -> bool { + app.session.active().is_some_and(|d| d.selection.cells.is_some() || d.selection.text.is_some_and(|t| t.cell.is_some())) +} + +/// The chosen edges as the `edges` parameter. +pub fn edges_param(edges: &Edges) -> Value { + json!(EDGE_NAMES.iter().zip(edges).filter(|(_, on)| **on).map(|(name, _)| *name).collect::>()) +} + +/// Edges from an `edges` parameter (names); anything else chooses all. +pub fn edges_from(v: &Value) -> Edges { + match v.as_array() { + Some(names) => std::array::from_fn(|i| names.iter().any(|n| n.as_str() == EDGE_NAMES.get(i).copied())), + None => [true; 6], + } +} + +/// The proxy's lines (start, end) inside its rect, in [`EDGE_NAMES`] order. +pub fn proxy_lines(r: Rect) -> [(Pos2, Pos2); 6] { + let i = r.shrink(PROXY_INSET); + let c = i.center(); + [ + (i.left_top(), i.right_top()), + (i.left_bottom(), i.right_bottom()), + (i.left_top(), i.left_bottom()), + (i.right_top(), i.right_bottom()), + (pos2(i.left(), c.y), pos2(i.right(), c.y)), + (pos2(c.x, i.top()), pos2(c.x, i.bottom())), + ] +} + +/// The line under `p`, if any is within reach. +fn line_at(r: Rect, p: Pos2) -> Option { + proxy_lines(r) + .iter() + .enumerate() + .map(|(i, (a, b))| { + let t = if a == b { 0.0 } else { ((p - *a).dot(*b - *a) / (*b - *a).length_sq()).clamp(0.0, 1.0) }; + (i, p.distance(*a + (*b - *a) * t)) + }) + .filter(|(_, d)| *d <= PROXY_INSET - 1.0) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| i) +} + +/// The edge proxy. Returns whether the choice changed. +pub fn edge_proxy(ui: &mut Ui, id: &str, edges: &mut Edges, lang: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(PROXY_SIZE, PROXY_SIZE), Sense::click()); + let label = tr(lang, "Cell edges").to_string(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, &label)); + let resp = resp.on_hover_text(tr( + lang, + "Click a line to include or exclude it. Double-click selects all outer or all inner lines; triple-click selects or clears all.", + )); + let hit = resp.interact_pointer_pos().or_else(|| resp.hover_pos()).and_then(|p| line_at(r, p)); + let mut changed = false; + if resp.clicked() { + // A double-click counts only on the line of the click before it. + let last_key = ui.id().with((id, "proxy_last")); + let last: Option = ui.data(|d| d.get_temp(last_key)).flatten(); + if resp.triple_clicked() { + let all = edges.iter().all(|on| *on); + *edges = [!all; 6]; + changed = true; + } else if let Some(h) = hit { + if resp.double_clicked() && last == Some(h) { + let outer = h < 4; + for (i, on) in edges.iter_mut().enumerate() { + *on = (i < 4) == outer; + } + } else if let Some(on) = edges.get_mut(h) { + *on = !*on; + } + changed = true; + } + ui.data_mut(|d| d.insert_temp(last_key, hit)); + } + let p = ui.painter(); + p.rect_filled(r, 0.0, t.input); + p.rect_filled(r.shrink(PROXY_INSET), 0.0, egui::Color32::from_gray(0xf2)); + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + for (i, (a, b)) in proxy_lines(r).into_iter().enumerate() { + let on = edges.get(i).copied().unwrap_or(false); + let w = if hit == Some(i) { + 3.0 + } else if on { + 2.5 + } else { + 1.0 + }; + p.line_segment([a, b], Stroke::new(w, if on { t.accent } else { egui::Color32::from_gray(0x9a) })); + } + changed +} + +/// A built-in type by its `type` parameter name. +fn builtin(kind: &str) -> StrokeType { + match kind { + "dashed" => StrokeType::Dashed { pattern: Vec::new() }, + "dotted" => StrokeType::Dotted, + "thickThin" => StrokeType::ThickThin, + "thinThick" => StrokeType::ThinThick, + "thinThin" => StrokeType::ThinThin, + "thickThick" => StrokeType::ThickThick, + "thinThickThin" => StrokeType::ThinThickThin, + "thickThinThick" => StrokeType::ThickThinThick, + "wavy" => StrokeType::Wavy, + "hashed" => StrokeType::Hashed, + _ => StrokeType::Solid, + } +} + +/// The type's menu label (named styles by name). +fn type_label(lang: &str, v: &Value) -> String { + match serde_json::from_value::(v.clone()) { + Ok(StrokeType::Style { name }) => name, + Ok(k) => tr(lang, k.label()).to_string(), + Err(_) => String::new(), + } +} + +/// Weight, type, colour, tint, gap colour and gap tint for the stroke attributes in `v` +/// (`table.getCellStroke` keys; null shows as mixed). Returns the attribute the user changed. +pub fn stroke_fields(app: &mut DesignApp, ui: &mut Ui, id: &str, v: &Value) -> Option<(&'static str, Value)> { + let lang = app.ui.language.clone(); + let custom: Vec = app.session.active().map(|d| d.doc.stroke_styles.iter().map(|x| x.name.clone()).collect()).unwrap_or_default(); + let mut out = None; + let percent = |key: &str| v[key].as_f64().map(|t| (t * 100.0).round()); + egui::Grid::new((id, "cell_stroke")).num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| { + caption(ui, tr(&lang, "Weight:")); + let wid = format!("{id}_weight"); + if let Some(w) = NumField::number(&wid, v["weight"].as_f64(), " pt", 3).width(86.0).spinner().presets(WEIGHTS).range(0.0, 1000.0).show(ui) { + out = Some(("weight", json!(w))); + } + ui.end_row(); + caption(ui, tr(&lang, "Type:")); + let shown = type_label(&lang, &v["type"]); + egui::ComboBox::from_id_salt((id, "type")).selected_text(crate::rtl::widget(ui, &shown)).width(140.0).show_ui(ui, |ui| { + for kind in TYPES { + let text = tr(&lang, builtin(kind).label()); + if ui.selectable_label(shown == text, crate::rtl::widget(ui, text)).clicked() { + out = Some(("type", json!(kind))); + } + } + if !custom.is_empty() { + ui.separator(); + } + for n in &custom { + if ui.selectable_label(shown == *n, n).clicked() { + out = Some(("type", json!({"kind": "style", "name": n}))); + } + } + }); + ui.end_row(); + for (label, key, tint_label, tint_key) in [("Color:", "color", "Tint:", "tint"), ("Gap Color:", "gapColor", "Gap Tint:", "gapTint")] { + caption(ui, tr(&lang, label)); + let mut picked = None; + super::swatch_picker(app, ui, &format!("{id}_{key}"), v[key].as_str().map(str::to_string), |_, n| picked = Some(n)); + if let Some(n) = picked { + out = Some((key, json!(n))); + } + ui.end_row(); + caption(ui, tr(&lang, tint_label)); + let tid = format!("{id}_{tint_key}"); + if let Some(t) = NumField::number(&tid, percent(tint_key), "%", 0).width(60.0).range(0.0, 100.0).show(ui) { + out = Some((tint_key, json!(t / 100.0))); + } + ui.end_row(); + } + }); + out +} + +/// The shown stroke attributes of `edges` of the target cells (null where they differ). +pub fn current(app: &mut DesignApp, edges: &Edges) -> Value { + if !edges.iter().any(|on| *on) { + return Value::Null; + } + app.session.execute("table.getCellStroke", &json!({"edges": edges_param(edges)})).unwrap_or(Value::Null) +} + +/// The Stroke panel for table cells: the proxy and the stroke fields, applied at once. +pub fn panel(app: &mut DesignApp, ui: &mut Ui) { + let key = egui::Id::new("cell_stroke_edges"); + let mut edges: Edges = ui.data(|d| d.get_temp(key)).unwrap_or([true; 6]); + let lang = app.ui.language.clone(); + if edge_proxy(ui, "stroke_panel", &mut edges, &lang) { + ui.data_mut(|d| d.insert_temp(key, edges)); + } + ui.add_space(6.0); + let values = current(app, &edges); + let any = edges.iter().any(|on| *on); + let changed = ui.add_enabled_ui(any, |ui| stroke_fields(app, ui, "csp", &values)).inner; + if let Some((k, v)) = changed + && let Err(e) = app.run("table.setCell", json!({"stroke": {k: v, "edges": edges_param(&edges)}})) + { + app.status(format!("Stroke: {e}")); + } +} + +#[cfg(test)] +mod tests { + use egui_kittest::{Harness, kittest::Queryable}; + + use super::*; + + #[test] + fn stroke_panel_proxy_limits_a_weight_change_to_the_chosen_cell_edges() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "caret": true})).unwrap(); + let tid = app.run("table.insert", json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + app.run("table.select", json!({"what": "table"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(320.0, 600.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + crate::panels::properties::stroke_panel(app, ui); + }, + app, + ); + h.run_steps(3); + // Leave only the inner horizontal line chosen: click the others off. + let lines = proxy_lines(h.get_by_label("Cell edges").rect()); + for i in [0, 1, 2, 3, 5] { + let (a, b) = lines[i]; + let at = a + (b - a) * 0.3; + h.hover_at(at); + h.drag_at(at); + h.drop_at(at); + h.run_steps(3); + } + let weight = h.get_by(|n| n.role() == egui::accesskit::Role::TextInput && n.value().as_deref() == Some("1 pt")).rect(); + h.hover_at(weight.center()); + h.drag_at(weight.center()); + h.drop_at(weight.center()); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("4".into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + let t = st.doc.stories.values().find_map(|s| s.tables.get(&tid)).unwrap(); + let w = |r: usize, c: usize, side: usize| t.cell(r, c).unwrap().strokes[side].weight; + assert_eq!([w(0, 0, 2), w(0, 1, 2), w(1, 0, 0), w(1, 1, 0)], [4.0; 4], "inner horizontal edges"); + assert_eq!([w(0, 0, 0), w(0, 0, 1), w(0, 0, 3), w(0, 1, 1), w(1, 1, 2), w(1, 1, 3)], [1.0; 6], "the other edges keep their weight"); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 05a73395b..7f4a562cc 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -1,5 +1,6 @@ //! Panels and shared panel helpers. +pub mod cell_stroke; pub mod conditions; pub mod datamerge; pub mod glyphs; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49cb920f2..32e706236 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1537,6 +1537,10 @@ pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) { /// Stroke panel: weight, cap, miter limit, join, alignment, type, arrowheads, gap colour. pub fn stroke_panel(app: &mut DesignApp, ui: &mut egui::Ui) { + if super::cell_stroke::in_cells(app) { + super::cell_stroke::panel(app, ui); + return; + } let Some(i) = sel_info(app) else { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Select an object.")); return; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8044ed51b..18909ab02 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -351,6 +351,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. - Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change. - Move table rows and columns (`table.moveRow`, `table.moveColumn`, Table panel buttons; with whole rows or columns selected, drag them with the Type tool — `table.dropCells`). +- Cell strokes: with cells selected (or the cursor in a cell) the Stroke panel edits the chosen cell edges, picked on a drawn edge proxy (top, bottom, left, right, inner horizontal, inner vertical); Table › Cell Options › Strokes and Fills… does the same with the cell fill. Weight, every stroke type, colour, tint, gap colour and gap tint draw on screen and in PDF and round-trip through IDML (`table.setCell` `stroke.edges`, `table.getCellStroke`). - Distribute Rows Evenly (`table.distributeRows`, Table menu and panel): selected rows receive equal fixed heights preserving their composed total height, with undo/redo. Row-spanning merges and rows outside the composed frames are rejected; shrinking tall cells can overset their text. - Object States (`states.create/show/rename/release/list`, Object States panel): multi-state objects show one state on screen and in PDF. - Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields too (`form.set/clear/list`: text fields, check boxes, combo and list boxes, signature fields; required, multiline, defaults) exported as AcroForm widgets through an incremental update (not in PDF/X). From cce6f986c5978e0c560fc17d5a81230ed86789ac Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 20:25:40 +0000 Subject: [PATCH 3/3] Tables: cell and table style strokes take type, gap colour and gap tint style.cell.create/edit stroke and style.table.create/edit border use the table.setCell stroke parser: type, gapColor (alias gapSwatch) and gapTint, with the same validation. A bad value is an error and changes nothing. --- crates/engine/src/cmd/table.rs | 42 +++++++++++++++----------------- crates/engine/src/tests_table.rs | 27 ++++++++++++++++++++ 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 01cd5fb46..f81cbb80f 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -29,7 +29,7 @@ fn style_specs() -> Vec { "New Cell Style…", [], None, - "{name, fromSelection?: bool (the target cell's look), fill?, tint?, insets?: n|[t,l,b,r], vj?: top|center|bottom|justify, stroke?: {weight, color, tint?}, paragraphStyle?} → {name}", + "{name, fromSelection?: bool (the target cell's look), fill?, tint?, insets?: n|[t,l,b,r], vj?: top|center|bottom|justify, stroke?: {weight?, color?, tint?, type?, gapColor?, gapTint?} (all four edges; values as table.setCell's stroke), paragraphStyle?} → {name}", has_doc, |s, p| cell_style_create(s, p) ), @@ -56,7 +56,7 @@ fn style_specs() -> Vec { "New Table Style…", [], None, - "{name, header?, body?, footer?, leftColumn?, rightColumn?: cell style names, border?: {weight, color}, altRows?: {first, firstColor, next, nextColor}, spaceBefore?, spaceAfter?} → {name}", + "{name, header?, body?, footer?, leftColumn?, rightColumn?: cell style names, border?: {weight?, color?, tint?, type?, gapColor?, gapTint?}, altRows?: {first, firstColor, next, nextColor}, spaceBefore?, spaceAfter?} → {name}", has_doc, table_style_create ), @@ -1242,23 +1242,17 @@ fn get(s: &mut Session, p: &Value) -> Result { // ---------- cell and table styles ---------- -fn stroke_param(v: Option<&Value>) -> Option { - let o = v?.as_object()?; - let mut st = designcraft_doc::CellStroke::default(); - if let Some(w) = o.get("weight").and_then(Value::as_f64) { - st.weight = w.max(0.0); +/// A style's uniform edge stroke from `stroke`/`border`: the given values over the default +/// edge, validated like `table.setCell`'s stroke. +fn stroke_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result> { + match v.filter(|v| !v.is_null()) { + Some(v) => Ok(Some(StrokeEdit::parse(&s.doc()?.doc, v, cmd)?.apply(CellStroke::default()))), + None => Ok(None), } - if let Some(c) = o.get("color").and_then(Value::as_str) { - st.color = c.to_string(); - } - if let Some(t) = o.get("tint").and_then(Value::as_f64) { - st.tint = t.clamp(0.0, 1.0) as f32; - } - Some(st) } /// Fill in the cell style fields given in `p`. -fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, cmd: &str) -> Result<()> { +fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, stroke: Option, cmd: &str) -> Result<()> { if let Some(v) = p.get("basedOn") { cs.based_on = v.as_str().map(str::to_string); } @@ -1283,7 +1277,7 @@ fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, cmd: &str) if let Some(v) = p.get("vj") { cs.vj = Some(serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("vj: {e}")))?); } - if let Some(st) = stroke_param(p.get("stroke")) { + if let Some(st) = stroke { cs.stroke = Some(st); cs.strokes = Default::default(); } @@ -1311,7 +1305,8 @@ fn cell_style_create(s: &mut Session, p: &Value) -> Result { cs.stroke = Some(cell.strokes[0].clone()); cs.paragraph_style = cell.text.paras.first().map(|p| p.style.clone()); } - cell_style_fields(&mut cs, p, "style.cell.create")?; + let stroke = stroke_param(s, p.get("stroke"), "style.cell.create")?; + cell_style_fields(&mut cs, p, stroke, "style.cell.create")?; s.edit(|d, _| { let name = designcraft_doc::Styles::unique_name(|n| d.styles.cell.iter().any(|c| c.name == n), &base); cs.name = name.clone(); @@ -1389,17 +1384,18 @@ fn dependent_styles<'a>(name: &str, styles: impl Iterator Result { let name = str_param(p, "name").ok_or_else(|| bad("style.cell.edit", "missing name"))?.to_string(); + let stroke = stroke_param(s, p.get("stroke"), "style.cell.edit")?; 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")?; + cell_style_fields(cs, &p, stroke, "style.cell.edit")?; 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) { +fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value, border: Option) { if let Some(v) = p.get("basedOn") { ts.based_on = v.as_str().map(str::to_string); } @@ -1414,7 +1410,7 @@ fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value) { *f = v.as_str().map(str::to_string); } } - if let Some(b) = stroke_param(p.get("border")) { + if let Some(b) = border { ts.border = Some(b); ts.borders = Default::default(); } @@ -1444,7 +1440,8 @@ fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value) { fn table_style_create(s: &mut Session, p: &Value) -> Result { let base = str_param(p, "name").unwrap_or("Table Style 1").to_string(); let mut ts = designcraft_doc::TableStyle::default(); - table_style_fields(&mut ts, p); + let border = stroke_param(s, p.get("border"), "style.table.create")?; + table_style_fields(&mut ts, p, border); s.edit(|d, _| { let name = designcraft_doc::Styles::unique_name(|n| d.styles.table.iter().any(|c| c.name == n), &base); ts.name = name.clone(); @@ -1470,11 +1467,12 @@ fn table_style_apply(s: &mut Session, p: &Value) -> Result { fn table_style_edit(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.table.edit", "missing name"))?.to_string(); + let border = stroke_param(s, p.get("border"), "style.table.edit")?; let p = p.clone(); s.edit(|d, _| { 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); + table_style_fields(ts, &p, border); 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(); diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 04620a402..83b4f9c97 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -437,3 +437,30 @@ fn cell_stroke_gap_colour_on_inner_horizontal_edges_of_a_selection_undoes_and_sa s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(table_for(&s, sid, tid), &before); } + +#[test] +fn cell_and_table_style_strokes_take_type_and_gap_colour_with_validation() { + let (mut s, sid, _) = session_with_frame(); + let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + let stroke = json!({"weight": 2, "type": "dotted", "gapColor": "[Paper]", "gapTint": 0.4}); + s.execute("style.cell.create", &json!({"name": "Dotted", "stroke": stroke})).unwrap(); + s.execute("style.cell.apply", &json!({"name": "Dotted", "table": tid})).unwrap(); + let edges = s.execute("table.getCellStroke", &json!({"table": tid})).unwrap(); + assert_eq!((edges["weight"].as_f64(), edges["type"]["kind"].as_str()), (Some(2.0), Some("dotted"))); + assert_eq!((edges["gapColor"].as_str(), edges["gapTint"].as_f64().map(|t| (t * 10.0).round())), (Some("[Paper]"), Some(4.0))); + s.execute("style.table.create", &json!({"name": "Framed", "border": {"type": "thickThin", "gapSwatch": "[Black]"}})).unwrap(); + let framed = s.doc().unwrap().doc.styles.table.iter().find(|t| t.name == "Framed").unwrap().border.clone().unwrap(); + assert_eq!((framed.kind, framed.gap_color.as_str()), (doc::StrokeType::ThickThin, "[Black]")); + let styles = s.doc().unwrap().doc.styles.clone(); + let table = table_for(&s, sid, tid).clone(); + for (cmd, p) in [ + ("style.cell.edit", json!({"name": "Dotted", "stroke": {"gapTint": 3}})), + ("style.cell.create", json!({"name": "Bad", "stroke": {"type": "zigzag"}})), + ("style.table.edit", json!({"name": "Framed", "border": {"gapColor": "No Such Swatch"}})), + ("style.table.create", json!({"name": "Bad", "border": {"weight": -2}})), + ] { + assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}"); + assert_eq!(s.doc().unwrap().doc.styles, styles, "{cmd} changed the styles"); + assert_eq!(table_for(&s, sid, tid), &table); + } +}