mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 11:16:50 -06:00
Merge table-cell-strokes: cell edge strokes in the Stroke panel and Cell Options, with gap colour and tint (#370)
Conflicts: ui-egui dialogs.rs (next's numberingSection arm and the branch's cellOptions arm; the branch's copies of the dialog test helpers already on next dropped, its cell options test kept); idml tests.rs and the i18n tables appended at the end on both sides (both kept; the branch's Gap Tint: and Tint: rows that next already had dropped).
This commit is contained in:
@@ -169,11 +169,20 @@ pub struct CellStrokeAttrs {
|
||||
pub tint: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<crate::item::StrokeType>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gap_color: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub gap_tint: Option<f32>,
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -189,6 +198,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) {
|
||||
@@ -204,10 +219,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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
-1
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+275
-70
@@ -29,7 +29,7 @@ fn style_specs() -> Vec<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
@@ -196,10 +196,19 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
"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<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
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<EdgeSel> {
|
||||
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<Option<f64>> {
|
||||
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<Option<String>> {
|
||||
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<StrokeEdit> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
// 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<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<Value> {
|
||||
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<Value>, 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<Value>; 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<AltFills> {
|
||||
|
||||
fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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);
|
||||
@@ -1035,23 +1242,17 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
// ---------- cell and table styles ----------
|
||||
|
||||
fn stroke_param(v: Option<&Value>) -> Option<designcraft_doc::CellStroke> {
|
||||
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<Option<CellStroke>> {
|
||||
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<CellStroke>, cmd: &str) -> Result<()> {
|
||||
if let Some(v) = p.get("basedOn") {
|
||||
cs.based_on = v.as_str().map(str::to_string);
|
||||
}
|
||||
@@ -1076,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();
|
||||
}
|
||||
@@ -1104,7 +1305,8 @@ fn cell_style_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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();
|
||||
@@ -1182,17 +1384,18 @@ fn dependent_styles<'a>(name: &str, styles: impl Iterator<Item = (&'a str, Optio
|
||||
|
||||
fn cell_style_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<CellStroke>) {
|
||||
if let Some(v) = p.get("basedOn") {
|
||||
ts.based_on = v.as_str().map(str::to_string);
|
||||
}
|
||||
@@ -1207,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();
|
||||
}
|
||||
@@ -1237,7 +1440,8 @@ fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value) {
|
||||
fn table_style_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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();
|
||||
@@ -1263,11 +1467,12 @@ fn table_style_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn table_style_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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();
|
||||
|
||||
@@ -399,3 +399,68 @@ 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);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2361,6 +2361,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) {
|
||||
@@ -2376,6 +2380,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) {
|
||||
|
||||
@@ -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).
|
||||
@@ -1753,6 +1761,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"))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2647,3 +2647,32 @@ fn hostile_tab_lists_import_within_the_limits() {
|
||||
let report = import_idml_report(&fixture_with_story(&fine), &|_| None).unwrap();
|
||||
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
|
||||
}
|
||||
|
||||
#[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#"<Cell Name="1:1">"#,
|
||||
r#"<Cell Name="1:1" LeftEdgeStrokeType="StrokeStyle/$ID/Dotted" LeftEdgeStrokeGapColor="Color/Brand" LeftEdgeStrokeGapTint="30" RightEdgeStrokeGapColor="Color/Paper">"#,
|
||||
);
|
||||
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));
|
||||
}
|
||||
|
||||
+31
-5
@@ -217,7 +217,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<f32> = pattern.iter().map(|v| v.max(0.0) as f32).collect();
|
||||
if pat.len() % 2 == 1 {
|
||||
@@ -230,10 +260,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);
|
||||
|
||||
@@ -1632,6 +1632,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());
|
||||
|
||||
@@ -464,22 +464,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1146,6 +1146,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"),
|
||||
"numberingSection" => crate::i18n::tr(&app.ui.language, "Numbering & Section Options…").trim_end_matches('…'),
|
||||
@@ -1677,6 +1678,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);
|
||||
@@ -1747,6 +1749,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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) };
|
||||
@@ -1802,6 +1897,15 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
|
||||
}
|
||||
r
|
||||
}
|
||||
"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;
|
||||
@@ -4766,6 +4870,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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbering_and_section_options_keep_their_actions_in_view() {
|
||||
for language in ["en", "ar"] {
|
||||
|
||||
@@ -3418,6 +3418,21 @@ const TABLE: &[(&str, [&str; 5])] = &[
|
||||
],
|
||||
),
|
||||
("Start At", ["Beginnen bei", "Commencer à", "Empezar en", "開始番号", "起始编号"]),
|
||||
// Cell strokes.
|
||||
("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.
|
||||
|
||||
@@ -1567,4 +1567,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Mode:", "الوضع:"),
|
||||
("Continue from Previous Number", "المتابعة من الرقم السابق"),
|
||||
("Start At", "البدء عند"),
|
||||
("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.",
|
||||
"انقر على خط لتضمينه أو استبعاده. النقر المزدوج يحدد كل الخطوط الخارجية أو كل الخطوط الداخلية؛ والنقر الثلاثي يحدد الكل أو يلغي تحديده.",
|
||||
),
|
||||
];
|
||||
|
||||
@@ -1820,6 +1820,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Em Box Top", "Geviert oben"),
|
||||
("Fancy", "Verziert"),
|
||||
("Gap Tint:", "Lückenfarbton:"),
|
||||
("Strokes and Fills…", "Konturen und Flächen …"),
|
||||
("Cell Stroke", "Zellkontur"),
|
||||
("Cell Fill", "Zellfläche"),
|
||||
("Cell edges", "Zellkanten"),
|
||||
(
|
||||
"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.",
|
||||
),
|
||||
("Glyphs…", "Glyphen…"),
|
||||
("Inset", "Eingezogen"),
|
||||
("Inverse Rounded", "Umgekehrt abgerundet"),
|
||||
|
||||
@@ -1623,4 +1623,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Mode:", "Modalità:"),
|
||||
("Continue from Previous Number", "Continua dal numero precedente"),
|
||||
("Start At", "Inizia da"),
|
||||
("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.",
|
||||
),
|
||||
];
|
||||
|
||||
@@ -1894,6 +1894,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Preset Name", "プリセット名"),
|
||||
("Bounding Box (Visible Layers Only)", "バウンディングボックス(表示レイヤーのみ)"),
|
||||
("Bounding Box (All Layers)", "バウンディングボックス(すべてのレイヤー)"),
|
||||
("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.",
|
||||
"線をクリックすると、含めるか除外するかを切り替えます。ダブルクリックで外側または内側の線をすべて選択し、トリプルクリックですべてを選択または解除します。",
|
||||
),
|
||||
];
|
||||
|
||||
/// Captions whose Japanese depends on where they appear: (English, context, Japanese).
|
||||
|
||||
@@ -1164,4 +1164,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Mode:", "Modo:"),
|
||||
("Continue from Previous Number", "Continuar do número anterior"),
|
||||
("Start At", "Iniciar em"),
|
||||
("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.",
|
||||
),
|
||||
];
|
||||
|
||||
@@ -1587,6 +1587,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Mode:", "Режим:"),
|
||||
("Continue from Previous Number", "Продовжити з попереднього номера"),
|
||||
("Start At", "Почати з"),
|
||||
("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.",
|
||||
"Клацніть лінію, щоб увімкнути або вимкнути її. Подвійне клацання вибирає всі зовнішні або всі внутрішні лінії; потрійне — вибирає всі або знімає вибір.",
|
||||
),
|
||||
];
|
||||
|
||||
/// Contexts whose Ukrainian noun differs from the shared action/text-layout label.
|
||||
|
||||
@@ -70,6 +70,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"),
|
||||
(
|
||||
@@ -615,6 +621,9 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
||||
"cmd:table.selectTable",
|
||||
"<",
|
||||
"-",
|
||||
">Cell Options",
|
||||
"ui:app.cellOptionsDialog",
|
||||
"<",
|
||||
"cmd:table.merge",
|
||||
"cmd:table.unmerge",
|
||||
">Convert Cell Type",
|
||||
@@ -820,6 +829,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new("menus", json!({"query": ""})));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.cellOptionsDialog" => 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);
|
||||
|
||||
@@ -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::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// 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<usize> {
|
||||
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<usize> = 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::<StrokeType>(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<String> = 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::<bool>(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");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Panels and shared panel helpers.
|
||||
|
||||
pub mod cell_stroke;
|
||||
pub mod conditions;
|
||||
pub mod datamerge;
|
||||
pub mod glyphs;
|
||||
|
||||
@@ -1578,6 +1578,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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user