//! Paragraph / character styles and swatches. use designcraft_color::{Swatch, SwatchValue}; use designcraft_doc::{CharAttrs, CharacterStyle, Effects, Item, ObjectStyle, ParaAttrs, ParagraphStyle, Styles}; use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, has_text_or_frames, ok, str_param}; use crate::{Result, Session}; pub fn specs() -> Vec { vec![ cmd!(query "style.compositeFont.list", "List Composite Fonts", [], None, "{} → composite font definitions", has_doc, |s, _| { serde_json::to_value(&s.doc()?.doc.styles.composite_fonts).map_err(|e| bad("style.compositeFont.list", e.to_string())) }), cmd!( "style.compositeFont.set", "Set Composite Font", [], None, "{name, entries: [{name?, characters?, family, style?, relativeSize?, horizontalScale?, verticalScale?, baselineShift?, scaleOption?}]} — replaces a composite font definition; empty characters selects the base entry", has_doc, |s, p| { let f: designcraft_doc::cjk::CompositeFont = serde_json::from_value(p.clone()).map_err(|e| bad("style.compositeFont.set", e.to_string()))?; if f.name.trim().is_empty() || f.entries.is_empty() || f.entries.len() > 1024 || !f.entries.iter().any(|e| e.characters.is_empty()) || f.entries.iter().any(|e| { e.family.trim().is_empty() || ![e.relative_size, e.horizontal_scale, e.vertical_scale].iter().all(|v| v.is_finite() && *v > 0.0 && *v <= 100.0) || !e.baseline_shift.is_finite() || e.baseline_shift.abs() > 100.0 }) { return Err(bad("style.compositeFont.set", "invalid name, base entry, font, scale or baseline shift")); } s.edit(|d, _| { let fonts = &mut d.styles_mut().composite_fonts; if let Some(old) = fonts.iter_mut().find(|old| old.name == f.name) { *old = f.clone(); } else { fonts.push(f.clone()); } Ok(json!({"name": f.name})) }) } ), cmd!( "style.exportTag", "Export Tagging", [], None, "{style, character?: bool, tag?: p|h1…h6|blockquote|pre|li|… (span|em|strong|code|sup|sub… for characters; \"\" = automatic), class?} → the tagging", has_doc, |s, p| { let name = str_param(p, "style").ok_or_else(|| bad("style.exportTag", "`style` required"))?.to_string(); let character = p.get("character").and_then(Value::as_bool).unwrap_or(false); let exists = if character { s.doc()?.doc.styles.char_style(&name).is_some() } else { s.doc()?.doc.styles.para(&name).is_some() }; if !exists { return Err(bad("style.exportTag", format!("no style `{name}`"))); } let key = format!("{}:{name}", if character { "c" } else { "p" }); let (tag, class) = (str_param(p, "tag").map(str::to_string), str_param(p, "class").map(str::to_string)); s.edit(|d, _| { let st = d.styles_mut(); let e = st.export_tags.entry(key.clone()).or_default(); if let Some(t) = tag.clone() { e.tag = t; } if let Some(c) = class.clone() { e.class = c; } let out = serde_json::to_value(&*e).unwrap_or_default(); if e.tag.is_empty() && e.class.is_empty() { st.export_tags.remove(&key); } Ok(out) }) } ), cmd!("style.paragraph.apply", "Apply Paragraph Style", [], None, "{name, clearOverrides?: bool}", has_text_or_frames, apply_para), cmd!("style.character.apply", "Apply Character Style", [], None, "{name}", has_text_or_frames, apply_char), cmd!( "style.paragraph.create", "New Paragraph Style…", [], None, "{name, basedOn?, nextStyle?, para?: {… startAt?: null = continue numbering; grepStyles?: [{style, pattern}]: a pattern that can't compile is an error}, chars?: {…}, fromSelection?: bool}", has_doc, create_para ), cmd!( "style.paragraph.edit", "Paragraph Style Options…", [], None, "{name, rename?, basedOn?, nextStyle?, para?: {… ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {…}}", has_doc, edit_para ), cmd!("style.paragraph.delete", "Delete Paragraph Style", [], None, "{name, replaceWith?}", has_doc, delete_para), cmd!( "style.character.create", "New Character Style…", [], None, "{name, basedOn?: style | \"[None]\", chars?: {…}, fromSelection?: bool (default false)} — if true, capture the first selected character's style/local overrides (caret: typing format), then overlay chars (null unsets); only explicit fill names are validated; paragraph formatting stays inherited; no text target: chars only → {name} (made unique)", has_doc, create_char ), cmd!( "style.character.edit", "Character Style Options…", [], None, "{name, rename?, basedOn?: style | \"[None]\" | null, chars?: {attr: value | null (no longer set)}} — renaming updates every use", has_doc, edit_char ), cmd!("style.character.delete", "Delete Character Style", [], None, "{name, replaceWith?}", has_doc, delete_char), cmd!(query "style.list", "List Styles", [], None, "{} → paragraph and character style names", has_doc, |s, _| { let st = s.doc()?; Ok(json!({ "paragraph": st.doc.styles.paragraph.iter().map(|p| &p.name).collect::>(), "character": st.doc.styles.character.iter().map(|p| &p.name).collect::>(), "object": st.doc.styles.object.iter().map(|p| &p.name).collect::>(), "cell": st.doc.styles.cell.iter().map(|p| &p.name).collect::>(), "table": st.doc.styles.table.iter().map(|p| &p.name).collect::>(), })) }), cmd!( "style.object.apply", "Apply Object Style", [], None, "{name, ids?} — sets the style's fill, stroke, effects and text frame options on the items", super::has_selection, apply_object ), cmd!( "style.object.create", "New Object Style…", [], None, "{name, fromSelection?: true (fill, stroke, effects and text frame options of the first selected item), fill?, paragraphStyle?, effects?: {dropShadow?, feather?, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?}, dropShadow?: {on?, color?, opacity? (0–1), angle?, globalLight?, distance?, size?, spread?}, feather?: width, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?} → {name} (made unique)", has_doc, create_object ), cmd!( "style.object.edit", "Object Style Options…", [], None, "{name, effects?: {…} | null (the style no longer sets effects), dropShadow?, feather?, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?} — effect objects merge field by field into the style's effects (fields as in `document.inspect` item effects); values that aren't finite numbers are refused, the others clamped (opacities 0–1, sizes and distances ≥ 0); items using the style whose effects match the style's (no effects, when the style set none) take the new effects, items with their own effects keep them → {name, updated}", has_doc, edit_object ), cmd!( "style.object.clearOverrides", "Clear Object Style Overrides", [], None, "{ids?} — each item takes its object style's fill, stroke, effects and text frame options again → {changed}", super::has_selection, clear_object_overrides ), cmd!( "swatch.create", "New Color Swatch…", [], None, "{name?, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255)|{model: rgb|cmyk|gray, …}(0..1), spot?: bool} | {name? (\"New Gradient Swatch\"), gradient: {kind?: linear|radial, stops: [{location: 0–100, swatch?: a colour swatch's name | color?, opacity?: 0–100, midpoint?: 13–87}]}} → {name}", has_doc, create_swatch ), cmd!( "swatch.new", "New Swatch", [], None, "{swatch?: name, target?: fill|stroke} → {name} — the Swatches panel's New Swatch: `swatch` (default: the selection's fill or stroke) as a new swatch. An unnamed colour or gradient becomes a named one (a gradient: \"New Gradient Swatch\"), still applied; a named swatch is duplicated (\"… copy\"); with nothing selected and no `swatch`, a new colour swatch", has_doc, new_swatch ), cmd!( "object.gradient", "Gradient", [], None, "{kind?: linear|radial, stops?: [{location: 0–100, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255)|{model: rgb|cmyk|gray, …}(0..1) | swatch: name (its colour, as dropped on the Gradient panel), opacity?: 0–100, midpoint?: 13–87}], reverse?: bool, angle?: degrees, from?: [x,y], to?: [x,y] (spread coords: the Gradient Swatch tool's drag), ids?} → {swatch, kind, stops} — edits the fill's gradient (an unnamed gradient unless it equals a gradient swatch)", super::has_selection, apply_gradient ), cmd!( "style.group", "Move to Group", [], None, "{kind: paragraph|character, names: [style names], group: \"Heads\" | \"Heads/Display\" | \"\" (out of any group)} — styles are named Group/Name; every use is renamed → {renamed: {old: new}}", has_doc, group_styles ), cmd!( "swatch.load", "Load Swatches…", [], None, "{path?|base64?, replace?: bool} — colour swatches from a swatch exchange (.ase) file; names already in the document are kept unless `replace` → {added, replaced}", has_doc, load_swatches ), cmd!( noundo "swatch.save", "Save Swatches for Exchange…", [], None, "{path?, names?: [swatch names] (default: all colour swatches)} → {base64} or writes `path` (.ase)", has_doc, save_swatches ), cmd!( "object.color", "Apply Color", [], None, "{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255)|{model: rgb|cmyk|gray, r,g,b|c,m,y,k|k}(0..1), target?: fill|stroke, text?: bool, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection; `text`: on the selected text, or the text of the selected frames (Formatting Affects Text)", |s| super::has_text(s).or_else(|_| super::has_selection(s)), apply_color ), cmd!( "color.sample", "Sample Color (Eyedropper)", [], None, "{at: [x,y] (spread coords), spread?, target?: fill|stroke, ids?, matchFrom?: id} → {color: \"#rrggbb\", swatch, from: image id} — the Eyedropper on a placed image: the image's colour at the point (an unnamed colour) fills the selected text, else the selected objects. With no opaque image pixel there, `matchFrom` gives the object whose attributes apply instead (object.matchAttributes)", |s| super::has_text(s).or_else(|_| super::has_selection(s)), sample_color ), cmd!( "swatch.addToSwatches", "Add to Swatches", [], None, "{swatch?: name (default: the selection's fill), target?: fill|stroke, name?: new name} — makes an unnamed colour a swatch", has_doc, add_to_swatches ), cmd!( "swatch.options", "Swatch Options…", [], None, "{name, to?: new name, nameWithValue?: bool, color?: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b]|{gray}(0..100), spot?: bool, tint?: 0–1 (tint swatches), base?: colour swatch (tint swatches)} → {name} — every use follows a rename; [None], [Registration], [Black] and [Paper] keep their names", has_doc, swatch_options ), cmd!("swatch.addUnnamed", "Add Unnamed Colors", [], None, "{} — every unnamed colour used becomes a swatch", has_doc, |s, _| { s.edit(|d, _| { let mut n = 0; for w in &mut d.swatches { if w.hidden { w.hidden = false; n += 1; } } Ok(json!({"added": n})) }) }), cmd!("swatch.delete", "Delete Swatch", [], None, "{name}", has_doc, |s, p| { let name = str_param(p, "name").unwrap_or("").to_string(); s.edit(|d, _| { if d.swatch(&name).is_some_and(|w| w.locked) { return Err(bad("swatch.delete", "special swatches can't be deleted")); } d.swatches.retain(|w| w.name != name); for g in &mut d.color_groups { g.swatches.retain(|w| *w != name); } ok() }) }), cmd!(query "ink.list", "Ink Manager", [], None, "{} → {allToProcess, inks: [{name, process: bool (printed as process), alias?}]} — the spot inks", has_doc, |s, _| { let d = &s.doc()?.doc; let inks: Vec = d .swatches .iter() .filter(|w| matches!(w.value, designcraft_color::swatch::SwatchValue::Color { color_type: designcraft_color::swatch::ColorType::Spot, .. })) .map(|w| { let alias = d.inks.aliases.iter().find(|(a, _)| *a == w.name).map(|(_, t)| t.clone()); json!({"name": w.name, "process": d.inks.resolve(&w.name).1, "alias": alias}) }) .collect(); Ok(json!({"allToProcess": d.inks.all_to_process, "inks": inks})) }), cmd!( "ink.options", "Ink Manager", [], None, "{allToProcess?: bool, ink?: spot swatch, toProcess?: bool, alias?: spot swatch | null} — output: spots to process, ink aliases", has_doc, |s, p| { let all = p.get("allToProcess").and_then(Value::as_bool); let ink = str_param(p, "ink").map(str::to_string); let to_process = p.get("toProcess").and_then(Value::as_bool); let alias = p.get("alias").cloned(); s.edit(|d, _| { if let Some(v) = all { d.inks.all_to_process = v; } if let Some(name) = &ink { let is_spot = |n: &str| { d.swatch(n).is_some_and(|w| { matches!( w.value, designcraft_color::swatch::SwatchValue::Color { color_type: designcraft_color::swatch::ColorType::Spot, .. } ) }) }; if !is_spot(name) { return Err(bad("ink.options", format!("`{name}` isn't a spot ink"))); } if let Some(Value::String(to)) = &alias && (!is_spot(to) || to == name) { return Err(bad("ink.options", format!("can't alias to `{to}`"))); } if let Some(v) = to_process { d.inks.to_process.retain(|x| x != name); if v { d.inks.to_process.push(name.clone()); } } match alias { Some(Value::String(to)) => { d.inks.aliases.retain(|(a, _)| a != name); d.inks.aliases.push((name.clone(), to)); } Some(Value::Null) => d.inks.aliases.retain(|(a, _)| a != name), _ => {} } } ok() }) } ), cmd!( "swatch.newColorGroup", "New Color Group", [], None, "{name?, swatches?: [names]} — a Swatches panel folder (the swatches move into it) → {name}", has_doc, |s, p| { let names: Vec = p .get("swatches") .and_then(Value::as_array) .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()) .unwrap_or_default(); let want = str_param(p, "name").map(str::to_string); s.edit(|d, _| { for n in &names { if d.swatch(n).is_none_or(|w| w.locked) { return Err(bad("swatch.newColorGroup", format!("`{n}` can't go in a group"))); } } let taken = |n: &str| d.color_groups.iter().any(|g| g.name == n); let name = match want { Some(n) if taken(&n) => return Err(bad("swatch.newColorGroup", format!("a group named `{n}` exists"))), Some(n) => n, None => (1..=d.color_groups.len() + 1) .map(|i| format!("Color Group {i}")) .find(|n| !taken(n)) .ok_or_else(|| bad("swatch.newColorGroup", "no free group name"))?, }; for g in &mut d.color_groups { g.swatches.retain(|w| !names.contains(w)); } d.color_groups.push(designcraft_doc::ColorGroup { name: name.clone(), swatches: names }); Ok(json!({"name": name})) }) } ), cmd!("swatch.moveToGroup", "Move to Color Group", [], None, "{swatches: [names], group: name | null (top level)}", has_doc, |s, p| { let names: Vec = p .get("swatches") .and_then(Value::as_array) .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()) .unwrap_or_default(); let group = str_param(p, "group").map(str::to_string); s.edit(|d, _| { if let Some(g) = &group && !d.color_groups.iter().any(|x| x.name == *g) { return Err(bad("swatch.moveToGroup", format!("no color group `{g}`"))); } for g in &mut d.color_groups { g.swatches.retain(|w| !names.contains(w)); } if let Some(g) = group.and_then(|g| d.color_groups.iter_mut().find(|x| x.name == g)) { g.swatches.extend(names.iter().filter(|n| d.swatches.iter().any(|w| w.name == **n && !w.locked)).cloned()); } ok() }) }), cmd!("swatch.ungroupColorGroup", "Ungroup Color Group", [], None, "{name} — its swatches go back to the top level", has_doc, |s, p| { let name = str_param(p, "name").unwrap_or("").to_string(); s.edit(|d, _| { let n = d.color_groups.len(); d.color_groups.retain(|g| g.name != name); if d.color_groups.len() == n { return Err(bad("swatch.ungroupColorGroup", format!("no color group `{name}`"))); } ok() }) }), cmd!("swatch.renameColorGroup", "Color Group Options", [], None, "{name, to}", has_doc, |s, p| { let (name, to) = (str_param(p, "name").unwrap_or("").to_string(), str_param(p, "to").unwrap_or("").trim().to_string()); s.edit(|d, _| { if to.is_empty() || d.color_groups.iter().any(|g| g.name == to) { return Err(bad("swatch.renameColorGroup", format!("can't rename to `{to}`"))); } let g = d .color_groups .iter_mut() .find(|g| g.name == name) .ok_or_else(|| bad("swatch.renameColorGroup", format!("no color group `{name}`")))?; g.name = to; ok() }) }), ] } fn attrs(v: Option<&Value>, set: impl Fn(&mut T, &str, &Value) -> std::result::Result<(), String>) -> Result { let mut a = T::default(); if let Some(o) = v.and_then(Value::as_object) { for (k, v) in o { set(&mut a, k, v).map_err(|e| bad("style", e))?; } } Ok(a) } fn apply_para(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.apply", "missing name"))?.to_string(); let clear = p.get("clearOverrides").and_then(Value::as_bool).unwrap_or(false); if s.doc()?.doc.styles.para(&name).is_none() { return Err(bad("style.paragraph.apply", format!("no paragraph style `{name}`"))); } let targets = super::text::format_targets_pub(s); s.edit(|d, _| { for t in &targets { let r = &t.range; if let Some(st) = d.text_story_mut(t.story, t.cell) { st.format_paras(r.clone(), |f| { f.style = name.clone(); if clear { f.para = ParaAttrs::default(); f.chars = CharAttrs::default(); } }); if clear { let (a, b) = (st.para_ranges()[st.para_at(r.start)].start, st.para_ranges()[st.para_at(r.end)].end); st.format_chars(a..b, |f| f.over = CharAttrs::default()); } } } ok() }) } fn apply_char(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.character.apply", "missing name"))?.to_string(); if s.doc()?.doc.styles.char_style(&name).is_none() { return Err(bad("style.character.apply", format!("no character style `{name}`"))); } let targets = super::text::format_targets_pub(s); s.edit(|d, _| { for t in &targets { if let Some(st) = d.text_story_mut(t.story, t.cell) { st.format_chars(t.range.clone(), |f| f.style = name.clone()); } } ok() }) } /// In a style, `startAt: null` is Continue from Previous Number: it is stored as an explicit /// "continue" so it also overrides a Start At the style would inherit. fn continue_numbering(p: Option<&Value>, para: &mut ParaAttrs) { let explicit = p.and_then(Value::as_object).is_some_and(|o| o.iter().any(|(k, v)| v.is_null() && k.replace('_', "").eq_ignore_ascii_case("startAt"))); if explicit { para.start_at = Some(None); } } fn create_para(s: &mut Session, p: &Value) -> Result { let base = str_param(p, "name").unwrap_or("Paragraph Style 1").to_string(); let mut para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, &designcraft_doc::ParaProps::default()))?; continue_numbering(p.get("para"), &mut para); explicit_default_kinsoku(&mut para, p.get("para")); check_grep_styles("style.paragraph.create", ¶)?; let mut chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; // Validate caller-supplied fills, not selection fills retained after a swatch is deleted. super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.create")?; if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) && let Ok(cur) = s.execute("type.selectionAttrs", &json!({})) && !cur.is_null() { if let Ok(pp) = serde_json::from_value::(cur["para"].clone()) { let mut full = ParaProps_to_attrs(&pp); full.merge(¶); para = full; } if let Ok(cp) = serde_json::from_value::(cur["chars"].clone()) { let mut full = CharProps_to_attrs(&cp); full.merge(&chars); chars = full; } } let based_on = str_param(p, "basedOn").map(str::to_string); let next = str_param(p, "nextStyle").map(str::to_string); s.edit(|d, _| { let name = Styles::unique_name(|n| d.styles.para(n).is_some(), &base); if let Some(b) = &based_on && d.styles.para(b).is_none() { return Err(bad("style.paragraph.create", format!("no style `{b}`"))); } d.styles_mut().paragraph.push(ParagraphStyle { name: name.clone(), based_on, next_style: next, para, chars, shortcut: String::new() }); Ok(json!({"name": name})) }) } /// `kinsoku: null` sets the composer's default kinsoku rules, also over a Based On style's set. /// For the other attributes `null` leaves the style's value as it is. fn explicit_default_kinsoku(para: &mut ParaAttrs, given: Option<&Value>) { if given.and_then(|o| o.get("kinsoku")).is_some_and(Value::is_null) { para.kinsoku = Some(None); } } /// The compile error of the first GREP style in `a` whose pattern can't compile. pub(crate) fn grep_style_error(a: &ParaAttrs) -> Option { a.grep_styles .iter() .flatten() .filter(|g| !g.pattern.is_empty()) .find_map(|g| designcraft_compose::grep::Grep::new(&g.pattern, Default::default()).err().map(|e| format!("{e} (pattern {:?})", g.pattern))) } /// Rejects GREP styles a command would set with a pattern that can't compile. pub(crate) fn check_grep_styles(cmd: &str, a: &ParaAttrs) -> Result<()> { grep_style_error(a).map_or(Ok(()), |e| Err(bad(cmd, e))) } /// One warning for each paragraph style or paragraph whose GREP style pattern can't compile. /// Such a GREP style stays in the document and composition skips it. pub(crate) fn grep_style_warnings(d: &designcraft_doc::Document) -> Vec { let mut out: Vec = d .styles .paragraph .iter() .filter_map(|st| grep_style_error(&st.para).map(|e| format!("paragraph style {:?}: GREP style not applied: {e}", st.name))) .collect(); for st in d.stories.values() { for (i, pf) in st.paras.iter().enumerate() { if let Some(e) = grep_style_error(&pf.para) { out.push(format!("story {} paragraph {}: GREP style not applied: {e}", st.id.0, i + 1)); } } } out } #[allow(non_snake_case)] fn ParaProps_to_attrs(p: &designcraft_doc::ParaProps) -> ParaAttrs { designcraft_doc::ParaProps::common([p]) } #[allow(non_snake_case)] fn CharProps_to_attrs(p: &designcraft_doc::CharProps) -> CharAttrs { designcraft_doc::CharProps::common([p]) } fn edit_para(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string(); let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?; let rename = str_param(p, "rename").map(str::to_string); let based = p.get("basedOn").cloned(); s.edit(|d, _| { if let Some(Value::String(b)) = &based && d.styles.para_based_on_cycles(&name, b) { return Err(bad("style.paragraph.edit", "based-on would create a cycle")); } // A rule object changes only the rule fields it names, over the style's resolved rule. let (current, _) = d.styles.resolve_para_style(&name); let mut para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, ¤t))?; continue_numbering(p.get("para"), &mut para); explicit_default_kinsoku(&mut para, p.get("para")); check_grep_styles("style.paragraph.edit", ¶)?; let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; st.para.merge(¶); st.chars.merge(&chars); match based { Some(Value::String(b)) => st.based_on = Some(b), Some(Value::Null) => st.based_on = None, _ => {} } if let Some(n) = rename.clone() { // Every use: other styles, stories, table cells, footnotes, object styles. rename_style(d, true, &name, &n); } ok() }) } /// Rename a paragraph or character style everywhere it's used. fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &str) { let rename = |r: &mut String| { if r == from { *r = to.to_string(); } }; let st = d.styles_mut(); if para { for s in &mut st.paragraph { if s.name == from { s.name = to.to_string(); } for r in [&mut s.based_on, &mut s.next_style] { if r.as_deref() == Some(from) { *r = Some(to.to_string()); } } } let objects = st.object.iter_mut().map(|o| &mut o.paragraph_style); for r in objects.chain(st.cell.iter_mut().map(|c| &mut c.paragraph_style)) { if r.as_deref() == Some(from) { *r = Some(to.to_string()); } } rename(&mut st.default_paragraph); let (fo, eo) = (&mut d.footnote_options, &mut d.endnote_options); [&mut fo.para_style, &mut eo.para_style, &mut eo.heading_style].into_iter().for_each(rename); d.toc.iter_mut().flat_map(|t| t.entries.iter_mut().map(|e| &mut e.style)).for_each(rename); } else { for s in &mut st.character { if s.name == from { s.name = to.to_string(); } if s.based_on.as_deref() == Some(from) { s.based_on = Some(to.to_string()); } } for s in &mut st.paragraph { rename_char_refs(&mut s.para, from, to); } rename(&mut st.default_character); rename(&mut d.footnote_options.ref_char_style); } for sid in d.stories.keys().copied().collect::>() { let Some(story) = d.story_mut(sid) else { continue }; story.for_each_text_mut(&mut |st| { if para { for f in st.paras.iter_mut().filter(|f| f.style == from) { f.style = to.to_string(); } } else { for r in st.chars.iter_mut().filter(|r| r.format.style == from) { r.format.style = to.to_string(); } for f in &mut st.paras { rename_char_refs(&mut f.para, from, to); } } st.rev = st.rev.wrapping_add(1); }); } } /// Point the nested, nested line, GREP and drop cap styles of paragraph attributes at a renamed /// character style. fn rename_char_refs(p: &mut ParaAttrs, from: &str, to: &str) { let names = p.nested_styles.iter_mut().flatten().map(|n| &mut n.style); let names = names.chain(p.nested_line_styles.iter_mut().flatten().map(|n| &mut n.style)); let names = names.chain(p.drop_cap_style.iter_mut()); for n in names.chain(p.grep_styles.iter_mut().flatten().map(|g| &mut g.style)) { if n == from { *n = to.to_string(); } } } fn group_styles(s: &mut Session, p: &Value) -> Result { let para = match str_param(p, "kind").unwrap_or("paragraph") { "paragraph" => true, "character" => false, k => return Err(bad("style.group", format!("unknown kind `{k}`"))), }; let names: Vec = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); let group = str_param(p, "group").unwrap_or("").trim_matches('/').to_string(); s.edit(|d, _| { let mut renamed = serde_json::Map::new(); for n in &names { if n.starts_with('[') { return Err(bad("style.group", format!("`{n}` is built in"))); } let exists = if para { d.styles.para(n).is_some() } else { d.styles.character.iter().any(|c| c.name == *n) }; if !exists { return Err(bad("style.group", format!("no style `{n}`"))); } let base = n.rsplit('/').next().unwrap_or(n); let mut to = if group.is_empty() { base.to_string() } else { format!("{group}/{base}") }; let taken = |d: &designcraft_doc::Document, x: &str| { if para { d.styles.para(x).is_some() } else { d.styles.character.iter().any(|c| c.name == x) } }; if to != *n { to = Styles::unique_name(|x| taken(d, x), &to); rename_style(d, para, n, &to); renamed.insert(n.clone(), json!(to)); } } Ok(json!({"renamed": renamed})) }) } fn delete_para(s: &mut Session, p: &Value) -> Result { delete_style(s, p, true) } fn delete_char(s: &mut Session, p: &Value) -> Result { delete_style(s, p, false) } /// Delete a paragraph (`para`) or character style; everything that used it uses `replaceWith` /// (default [Basic Paragraph] or [None]) instead, in one undo step. fn delete_style(s: &mut Session, p: &Value, para: bool) -> Result { let (id, default) = if para { ("style.paragraph.delete", designcraft_doc::BASIC_PARAGRAPH) } else { ("style.character.delete", designcraft_doc::NO_CHAR_STYLE) }; let name = str_param(p, "name").filter(|n| !n.is_empty()).ok_or_else(|| bad(id, "missing name"))?.to_string(); let repl = str_param(p, "replaceWith").unwrap_or(default).to_string(); if name.starts_with('[') { return Err(bad(id, "built-in styles can't be deleted")); } if repl == name { return Err(bad(id, "a style can't be replaced with itself")); } s.edit(|d, _| { let st = &d.styles; let exists = |n: &str| if para { st.para(n).is_some() } else { n == designcraft_doc::NO_CHAR_STYLE || st.char_style(n).is_some() }; if !exists(&name) { return Err(bad(id, format!("no style `{name}`"))); } if !exists(&repl) { return Err(bad(id, format!("no style `{repl}` to replace it with"))); } // The deleted style's children are based on the replacement, or on the deleted style's // own parent when the replacement descends from it (so no based-on chain loops). let (own_parent, descends) = if para { (st.para(&name).and_then(|x| x.based_on.clone()), st.para_based_on_cycles(&name, &repl)) } else { (st.char_style(&name).and_then(|x| x.based_on.clone()), st.char_based_on_cycles(&name, &repl)) }; let parent = if descends { own_parent.filter(|b| *b != name) } else if repl == designcraft_doc::NO_CHAR_STYLE || repl == designcraft_doc::NO_PARA_STYLE { None } else { Some(repl.clone()) }; let st = d.styles_mut(); let based = if para { st.paragraph.retain(|x| x.name != name); st.paragraph.iter_mut().map(|x| &mut x.based_on).collect::>() } else { st.character.retain(|x| x.name != name); st.character.iter_mut().map(|x| &mut x.based_on).collect() }; for b in based.into_iter().filter(|b| b.as_deref() == Some(name.as_str())) { *b = parent.clone(); } // Every other use: styles, stories, table cells, nested and GREP styles, notes, defaults. rename_style(d, para, &name, &repl); ok() }) } fn create_char(s: &mut Session, p: &Value) -> Result { let base = str_param(p, "name").unwrap_or("Character Style 1").to_string(); let mut chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; // Validate caller-supplied fills; captured formatting may retain a deleted swatch name. super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.create")?; if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) && let Some(target) = super::text::format_targets_pub(s).first() { let st = s.doc()?; let story = st.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; // Match paragraph fromSelection's first-character/caret policy, but keep paragraph // attributes inherited instead of freezing the fully resolved character properties. let mut format = if target.range.is_empty() { story.char_format_at(target.range.start) } else { story.format_after(target.range.start) }.clone(); // A bare caret: what typing there would get, as `type.selectionAttrs` reports. if target.range.is_empty() { super::text::with_typing_format(s, &mut format); } let mut captured = st.doc.styles.char_style_attrs(&format.style); captured.merge(&format.over); // Replay the explicit fields so null clears a captured value rather than looking absent. if let Some(overrides) = p.get("chars").and_then(Value::as_object) { for (key, value) in overrides { captured.set_json(key, value).map_err(|e| bad("style", e))?; } } chars = captured; } // Based on [None] is based on nothing. let based_on = str_param(p, "basedOn").filter(|b| *b != designcraft_doc::NO_CHAR_STYLE).map(str::to_string); s.edit(|d, _| { if let Some(b) = &based_on && d.styles.char_style(b).is_none() { return Err(bad("style.character.create", format!("no character style `{b}`"))); } let name = Styles::unique_name(|n| d.styles.char_style(n).is_some(), &base); d.styles_mut().character.push(CharacterStyle { name: name.clone(), based_on, chars, shortcut: String::new() }); Ok(json!({"name": name})) }) } fn edit_char(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.character.edit", "missing name"))?.to_string(); if name == designcraft_doc::NO_CHAR_STYLE { return Err(bad("style.character.edit", "[None] can't be edited")); } let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.edit")?; // `null` unsets an attribute (the style no longer sets it). let unset: Vec = p .get("chars") .and_then(Value::as_object) .map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()) .unwrap_or_default(); let rename = str_param(p, "rename").filter(|n| *n != name).map(|n| n.trim().to_string()); let based: Option> = match p.get("basedOn") { None => None, Some(Value::Null) => Some(None), Some(Value::String(b)) if b == designcraft_doc::NO_CHAR_STYLE => Some(None), Some(Value::String(b)) => Some(Some(b.clone())), Some(_) => return Err(bad("style.character.edit", "`basedOn` is a style name or null")), }; s.edit(|d, _| { if let Some(Some(b)) = &based { if d.styles.char_style(b).is_none() { return Err(bad("style.character.edit", format!("no character style `{b}`"))); } if d.styles.char_based_on_cycles(&name, b) { return Err(bad("style.character.edit", "based-on would create a cycle")); } } if let Some(n) = &rename && (n.is_empty() || n.starts_with('[') || d.styles.char_style(n).is_some()) { return Err(bad("style.character.edit", format!("can't rename to `{n}`"))); } let st = d.styles_mut().char_style_mut(&name).ok_or_else(|| bad("style.character.edit", format!("no character style `{name}`")))?; st.chars.merge(&chars); for k in &unset { st.chars.set_json(k, &Value::Null).map_err(|e| bad("style.character.edit", e))?; } if let Some(b) = based.clone() { st.based_on = b; } if let Some(n) = &rename { // Every use: other styles, stories, table cells, nested and GREP styles, footnotes. rename_style(d, false, &name, n); } ok() }) } fn create_swatch(s: &mut Session, p: &Value) -> Result { if let Some(g) = p.get("gradient") { return create_gradient_swatch(s, p, g); } let c = p.get("color").ok_or_else(|| bad("swatch.create", "missing color (or gradient)"))?; let color = parse_color(c).ok_or_else(|| bad("swatch.create", "bad color"))?; let spot = p.get("spot").and_then(Value::as_bool).unwrap_or(false); let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| value_name(color)); s.edit(|d, _| { let name = Styles::unique_name(|n| d.swatch(n).is_some(), &name); d.swatches.push(Swatch { name: name.clone(), value: SwatchValue::Color { color, color_type: if spot { designcraft_color::ColorType::Spot } else { designcraft_color::ColorType::Process }, }, locked: false, named: true, hidden: false, }); Ok(json!({"name": name})) }) } /// `swatch.create {gradient}`: a named gradient swatch. Stops take a colour swatch by name (as a /// swatch dragged onto the Gradient panel's stop) or a colour. fn create_gradient_swatch(s: &mut Session, p: &Value, g: &Value) -> Result { use designcraft_color::{Gradient, GradientKind, GradientStop}; const ID: &str = "swatch.create"; let kind = match g.get("kind").and_then(Value::as_str) { None | Some("linear") => GradientKind::Linear, Some("radial") => GradientKind::Radial, Some(k) => return Err(bad(ID, format!("unknown gradient kind {k}"))), }; let doc = &s.doc()?.doc; let mut stops = Vec::new(); for st in g.get("stops").and_then(Value::as_array).ok_or_else(|| bad(ID, "a gradient needs stops"))? { let color = match (st.get("swatch").and_then(Value::as_str), st.get("color")) { (Some(n), _) => match doc.swatch(n).map(|w| &w.value) { Some(SwatchValue::Gradient { .. }) => return Err(bad(ID, format!("`{n}` is a gradient; a stop takes a colour"))), Some(_) => doc.resolve_color(n, 1.0).ok_or_else(|| bad(ID, format!("`{n}` has no colour")))?, None => return Err(bad(ID, format!("no swatch named `{n}`"))), }, (None, Some(c)) => parse_color(c).ok_or_else(|| bad(ID, "a stop has a bad color"))?, (None, None) => return Err(bad(ID, "a stop needs a swatch or a color")), }; let pct = |k: &str, d: f64| st.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(d).clamp(0.0, 100.0) as f32 / 100.0; stops.push(GradientStop { offset: pct("location", 0.0), color, opacity: pct("opacity", 100.0), midpoint: pct("midpoint", 50.0).clamp(0.13, 0.87), }); } if stops.len() < 2 { return Err(bad(ID, "a gradient needs at least two stops")); } let mut gradient = Gradient { kind, stops }; gradient.sort(); let name = str_param(p, "name").unwrap_or("New Gradient Swatch").to_string(); s.edit(|d, _| { let name = Styles::unique_name(|n| d.swatch(n).is_some(), &name); d.swatches.push(Swatch { name: name.clone(), value: SwatchValue::Gradient { gradient }, locked: false, named: true, hidden: false }); Ok(json!({"name": name})) }) } /// `swatch.new`: InDesign's New Swatch. An unnamed colour or gradient (the selection's, or /// `swatch`) is added to the swatches under a name, and stays applied; a named swatch is /// duplicated as " copy". fn new_swatch(s: &mut Session, p: &Value) -> Result { const ID: &str = "swatch.new"; let st = s.doc()?; let name = match str_param(p, "swatch") { Some(n) => n.to_string(), None => { let Some(id) = st.selection.items.first().copied() else { // Nothing selected: a new colour swatch. return create_swatch(s, &json!({"color": {"c": 0, "m": 50, "y": 100, "k": 0}})); }; let it = st.doc.item(id).ok_or_else(|| bad(ID, "no such item"))?; if str_param(p, "target") == Some("stroke") { it.stroke.swatch.clone() } else { it.fill.swatch.clone() } } }; let w = st.doc.swatch(&name).cloned().ok_or_else(|| bad(ID, format!("no swatch named `{name}`")))?; if matches!(w.value, SwatchValue::None | SwatchValue::Registration) { return Err(bad(ID, format!("`{name}` can't be made a new swatch"))); } if w.hidden { // An unnamed gradient takes InDesign's "New Gradient Swatch"; an unnamed colour its values. let new = match w.value { SwatchValue::Gradient { .. } => Styles::unique_name(|n| st.doc.swatch(n).is_some(), "New Gradient Swatch"), _ => name.clone(), }; return add_to_swatches(s, &json!({"swatch": name, "name": new})); } s.edit(|d, _| { let copy = Styles::unique_name(|n| d.swatch(n).is_some(), &format!("{name} copy")); d.swatches.push(Swatch { name: copy.clone(), locked: false, named: true, hidden: false, ..w.clone() }); Ok(json!({"name": copy})) }) } pub fn parse_color(v: &Value) -> Option { use designcraft_color::Color; match v { Value::String(s) => Color::from_hex(s), // `{model, …}` is the colour's own form (as `color.convert` takes it): fractions, never guessed. Value::Object(o) if o.contains_key("model") => model_color(v), // One scale per colour, not per component: {c: 0.7, y: 12.2} is C 0.7 %, Y 12.2 %. Only a colour whose // components are all within 0..1 is read as fractions (the older form). Value::Array(a) if a.len() >= 3 => { let v = [a[0].as_f64()?, a[1].as_f64()?, a[2].as_f64()?]; let scale = if v.iter().any(|&x| x > 1.0) { 255.0 } else { 1.0 }; Some(Color::rgb((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32)) } Value::Object(o) => { if o.contains_key("gray") { let gray = o.get("gray").and_then(Value::as_f64)?; return Some(Color::gray(if gray > 1.0 { gray / 100.0 } else { gray } as f32)); } let g = |k: &str| o.get(k).and_then(Value::as_f64); let v = [g("c")?, g("m")?, g("y")?, g("k")?]; let scale = if v.iter().any(|&x| x > 1.0) { 100.0 } else { 1.0 }; Some(Color::cmyk((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32, (v[3] / scale) as f32)) } _ => None, } } /// `{model: rgb|cmyk|gray, …}` with every component in 0..=1; anything else is not a colour. fn model_color(v: &Value) -> Option { use designcraft_color::Color; let color: Color = serde_json::from_value(v.clone()).ok()?; // A float's last bit (100.0 / 100.0 ≠ 1.0 after an f32 round trip) is not out of range. let unit = |x: f32| x.is_finite() && (-1e-4..=1.0 + 1e-4).contains(&x); let q = |x: f32| x.clamp(0.0, 1.0); match color { Color::Rgb { r, g, b } => [r, g, b].into_iter().all(unit).then(|| Color::rgb(q(r), q(g), q(b))), Color::Cmyk { c, m, y, k } => [c, m, y, k].into_iter().all(unit).then(|| Color::cmyk(q(c), q(m), q(y), q(k))), Color::Gray { k } => unit(k).then_some(Color::Gray { k: q(k) }), } } fn apply_object(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.object.apply", "missing name"))?.to_string(); let os = s.doc()?.doc.styles.object_style(&name).cloned().ok_or_else(|| bad("style.object.apply", format!("no object style `{name}`")))?; let ids = super::targets(s, p)?; s.edit(|d, _| { let mut stories = Vec::new(); for id in &ids { let Some(it) = d.item_mut(*id) else { continue }; it.object_style = name.clone(); os.apply_to(it); if let Some(tf) = it.text_frame() { stories.push(tf.story); } } if let Some(ps) = &os.paragraph_style { for sid in stories { if let Some(st) = d.story_mut(sid) { let len = st.len(); st.format_paras(0..len, |f| f.style = ps.clone()); } } } Ok(Value::Null) }) } fn clear_object_overrides(s: &mut Session, p: &Value) -> Result { let ids = super::targets(s, p)?; // Each target with its style's attributes applied, kept only where that changes the item. let d = &s.doc()?.doc; let changed: Vec = ids .iter() .filter_map(|id| { let it = d.item(*id)?; let mut next = it.clone(); d.styles.object_style(&it.object_style)?.apply_to(&mut next); (next != *it).then_some(next) }) .collect(); if changed.is_empty() { return Ok(json!({"changed": 0})); } s.edit(|d, _| { for next in &changed { if let Some(it) = d.item_mut(next.id) { *it = next.clone(); } } Ok(json!({"changed": changed.len()})) }) } /// Effect keys accepted at the top level of `style.object.create` / `edit` as shorthand for /// `effects.`. const EFFECT_KEYS: [&str; 9] = ["dropShadow", "feather", "innerShadow", "outerGlow", "innerGlow", "bevel", "satin", "gradientFeather", "directionalFeather"]; /// The effects `p` gives (`effects`, then the top-level effect keys) merged field by field over /// `base`, or `None` when `p` names no effect. fn effects_param(cmd: &str, p: &Value, base: &Effects) -> Result> { let mut patch = serde_json::Map::new(); match p.get("effects") { None | Some(Value::Null) => {} Some(Value::Object(o)) => patch.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))), Some(_) => return Err(bad(cmd, "`effects` must be an object or null")), } for k in EFFECT_KEYS { if let Some(v) = p.get(k) { patch.insert(k.to_string(), v.clone()); } } if patch.is_empty() { return Ok(None); } let mut v = serde_json::to_value(base).map_err(|e| bad(cmd, e.to_string()))?; merge_json(&mut v, Value::Object(patch), 0); let e: Effects = serde_json::from_value(v).map_err(|e| bad(cmd, format!("effects: {e}")))?; e.sanitized().map(Some).map_err(|m| bad(cmd, m)) } /// Merges `top` into `base`: objects key by key, anything else replaced. fn merge_json(base: &mut Value, top: Value, depth: usize) { match (base, top) { (Value::Object(b), Value::Object(t)) if depth < 8 => { for (k, v) in t { match b.get_mut(&k) { Some(slot) => merge_json(slot, v, depth + 1), None => { b.insert(k, v); } } } } (b, t) => *b = t, } } fn create_object(s: &mut Session, p: &Value) -> Result { let base = str_param(p, "name").unwrap_or("Object Style 1").to_string(); let from_sel = p.get("fromSelection").and_then(Value::as_bool).unwrap_or(true); let src = if from_sel { s.doc()?.selection.items.first().and_then(|i| s.doc().ok()?.doc.item(*i).cloned()) } else { None }; let mut os = ObjectStyle::default(); if let Some(it) = &src { os.fill = Some(it.fill.clone()); os.stroke = Some(it.stroke.clone()); os.text_frame = it.text_frame().map(|t| t.options.clone()); os.effects = Some(it.effects.clone()); } if let Some(sw) = str_param(p, "fill") { os.fill = Some(designcraft_doc::Fill::swatch(sw)); } if let Some(ps) = str_param(p, "paragraphStyle") { os.paragraph_style = Some(ps.to_string()); } if let Some(e) = effects_param("style.object.create", p, os.effects.as_ref().unwrap_or(&Effects::default()))? { os.effects = Some(e); } s.edit(|d, _| { let name = Styles::unique_name(|n| d.styles.object_style(n).is_some(), &base); os.name = name.clone(); d.styles_mut().object.push(os.clone()); Ok(json!({"name": name})) }) } fn edit_object(s: &mut Session, p: &Value) -> Result { const CMD: &str = "style.object.edit"; let name = str_param(p, "name").ok_or_else(|| bad(CMD, "missing name"))?.to_string(); if name == designcraft_doc::NO_OBJECT_STYLE { return Err(bad(CMD, format!("`{name}` can't be edited"))); } let old = s.doc()?.doc.styles.object_style(&name).cloned().ok_or_else(|| bad(CMD, format!("no object style `{name}`")))?; let effects = if p.get("effects").is_some_and(Value::is_null) { if EFFECT_KEYS.iter().any(|k| p.get(k).is_some()) { return Err(bad(CMD, "`effects: null` can't be combined with effect settings")); } None } else { match effects_param(CMD, p, old.effects.as_ref().unwrap_or(&Effects::default()))? { Some(e) => Some(e), None => return Ok(json!({"name": name, "updated": 0})), } }; if effects == old.effects { return Ok(json!({"name": name, "updated": 0})); } s.edit(|d, _| { let Some(os) = d.styles_mut().object.iter_mut().find(|o| o.name == name) else { return Err(bad(CMD, format!("no object style `{name}`"))); }; os.effects = effects.clone(); // An item follows the style while its effects are the style's. When the style didn't set // effects before, only items without effects of their own follow (the default styles are // on most frames). let follows = old.effects.clone().unwrap_or_default(); let updated = match &effects { Some(new) => d.update_items(|it| it.object_style == name && it.effects == follows, |it| it.effects = new.clone()), None => 0, }; Ok(json!({"name": name, "updated": updated})) }) } /// The swatch name of a colour value ("C=… M=… Y=… K=…" / "R=… G=… B=…"). fn value_name(color: designcraft_color::Color) -> String { designcraft_color::swatch::value_name(color) } fn load_swatches(s: &mut Session, p: &Value) -> Result { let bytes = match (str_param(p, "path"), str_param(p, "base64")) { (_, Some(b)) => super::file::base64_decode(b), (Some(path), None) => std::fs::read(path).map_err(|e| bad("swatch.load", format!("{path}: {e}")))?, _ => return Err(bad("swatch.load", "give `path` or `base64`")), }; let incoming = designcraft_color::ase::read(&bytes).map_err(|e| bad("swatch.load", e.to_string()))?; let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(false); s.edit(|d, _| { // The position of the first swatch with each name, kept up to date as the file's colors are added. let mut at: std::collections::HashMap = std::collections::HashMap::new(); for (i, x) in d.swatches.iter().enumerate() { at.entry(x.name.clone()).or_insert(i); } let (mut added, mut replaced) = (0, 0); for w in incoming { match at.get(&w.name).and_then(|&i| d.swatches.get_mut(i)) { Some(x) if x.locked => {} Some(x) => { if replace { x.value = w.value; x.hidden = false; replaced += 1; } } None => { at.insert(w.name.clone(), d.swatches.len()); d.swatches.push(w); added += 1; } } } Ok(json!({"added": added, "replaced": replaced})) }) } fn save_swatches(s: &mut Session, p: &Value) -> Result { let d = &s.doc()?.doc; let names: Option> = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()); let chosen: Vec = d.swatches.iter().filter(|w| names.as_ref().is_none_or(|n| n.contains(&w.name))).cloned().collect(); let bytes = designcraft_color::ase::write(&chosen); match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] let written = crate::atomic::write_atomic(std::path::Path::new(path), &bytes); #[cfg(target_arch = "wasm32")] let written = std::fs::write(path, &bytes); written.map_err(|e| bad("swatch.save", format!("{path}: {e}")))?; Ok(json!({"path": path, "bytes": bytes.len()})) } None => Ok(json!({"base64": super::file::base64_encode(&bytes)})), } } fn gradient_json(name: &str, g: &designcraft_color::Gradient) -> Value { let stops: Vec = g .stops .iter() .map(|s| json!({"location": (s.offset as f64 * 1000.0).round() / 10.0, "color": s.color.to_hex(), "opacity": (s.opacity * 100.0).round(), "midpoint": (s.midpoint * 100.0).round()})) .collect(); json!({"swatch": name, "kind": if g.kind == designcraft_color::GradientKind::Radial { "radial" } else { "linear" }, "stops": stops}) } fn apply_gradient(s: &mut Session, p: &Value) -> Result { use designcraft_color::{GradientKind, GradientStop}; let kind = match str_param(p, "kind") { Some("radial") => Some(GradientKind::Radial), Some("linear") => Some(GradientKind::Linear), Some(k) => return Err(bad("object.gradient", format!("unknown kind {k}"))), None => None, }; let stops = match p.get("stops").and_then(Value::as_array) { Some(a) => { let doc = &s.doc()?.doc; let mut v = Vec::new(); for st in a { // A swatch dropped on a stop gives it the swatch's colour (a tint's mixed in). let color = match st.get("swatch").and_then(Value::as_str) { Some(name) => doc .resolve_color(name, 1.0) .filter(|_| designcraft_color::swatch::resolve_gradient(&doc.swatches, name).is_none()) .ok_or_else(|| bad("object.gradient", format!("`{name}` isn't a colour swatch")))?, None => st.get("color").and_then(parse_color).ok_or_else(|| bad("object.gradient", "a stop needs a color or a swatch"))?, }; let pct = |k: &str, d: f64| st.get(k).and_then(Value::as_f64).unwrap_or(d).clamp(0.0, 100.0) as f32 / 100.0; v.push(GradientStop { offset: pct("location", 0.0), color, opacity: pct("opacity", 100.0), midpoint: pct("midpoint", 50.0).clamp(0.13, 0.87), }); } if v.len() < 2 { return Err(bad("object.gradient", "a gradient needs at least two stops")); } Some(v) } None => None, }; let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); let angle = p.get("angle").and_then(Value::as_f64); let (from, to) = (super::point_param(p, "from"), super::point_param(p, "to")); let ids = super::targets(s, p)?; s.edit(|d, _| { let mut out = Value::Null; for id in &ids { let Some(it) = d.item(*id) else { continue }; let cur_name = it.fill.swatch.clone(); let cur = designcraft_color::swatch::resolve_gradient(&d.swatches, &cur_name).cloned(); let mut g = cur.clone().unwrap_or_default(); if let Some(k) = kind { g.kind = k; } if let Some(st) = &stops { g.stops = st.clone(); g.sort(); } if reverse { g.reverse(); } // Keep the swatch while the gradient is unchanged, else reuse an equal gradient swatch // or add an unnamed one. let name = if cur.as_ref() == Some(&g) { cur_name } else if let Some(w) = d.swatches.iter().find(|w| matches!(&w.value, SwatchValue::Gradient { gradient } if *gradient == g)) { w.name.clone() } else { let name = Styles::unique_name(|n| d.swatch(n).is_some(), "Gradient"); d.swatches.push(Swatch { name: name.clone(), value: SwatchValue::Gradient { gradient: g.clone() }, locked: false, named: false, hidden: true, }); name }; let inv = d.find(*id).map(|l| d.parent_xf(&l)).zip(d.item(*id).map(|i| i.xf)).map(|(pxf, xf)| (pxf * xf).inverse()); let Some(it) = d.item_mut(*id) else { continue }; it.fill.swatch = name.clone(); it.fill.tint = 1.0; if let Some(a) = angle { it.fill.gradient_angle = Some(a); it.fill.gradient_vector = None; } if let (Some(a), Some(b), Some(inv)) = (from, to, inv) { let (a, b) = (inv * a, inv * b); if (b - a).hypot() > 1e-6 { it.fill.gradient_vector = Some([a.x, a.y, b.x, b.y]); } } if out.is_null() { out = gradient_json(&name, &g); } } Ok(out) }) } fn apply_color(s: &mut Session, p: &Value) -> Result { let color = p.get("color").and_then(parse_color).ok_or_else(|| bad("object.color", "missing or bad color"))?; let stroke = str_param(p, "target") == Some("stroke"); let text = (p.get("text").and_then(Value::as_bool).unwrap_or(false) || s.doc()?.selection.text.is_some()) && p.get("ids").is_none() && p.get("id").is_none(); let (ids, texts) = if text { (vec![], super::text::format_targets_pub(s)) } else { (super::targets(s, p)?, vec![]) }; // A caret: the colour is for the next typing (once the swatch exists). let caret = matches!(texts.as_slice(), [t] if t.range.is_empty()); let r = s.edit(|d, _| { // Reuse a swatch holding this colour to the precision its name shows (whole CMYK percents, // 0-255 RGB), else add an unnamed one: values a fraction of a percent apart would otherwise // add hidden "C=0 M=50 Y=100 K=0 2" twins. let shown = value_name(color); let process = |w: &&Swatch| match &w.value { SwatchValue::Color { color: c, color_type: designcraft_color::ColorType::Process } => Some(*c), _ => None, }; let existing = d .swatches .iter() .find(|w| process(w) == Some(color)) .or_else(|| { d.swatches .iter() .find(|w| process(w).is_some_and(|c| std::mem::discriminant(&c) == std::mem::discriminant(&color) && value_name(c) == shown)) }) .map(|w| w.name.clone()); let name = match existing { Some(n) => n, None => { let name = Styles::unique_name(|n| d.swatch(n).is_some(), &shown); d.swatches.push(Swatch { name: name.clone(), value: SwatchValue::Color { color, color_type: designcraft_color::ColorType::Process }, locked: false, named: false, hidden: true, }); name } }; for id in &ids { if let Some(it) = d.item_mut(*id) { if stroke { it.stroke.swatch = name.clone(); it.stroke.tint = 1.0; } else { it.fill.swatch = name.clone(); it.fill.tint = 1.0; } } } if !caret { let a = if stroke { CharAttrs { stroke: Some(name.clone()), stroke_tint: Some(1.0), ..Default::default() } } else { CharAttrs { fill: Some(name.clone()), fill_tint: Some(1.0), ..Default::default() } }; super::text::apply_char_attrs(d, &texts, &a, &[]); } Ok(json!({"swatch": name})) })?; if caret && let Some(name) = r.get("swatch").cloned() { let attrs = if stroke { json!({"stroke": name, "strokeTint": 1.0}) } else { json!({"fill": name, "fillTint": 1.0}) }; super::text::format_chars(s, &attrs)?; } Ok(r) } /// The Eyedropper on a placed image (InDesign samples the pixel under the pointer): that colour, /// as an unnamed colour, fills the selected text (characters), or the selected objects. fn sample_color(s: &mut Session, p: &Value) -> Result { const ID: &str = "color.sample"; let at = super::point_param(p, "at").ok_or_else(|| bad(ID, "missing at"))?; let sr = super::spread_param(p, ID)?; let st = s.doc()?; let Some((from, color)) = image_color_at(&st.doc, sr, at) else { // No opaque image pixel there (an empty part of an image frame, a transparent pixel): the // Eyedropper applies the clicked object's attributes instead. return match p.get("matchFrom") { Some(from) => { let mut params = p.clone(); params["from"] = from.clone(); super::object::match_attributes(s, ¶ms) } None => Err(bad(ID, "no placed image under the point")), }; }; let hex = color.to_hex(); let mut params = p.clone(); params["color"] = json!(hex); let mut result = apply_color(s, ¶ms)?; result["color"] = json!(hex); result["from"] = json!(from.0); Ok(result) } /// The colour of the placed raster image at `at` on spread `sr`: the frontmost visible image whose /// frame shows that point (locked ones too, as sampling changes nothing). (image frame, colour); /// None over no image, a transparent pixel, or a graphic that is not a raster (PDF, EPS). pub(crate) fn image_color_at( d: &designcraft_doc::Document, sr: designcraft_doc::SpreadRef, at: designcraft_geom::Point, ) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> { let sp = d.spread(sr)?; let mut items: Vec<&designcraft_doc::Item> = sp.items.iter().map(|i| &**i).collect(); items.sort_by_key(|i| d.layer_rank(i.layer)); items .into_iter() .rev() .filter(|it| d.layer(it.layer).is_none_or(|l| l.visible)) .find_map(|it| sample_item(d, it, designcraft_geom::Affine::IDENTITY, at, 0)) } fn sample_item( d: &designcraft_doc::Document, it: &designcraft_doc::Item, outer: designcraft_geom::Affine, at: designcraft_geom::Point, depth: usize, ) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> { use designcraft_doc::Content; if it.hidden || depth > 64 { return None; } let xf = outer * it.xf; match &it.content { // Only the active state of a multi-state object shows. Content::Group { .. } => { let shown: Vec<_> = it.shown_children().collect(); shown.into_iter().rev().find_map(|c| sample_item(d, c, xf, at, depth + 1)) } Content::Graphic(g) => { let inner = xf.inverse() * at; if !designcraft_geom::hit::fill_contains(&it.path.to_bezpath(), designcraft_geom::FillRule::NonZero, inner) { return None; } // A clipping path hides the image outside it. if let Some((region, even_odd)) = g.clip_region() { let rule = if even_odd { designcraft_geom::FillRule::EvenOdd } else { designcraft_geom::FillRule::NonZero }; if !designcraft_geom::hit::fill_contains(®ion, rule, inner) { return None; } } let gp = g.xf.inverse() * inner; let (w, h) = g.size; // Written so that NaN (a degenerate transform) is outside too. if !(w > 0.0 && h > 0.0 && gp.x >= 0.0 && gp.y >= 0.0 && gp.x < w && gp.y < h) { return None; } let asset = d.assets.get(&g.asset).filter(|a| a.is_raster())?; let img = designcraft_images::decode_rgba(&asset.data)?; let (pw, ph) = img.dimensions(); let px = ((gp.x / w * f64::from(pw)) as u32).min(pw.checked_sub(1)?); let py = ((gp.y / h * f64::from(ph)) as u32).min(ph.checked_sub(1)?); let [r, gr, b, a] = img.get_pixel_checked(px, py)?.0; (a > 0).then(|| (it.id, designcraft_color::Color::rgb8(r, gr, b))) } _ => None, } } fn add_to_swatches(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let name = match str_param(p, "swatch") { Some(n) => n.to_string(), None => { let id = st.selection.items.first().copied().ok_or_else(|| bad("swatch.addToSwatches", "select an object or give `swatch`"))?; let it = st.doc.item(id).ok_or_else(|| bad("swatch.addToSwatches", "no such item"))?; if str_param(p, "target") == Some("stroke") { it.stroke.swatch.clone() } else { it.fill.swatch.clone() } } }; let rename = str_param(p, "name").map(str::to_string); s.edit(|d, _| { let w = d.swatches.iter_mut().find(|w| w.name == name).ok_or_else(|| bad("swatch.addToSwatches", format!("no swatch `{name}`")))?; if w.locked { return Err(bad("swatch.addToSwatches", "special swatches are already listed")); } w.hidden = false; let Some(new) = rename.filter(|n| *n != name) else { return Ok(json!({"name": name})) }; if d.swatches.iter().any(|w| w.name == new) { return Err(bad("swatch.addToSwatches", format!("a swatch named `{new}` exists"))); } let w = d.swatches.iter_mut().find(|w| w.name == name).ok_or_else(|| bad("swatch.addToSwatches", format!("no swatch `{name}`")))?; w.name = new.clone(); w.named = true; d.rename_swatch_refs(&name, &new); Ok(json!({"name": new})) }) } /// Swatch Options: rename, Name with Color Value, colour type and value, a tint swatch's tint /// and base — one undo step. Every use of the swatch follows a rename. fn swatch_options(s: &mut Session, p: &Value) -> Result { const ID: &str = "swatch.options"; let name = str_param(p, "name").ok_or_else(|| bad(ID, "missing name"))?.to_string(); let to = str_param(p, "to").map(|n| n.trim().to_string()); let with_value = p.get("nameWithValue").and_then(Value::as_bool); let color = match p.get("color") { Some(c) => Some(parse_color(c).ok_or_else(|| bad(ID, "bad color"))?), None => None, }; let spot = p.get("spot").and_then(Value::as_bool); let tint = match p.get("tint") { Some(v) => Some(v.as_f64().filter(|t| t.is_finite() && (0.0..=1.0).contains(t)).ok_or_else(|| bad(ID, "tint is 0–1"))? as f32), None => None, }; let base = str_param(p, "base").map(str::to_string); s.edit(|d, _| { let w = d.swatch(&name).cloned().ok_or_else(|| bad(ID, format!("no swatch `{name}`")))?; let mut w2 = w.clone(); if w.locked { if to.as_ref().is_some_and(|t| *t != name) || with_value == Some(true) { return Err(bad(ID, format!("`{name}` can't be renamed"))); } match (&mut w2.value, color) { (_, None) => {} (SwatchValue::Paper { color: c }, Some(new)) => *c = new, _ => return Err(bad(ID, format!("`{name}` can't be edited"))), } if spot.is_some() || tint.is_some() || base.is_some() { return Err(bad(ID, format!("`{name}` can't be edited"))); } } else { match &mut w2.value { SwatchValue::Color { color: c, color_type } => { if let Some(new) = color { *c = new; } if let Some(sp) = spot { *color_type = if sp { designcraft_color::ColorType::Spot } else { designcraft_color::ColorType::Process }; } if tint.is_some() || base.is_some() { return Err(bad(ID, "only tint swatches have a tint")); } } SwatchValue::Tint { base: b, tint: t } => { if color.is_some() || spot.is_some() { return Err(bad(ID, "a tint swatch takes its colour from its base")); } if let Some(v) = tint { *t = v; } if let Some(nb) = &base { if !d.swatch(nb).is_some_and(|x| matches!(x.value, SwatchValue::Color { .. })) || *nb == name { return Err(bad(ID, format!("`{nb}` isn't a colour swatch"))); } *b = nb.clone(); } } _ => { if color.is_some() || spot.is_some() || tint.is_some() || base.is_some() { return Err(bad(ID, format!("`{name}` only takes a name here"))); } } } // Name with Color Value: the name is the colour's values and tracks later edits. let auto = with_value.unwrap_or(!w.named && to.as_ref().is_none_or(|t| *t == name)); let derived = match (&w2.value, auto) { (SwatchValue::Color { color, .. }, true) => Some(value_name(*color)), _ => None, }; match derived { Some(n) => { w2.name = n; w2.named = false; } None => { if let Some(t) = &to { if t.is_empty() { return Err(bad(ID, "a swatch needs a name")); } w2.name = t.clone(); } if with_value == Some(false) || w2.name != name { w2.named = true; } } } if w2.name != name && d.swatch(&w2.name).is_some() { return Err(bad(ID, format!("a swatch named `{}` exists", w2.name))); } } let new = w2.name.clone(); let slot = d.swatches.iter_mut().find(|x| x.name == name).ok_or_else(|| bad(ID, format!("no swatch `{name}`")))?; *slot = w2; d.rename_swatch_refs(&name, &new); Ok(json!({"name": new})) }) } #[cfg(test)] mod color_tests { use super::*; fn fill_session() -> Session { let mut session = Session::new(); session.execute("file.new", &json!({})).unwrap(); let frame = session.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "Hi"})).unwrap(); session.execute("text.select", &json!({"story": frame["story"], "anchor": 0, "focus": 2})).unwrap(); session.execute("style.paragraph.create", &json!({"name": "Existing"})).unwrap(); session.execute("style.character.create", &json!({"name": "Existing"})).unwrap(); session.execute("type.char", &json!({"size": 18})).unwrap(); session.execute("edit.undo", &json!({})).unwrap(); session } fn assert_fill_rejected(session: &mut Session, command: &str, params: Value) { let before = session.doc().unwrap().clone(); let journal = session.journal.clone(); let fill = params .pointer("/chars/fill") .or_else(|| params.pointer("/attrs/fill")) .or_else(|| params.get("fill")) .and_then(Value::as_str) .unwrap_or("Missing Ink"); let result = session.execute(command, ¶ms); match result { Err(crate::EngineError::BadParams { cmd, msg }) => { assert_eq!(cmd, command); assert!(msg.contains(fill), "{msg}"); assert!(msg.contains("swatch.create") && msg.contains("returned name"), "{msg}"); } other => panic!("expected BadParams for {command}, got {other:?}"), } let after = session.doc().unwrap(); assert!(std::sync::Arc::ptr_eq(&before.doc, &after.doc)); assert_eq!(before.selection, after.selection); assert_eq!(before.revision, after.revision); assert_eq!(format!("{:?}", before.history), format!("{:?}", after.history)); assert_eq!(journal, session.journal); } #[test] fn char_fill_rejects_unknown_flat_and_nested_names_when_formatting() { for fill in ["Missing Ink", "#ff0000", "[black]", " [Black]"] { for params in [json!({"fill": fill, "size": 24}), json!({"attrs": {"fill": fill, "size": 24}})] { let mut session = fill_session(); assert_fill_rejected(&mut session, "type.char", params); } } } #[test] fn char_fill_rejects_unknown_names_when_creating_or_editing_styles() { for command in ["style.paragraph.create", "style.paragraph.edit", "style.character.create", "style.character.edit"] { for fill in ["Missing Ink", "#ff0000"] { let mut session = fill_session(); session.execute("style.paragraph.apply", &json!({"name": "Existing"})).unwrap(); session.execute("style.character.apply", &json!({"name": "Existing"})).unwrap(); assert_fill_rejected(&mut session, command, json!({"name": "Existing", "rename": "Renamed", "chars": {"fill": fill, "size": 24}})); } } } #[test] fn char_fill_preserves_deleted_swatch_when_creating_from_selection() { for params in [json!({"name": "Inherited", "fromSelection": true}), json!({"name": "Inherited", "fromSelection": true, "chars": {"size": 24}})] { let mut session = fill_session(); let swatch = session.execute("swatch.create", &json!({"name": "Deleted Ink", "color": "#108080"})).unwrap(); let fill = swatch["name"].as_str().unwrap(); session.execute("type.char", &json!({"fill": fill})).unwrap(); session.execute("swatch.delete", &json!({"name": fill})).unwrap(); assert_eq!(session.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], fill); session.execute("style.paragraph.create", ¶ms).unwrap(); let chars = &session.doc().unwrap().doc.styles.para("Inherited").unwrap().chars; assert_eq!(chars.fill.as_deref(), Some(fill)); if params.get("chars").is_some() { assert_eq!(chars.size, Some(24.0)); } } } #[test] fn char_fill_rejects_explicit_unknown_name_when_creating_from_selection() { for fill in ["Missing Ink", "#ff0000"] { let mut session = fill_session(); assert_fill_rejected( &mut session, "style.paragraph.create", json!({"name": "Explicit", "fromSelection": true, "chars": {"fill": fill, "size": 24}}), ); } } #[test] fn char_fill_accepts_valid_override_when_selection_has_unknown_name() { let mut session = fill_session(); session .edit(|document, selection| { let selected = selection.text.unwrap(); document.story_mut(selected.story).unwrap().format_chars(selected.range(), |format| format.over.fill = Some("Missing Ink".into())); Ok(()) }) .unwrap(); session.execute("style.paragraph.create", &json!({"name": "Override", "fromSelection": true, "chars": {"fill": "[Paper]"}})).unwrap(); assert_eq!(session.doc().unwrap().doc.styles.para("Override").unwrap().chars.fill.as_deref(), Some("[Paper]")); } #[test] fn char_fill_rejects_unknown_name_when_changing_text_even_without_output() { for (find, change) in [("Hi", "Bye"), ("Hi", ""), ("Absent", "Bye")] { for fill in ["Missing Ink", "#ff0000"] { let mut session = fill_session(); assert_fill_rejected(&mut session, "find.change", json!({"find": find, "change": change, "attrs": {"fill": fill}})); } } } #[test] fn char_fill_preserves_exact_registered_names_when_used_at_all_boundaries() { for fill in ["Brand Ink", "#ff0000", "[None]", "[Black]"] { let mut session = fill_session(); if !fill.starts_with('[') { let created = session.execute("swatch.create", &json!({"name": fill, "color": "#108080"})).unwrap(); assert_eq!(created["name"], fill); } for params in [json!({"fill": fill}), json!({"attrs": {"fill": fill}})] { session.execute("type.char", ¶ms).unwrap(); assert_eq!(session.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], fill); } for kind in ["paragraph", "character"] { session.execute(&format!("style.{kind}.create"), &json!({"name": "Colored", "chars": {"fill": fill}})).unwrap(); session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": {"fill": fill}})).unwrap(); let document = &session.doc().unwrap().doc; for name in ["Colored", "Existing"] { let attrs = if kind == "paragraph" { &document.styles.para(name).unwrap().chars } else { &document.styles.char_style(name).unwrap().chars }; assert_eq!(attrs.fill.as_deref(), Some(fill)); } } assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": {"fill": fill}})).unwrap()["count"], 1); let selected = session.doc().unwrap().selection.text.unwrap(); let story = session.doc().unwrap().doc.story(selected.story).unwrap(); assert_eq!(story.text, "Bye"); assert_eq!(story.format_after(0).over.fill.as_deref(), Some(fill)); } } #[test] fn char_fill_accepts_omitted_and_null_attributes_when_used_at_all_boundaries() { for chars in [json!({"size": 18}), json!({"fill": null})] { let mut session = fill_session(); session.execute("type.char", &json!({"attrs": chars})).unwrap(); for kind in ["paragraph", "character"] { session.execute(&format!("style.{kind}.create"), &json!({"name": "Nullable", "chars": chars})).unwrap(); session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": chars})).unwrap(); } assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": chars})).unwrap()["count"], 1); } } #[test] fn percent_components_at_most_one_stay_percent() { use designcraft_color::Color; // {"c": 0.7, ..., "y": 12.2} is a pale cream (C 0.7 %, Y 12.2 %), not C 70 %. let Some(Color::Cmyk { c, m, y, k }) = parse_color(&json!({"c": 0.7, "m": 0, "y": 12.2, "k": 0})) else { panic!("not CMYK") }; assert!((c - 0.007).abs() < 1e-6 && m == 0.0 && (y - 0.122).abs() < 1e-6 && k == 0.0, "got {c} {m} {y} {k}"); // Fractions (every component within 0..1) keep working. let Some(Color::Cmyk { c, k, .. }) = parse_color(&json!({"c": 0.5, "m": 0, "y": 0, "k": 1})) else { panic!("not CMYK") }; assert!((c - 0.5).abs() < 1e-6 && (k - 1.0).abs() < 1e-6); // RGB arrays follow the same rule: [1, 128, 255] is 0..255. let Some(Color::Rgb { r, g, b }) = parse_color(&json!([1, 128, 255])) else { panic!("not RGB") }; assert!((r - 1.0 / 255.0).abs() < 1e-6 && (g - 128.0 / 255.0).abs() < 1e-6 && (b - 1.0).abs() < 1e-6); } #[test] fn a_colour_with_a_model_is_read_as_fractions_without_guessing() { use designcraft_color::Color; let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let id = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); s.execute("selection.set", &json!({"ids": [id]})).unwrap(); let fill = |s: &Session| { let d = &s.doc().unwrap().doc; d.resolve_color(&d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, 1.0).unwrap() }; // M 1 % from the Color panel: not M 100 % (magenta), though every component is within 0..1. s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0, "m": 0.01, "y": 0, "k": 0}})).unwrap(); let Color::Cmyk { c, m, y, k } = fill(&s) else { panic!("not CMYK") }; assert!(c == 0.0 && (m - 0.01).abs() < 1e-6 && y == 0.0 && k == 0.0, "got {c} {m} {y} {k}"); // R 1 of 255 is a near black, not pure red. s.execute("object.color", &json!({"color": {"model": "rgb", "r": 1.0 / 255.0, "g": 0, "b": 0}})).unwrap(); let Color::Rgb { r, g, b } = fill(&s) else { panic!("not RGB") }; assert!((r - 1.0 / 255.0).abs() < 1e-6 && g == 0.0 && b == 0.0, "got {r} {g} {b}"); // Out of range or not a model: refused rather than guessed. assert!(s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0, "m": 50, "y": 0, "k": 0}})).is_err()); assert!(s.execute("object.color", &json!({"color": {"model": "hsb", "h": 0, "s": 0, "b": 0}})).is_err()); } #[test] fn colours_that_round_to_the_same_name_share_one_swatch() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let id = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); s.execute("selection.set", &json!({"ids": [id]})).unwrap(); let n = |s: &Session| s.doc().unwrap().doc.swatches.len(); let first = s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0.1, "m": 0.5, "y": 1, "k": 0}})).unwrap(); let before = n(&s); for m in [0.501, 0.499, 0.5041] { let r = s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0.1, "m": m, "y": 1, "k": 0}})).unwrap(); assert_eq!(r["swatch"], first["swatch"], "M {m}"); } s.execute("object.color", &json!({"color": {"model": "rgb", "r": 0.5, "g": 0.25, "b": 0}})).unwrap(); s.execute("object.color", &json!({"color": {"model": "rgb", "r": 0.5005, "g": 0.25, "b": 0}})).unwrap(); assert_eq!(n(&s), before + 1, "no hidden twins for values a fraction of a step apart"); // A whole percent apart is a different colour. let r = s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0.1, "m": 0.51, "y": 1, "k": 0}})).unwrap(); assert_eq!(r["swatch"], "C=10 M=51 Y=100 K=0"); } #[test] fn style_groups_rename_every_use() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap(); s.execute("style.paragraph.create", &json!({"name": "Sub", "basedOn": "Head"})).unwrap(); let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hi"})).unwrap(); let sid = r["story"].as_u64().unwrap(); s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 2})).unwrap(); s.execute("style.paragraph.apply", &json!({"name": "Head"})).unwrap(); let r = s.execute("style.group", &json!({"kind": "paragraph", "names": ["Head", "Sub"], "group": "Titles"})).unwrap(); assert_eq!(r["renamed"]["Head"], "Titles/Head"); let d = s.doc().unwrap().doc.clone(); assert_eq!(d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0].style, "Titles/Head"); assert_eq!(d.styles.para("Titles/Sub").unwrap().based_on.as_deref(), Some("Titles/Head")); // Out of the group again. s.execute("style.group", &json!({"kind": "paragraph", "names": ["Titles/Head"], "group": ""})).unwrap(); assert!(s.doc().unwrap().doc.styles.para("Head").is_some()); assert!(s.execute("style.group", &json!({"names": ["[Basic Paragraph]"], "group": "X"})).is_err()); } #[test] fn character_style_edit_sets_unsets_rebases_and_renames_every_use() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("style.character.create", &json!({"name": "Base", "chars": {"fontStyle": "Bold"}})).unwrap(); s.execute("style.character.create", &json!({"name": "Emphasis", "chars": {"fontStyle": "Italic", "tracking": 20}})).unwrap(); s.execute( "style.paragraph.create", &json!({"name": "Lead", "para": { "nestedStyles": [{"style": "Emphasis", "through": true, "count": 1, "until": {"kind": "words"}}], "grepStyles": [{"style": "Emphasis", "pattern": "\\d+"}], "nestedLineStyles": [{"style": "Emphasis", "lines": 1}]}}), ) .unwrap(); s.execute("footnote.options", &json!({"refCharStyle": "Emphasis"})).unwrap(); let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hi there"})).unwrap(); let sid = r["story"].as_u64().unwrap(); s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 2})).unwrap(); s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); s.execute( "style.character.edit", &json!({"name": "Emphasis", "rename": "Strong", "basedOn": "Base", "chars": {"size": 14, "fill": "[Paper]", "fontStyle": null}}), ) .unwrap(); let d = s.doc().unwrap().doc.clone(); assert!(d.styles.char_style("Emphasis").is_none()); let st = d.styles.char_style("Strong").unwrap(); assert_eq!(st.based_on.as_deref(), Some("Base")); // Exactly the attributes set: `null` unset the font style, the rest stayed unset. assert_eq!(st.chars, CharAttrs { size: Some(14.0), fill: Some("[Paper]".into()), tracking: Some(20.0), ..Default::default() }); let story = d.story(designcraft_doc::StoryId(sid)).unwrap(); assert!(story.chars.iter().any(|r| r.format.style == "Strong")); assert!(story.chars.iter().all(|r| r.format.style != "Emphasis")); let lead = d.styles.para("Lead").unwrap(); assert_eq!(lead.para.nested_styles.as_ref().unwrap()[0].style, "Strong"); assert_eq!(lead.para.grep_styles.as_ref().unwrap()[0].style, "Strong"); assert_eq!(lead.para.nested_line_styles.as_ref().unwrap()[0].style, "Strong"); assert_eq!(d.footnote_options.ref_char_style, "Strong"); // Based On [None] (null or the name) clears it. s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": null})).unwrap(); assert_eq!(s.doc().unwrap().doc.styles.char_style("Strong").unwrap().based_on, None); s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Base"})).unwrap(); s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "[None]"})).unwrap(); assert_eq!(s.doc().unwrap().doc.styles.char_style("Strong").unwrap().based_on, None); // Refused: a based-on cycle, an unknown parent, a taken or empty name, editing [None]. s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Base"})).unwrap(); assert!(s.execute("style.character.edit", &json!({"name": "Base", "basedOn": "Strong"})).is_err()); assert!(s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Nope"})).is_err()); assert!(s.execute("style.character.edit", &json!({"name": "Strong", "rename": "Base"})).is_err()); assert!(s.execute("style.character.edit", &json!({"name": "Strong", "rename": " "})).is_err()); assert!(s.execute("style.character.edit", &json!({"name": "[None]", "chars": {"size": 9}})).is_err()); assert!(s.doc().unwrap().doc.styles.char_style("[None]").unwrap().chars.is_empty()); } #[test] fn continue_numbering_in_a_style_overrides_an_inherited_start_at() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("style.paragraph.create", &json!({"name": "Steps", "para": {"listType": "numbers", "startAt": 5}})).unwrap(); s.execute("style.paragraph.create", &json!({"name": "More Steps", "basedOn": "Steps"})).unwrap(); let start = |s: &Session, name: &str| s.doc().unwrap().doc.styles.resolve_para_style(name).0.start_at; assert_eq!(start(&s, "More Steps"), Some(5), "inherited"); s.execute("style.paragraph.edit", &json!({"name": "More Steps", "para": {"startAt": null}})).unwrap(); assert_eq!(s.doc().unwrap().doc.styles.para("More Steps").unwrap().para.start_at, Some(None)); assert_eq!(start(&s, "More Steps"), None, "continues"); assert_eq!(start(&s, "Steps"), Some(5), "the parent keeps its Start At"); s.execute("style.paragraph.edit", &json!({"name": "Steps", "para": {"startAt": null}})).unwrap(); assert_eq!(start(&s, "Steps"), None); s.execute("style.paragraph.create", &json!({"name": "Fresh", "basedOn": "More Steps", "para": {"start_at": null}})).unwrap(); assert_eq!(s.doc().unwrap().doc.styles.para("Fresh").unwrap().para.start_at, Some(None)); } #[test] fn null_kinsoku_sets_the_default_rules_over_a_based_on_set() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let hard = serde_json::to_value(designcraft_doc::cjk::Kinsoku::named("HardKinsoku").unwrap()).unwrap(); s.execute("style.paragraph.create", &json!({"name": "Base", "para": {"kinsoku": hard}})).unwrap(); s.execute("style.paragraph.create", &json!({"name": "Body", "basedOn": "Base", "para": {"kinsoku": hard}})).unwrap(); s.execute("style.paragraph.edit", &json!({"name": "Body", "para": {"kinsoku": null}})).unwrap(); let styles = s.doc().unwrap().doc.styles.clone(); assert_eq!(styles.para("Body").unwrap().para.kinsoku, Some(None)); assert_eq!(styles.resolve_para_style("Body").0.kinsoku, None, "Base's set no longer applies"); // An edit without the attribute keeps it. s.execute("style.paragraph.edit", &json!({"name": "Body", "para": {"rensuuji": false}})).unwrap(); assert_eq!(s.doc().unwrap().doc.styles.para("Body").unwrap().para.kinsoku, Some(None)); } #[test] fn new_character_style_checks_its_based_on() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); assert!(s.execute("style.character.create", &json!({"name": "Orphan", "basedOn": "Nope"})).is_err()); s.execute("style.character.create", &json!({"name": "Plain", "basedOn": "[None]", "chars": {"size": 9}})).unwrap(); let st = s.doc().unwrap().doc.styles.char_style("Plain").cloned().unwrap(); assert_eq!((st.based_on, st.chars), (None, CharAttrs { size: Some(9.0), ..Default::default() })); } #[test] fn swatches_save_and_load_ase() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("swatch.create", &json!({"name": "Brand Teal", "color": "#108080"})).unwrap(); let b64 = s.execute("swatch.save", &json!({"names": ["Brand Teal"]})).unwrap()["base64"].as_str().unwrap().to_string(); s.execute("file.new", &json!({})).unwrap(); let r = s.execute("swatch.load", &json!({"base64": b64})).unwrap(); assert_eq!(r["added"], 1); assert!(s.doc().unwrap().doc.swatch("Brand Teal").is_some()); let r = s.execute("swatch.load", &json!({"base64": b64})).unwrap(); assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(0), Some(0))); assert!(s.execute("swatch.load", &json!({"base64": "AAAA"})).is_err()); } #[test] fn loading_swatches_matches_the_first_swatch_of_each_name() { use designcraft_color::{Color, ColorType}; let ink = |k: f32| SwatchValue::Color { color: Color::cmyk(0.0, 0.0, 0.0, k), color_type: ColorType::Process }; let sw = |name: &str, k: f32| Swatch { name: name.into(), value: ink(k), locked: false, named: true, hidden: false }; let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); // Two swatches share a name, and an unnamed color is hidden. s.edit(|d, _| { d.swatches.extend([sw("Twin", 0.1), sw("Twin", 0.2), Swatch { named: false, hidden: true, ..sw("Unnamed", 0.3) }]); Ok(()) }) .unwrap(); let file = designcraft_color::ase::write(&[sw("New", 0.4), sw("New", 0.5), sw("Twin", 0.6), sw("Unnamed", 0.7), sw("[Black]", 0.8)]); let b64 = crate::cmd::base64_encode(&file); let named = |s: &Session, name: &str| -> Vec<(SwatchValue, bool)> { s.doc().unwrap().doc.swatches.iter().filter(|w| w.name == name).map(|w| (w.value.clone(), w.hidden)).collect() }; // With `replace`, the file's second "New" replaces its first, only the first "Twin" changes, the hidden // swatch becomes visible, and the locked [Black] stays. let r = s.execute("swatch.load", &json!({"base64": b64, "replace": true})).unwrap(); assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(1), Some(3))); assert_eq!(named(&s, "New"), [(ink(0.5), false)]); assert_eq!(named(&s, "Twin"), [(ink(0.6), false), (ink(0.2), false)]); assert_eq!(named(&s, "Unnamed"), [(ink(0.7), false)]); assert_eq!(named(&s, "[Black]"), [(ink(1.0), false)]); // Without it, every name is already there and nothing changes. let before = s.doc().unwrap().doc.swatches.clone(); let r = s.execute("swatch.load", &json!({"base64": b64})).unwrap(); assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(0), Some(0))); assert_eq!(s.doc().unwrap().doc.swatches, before); } #[test] fn unnamed_colors_then_add_to_swatches() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let id = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); s.execute("selection.set", &json!({"ids": [id]})).unwrap(); let listed = |s: &Session| s.doc().unwrap().doc.swatches.iter().filter(|w| !w.hidden).count(); let before = listed(&s); let r = s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap(); assert_eq!(r["swatch"], "C=10 M=20 Y=30 K=0"); assert_eq!(listed(&s), before, "unnamed: not in the Swatches panel"); // The same colour again reuses it; the stroke target works too. let n = s.doc().unwrap().doc.swatches.len(); s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}, "target": "stroke"})).unwrap(); assert_eq!(s.doc().unwrap().doc.swatches.len(), n); let d = &s.doc().unwrap().doc; let it = d.item(designcraft_doc::ItemId(id)).unwrap(); assert_eq!(it.fill.swatch, it.stroke.swatch); // Add to Swatches with a name: listed, and the object follows the rename. s.execute("swatch.addToSwatches", &json!({"name": "Sand"})).unwrap(); assert_eq!(listed(&s), before + 1); let d = &s.doc().unwrap().doc; assert_eq!(d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, "Sand"); // A colour equal to an existing swatch applies that swatch. s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap(); assert_eq!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, "Sand"); } #[test] fn add_to_swatches_renames_the_colour_in_text_cells_and_notes() { use std::sync::Arc; let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello world"})).unwrap(); let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); s.execute("text.select", &json!({"story": sid.0, "anchor": 11})).unwrap(); s.execute("endnote.insert", &json!({"text": "end"})).unwrap(); s.execute("text.select", &json!({"story": sid.0, "anchor": 11})).unwrap(); s.execute("footnote.insert", &json!({"text": "note"})).unwrap(); s.execute("text.select", &json!({"story": sid.0, "anchor": 0})).unwrap(); s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); s.execute("text.select", &json!({"story": sid.0, "anchor": 1, "focus": 4})).unwrap(); let old = s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap()["swatch"].as_str().unwrap().to_string(); // The same unnamed colour on a cell, the cell's text and the footnote's text. s.edit(|d, _| { let st = d.story_mut(sid).unwrap(); let t = Arc::make_mut(st.tables.values_mut().next().unwrap()); t.cells[0].fill = old.clone(); t.cells[0].text.chars[0].format.over.fill = Some(old.clone()); Arc::make_mut(&mut st.notes[0]).text.chars[0].format.over.stroke = Some(old.clone()); Arc::make_mut(&mut st.endnotes[0]).text.chars[0].format.over.underline_color = Some(old.clone()); Ok(()) }) .unwrap(); s.execute("swatch.addToSwatches", &json!({"swatch": old, "name": "Sand"})).unwrap(); let d = &s.doc().unwrap().doc; let st = d.story(sid).unwrap(); assert_eq!(st.char_format_at(2).over.fill.as_deref(), Some("Sand")); let cell = &st.tables.values().next().unwrap().cells[0]; assert_eq!(cell.fill, "Sand"); assert_eq!(cell.text.chars[0].format.over.fill.as_deref(), Some("Sand")); assert_eq!(st.notes[0].text.chars[0].format.over.stroke.as_deref(), Some("Sand")); assert_eq!(st.endnotes[0].text.chars[0].format.over.underline_color.as_deref(), Some("Sand")); d.check().unwrap(); } } #[cfg(test)] mod group_sample_tests { use serde_json::json; use crate::Session; /// Grouping the sample's table styles keeps its table cells styled (no missing styles). #[test] fn grouping_reaches_table_cells() { let mut s = Session::new(); s.execute("file.newSample", &json!({})).unwrap(); let before = s.execute("preflight.run", &json!({})).unwrap()["errors"].as_u64().unwrap(); s.execute("style.group", &json!({"kind": "paragraph", "names": ["Table Head", "Table Body"], "group": "Tables"})).unwrap(); let after = s.execute("preflight.run", &json!({})).unwrap(); assert_eq!(after["errors"].as_u64().unwrap(), before, "{after}"); } } #[cfg(test)] mod color_group_tests { use serde_json::json; use crate::Session; #[test] fn color_groups_hold_swatches_and_round_trip() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let a = s.execute("swatch.create", &json!({"name": "Brand Red", "color": {"c": 0, "m": 100, "y": 100, "k": 0}})).unwrap()["name"] .as_str() .unwrap() .to_string(); let b = s.execute("swatch.create", &json!({"name": "Brand Blue", "color": {"c": 100, "m": 60, "y": 0, "k": 0}})).unwrap()["name"] .as_str() .unwrap() .to_string(); let g = s.execute("swatch.newColorGroup", &json!({"name": "Brand", "swatches": [a]})).unwrap(); assert_eq!(g["name"], "Brand"); assert!(s.execute("swatch.newColorGroup", &json!({"swatches": ["[Black]"]})).is_err(), "special swatches stay at the top"); s.execute("swatch.moveToGroup", &json!({"swatches": [b], "group": "Brand"})).unwrap(); let groups = |s: &Session| s.doc().unwrap().doc.color_groups.clone(); assert_eq!(groups(&s)[0].swatches, [a.clone(), b.clone()]); let bytes = designcraft_idml::export_idml(&s.doc().unwrap().doc); let back = designcraft_idml::import_idml(&bytes).unwrap(); assert_eq!(back.color_groups, groups(&s), "IDML ColorGroup"); s.execute("swatch.delete", &json!({"name": b})).unwrap(); assert_eq!(groups(&s)[0].swatches, [a.as_str()]); s.execute("swatch.renameColorGroup", &json!({"name": "Brand", "to": "Identity"})).unwrap(); s.execute("swatch.ungroupColorGroup", &json!({"name": "Identity"})).unwrap(); assert!(groups(&s).is_empty()); assert!(s.doc().unwrap().doc.swatch(&a).is_some(), "ungrouping keeps the swatches"); } } #[cfg(test)] mod ink_tests { use serde_json::json; use crate::Session; #[test] fn ink_manager_converts_and_aliases_spots_in_pdf() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); for (n, c) in [("Pantone A", [0, 50, 100, 0]), ("Pantone B", [100, 0, 0, 0])] { s.execute("swatch.create", &json!({"name": n, "spot": true, "color": {"c": c[0], "m": c[1], "y": c[2], "k": c[3]}})).unwrap(); } let id = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].clone(); s.execute("object.fill", &json!({"swatch": "Pantone A", "ids": [id]})).unwrap(); let pdf = |s: &mut Session| { let b = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); String::from_utf8_lossy(&super::super::file::base64_decode(&b)).into_owned() }; assert!(pdf(&mut s).contains("Pantone"), "a separation"); s.execute("ink.options", &json!({"ink": "Pantone A", "alias": "Pantone B"})).unwrap(); let p = pdf(&mut s); assert!(p.contains("Pantone#20B") || p.contains("Pantone B"), "aliased onto B"); assert!(!p.contains("Pantone#20A") && !p.contains("/Pantone A")); s.execute("ink.options", &json!({"allToProcess": true})).unwrap(); assert!(!pdf(&mut s).contains("Pantone"), "all spots to process"); let l = s.execute("ink.list", &json!({})).unwrap(); assert_eq!(l["inks"][0]["alias"], "Pantone B"); let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); assert!(back.inks.resolve("Pantone A").1, "IDML ConvertToProcess"); assert!(back.inks.aliases.iter().any(|(a, b)| a == "Pantone A" && b == "Pantone B"), "IDML AliasInkName"); assert!(s.execute("ink.options", &json!({"ink": "[Black]", "toProcess": true})).is_err()); } } #[cfg(test)] mod export_tag_tests { use serde_json::json; use crate::Session; #[test] fn export_tagging_shapes_epub_markup() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("style.paragraph.create", &json!({"name": "Chapter Title", "chars": {"size": 24}})).unwrap(); s.execute("style.character.create", &json!({"name": "Key Term", "chars": {"fontStyle": "Bold"}})).unwrap(); s.execute("style.exportTag", &json!({"style": "Chapter Title", "tag": "h1", "class": "chapter"})).unwrap(); s.execute("style.exportTag", &json!({"style": "Key Term", "character": true, "tag": "strong"})).unwrap(); assert!(s.execute("style.exportTag", &json!({"style": "Nope"})).is_err()); let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Origins\nA word"})).unwrap(); let sid = r["story"].as_u64().unwrap(); s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); s.execute("style.paragraph.apply", &json!({"name": "Chapter Title"})).unwrap(); s.execute("text.select", &json!({"story": sid, "anchor": 10, "focus": 14})).unwrap(); s.execute("style.character.apply", &json!({"name": "Key Term"})).unwrap(); let html = s.execute("file.exportHtml", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); assert!(html.contains("

Origins

"), "{html}"); assert!(html.contains("word"), "{html}"); // Tagged PDF: a heading element and a paragraph element. let r = s.execute("file.exportPdf", &json!({"tagged": true, "compress": false})).unwrap(); let pdf = super::super::file::base64_decode(r["base64"].as_str().unwrap()); let text = String::from_utf8_lossy(&pdf); assert!(text.contains("/S/H1") && text.contains("/S/P"), "structure elements"); assert!(designcraft_render::pdf_page_count(&pdf) == Some(1)); } } #[cfg(test)] mod cjk_tests { use super::*; #[test] fn cjk_composite_font_command_invalidates_layout_and_undo_restores_it() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 150], "content": "text", "text": "AB"})).unwrap(); let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); let definition = |scale| json!({"name": "Mixed", "entries": [{"family": "Source Serif 4", "relativeSize": scale}]}); s.execute("style.compositeFont.set", &definition(1.0)).unwrap(); s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 2})).unwrap(); s.execute("type.char", &json!({"fontFamily": "Mixed", "leadingAki": 0.25, "jidori": 4})).unwrap(); let old = s.cache.get(&s.doc().unwrap().doc, sid, None); s.execute("style.compositeFont.set", &definition(2.0)).unwrap(); let new = s.cache.get(&s.doc().unwrap().doc, sid, None); assert!(!std::sync::Arc::ptr_eq(&old, &new)); assert!(new.frames[0].lines[0].end_x > old.frames[0].lines[0].end_x); s.execute("edit.undo", &json!({})).unwrap(); let restored = s.cache.get(&s.doc().unwrap().doc, sid, None); assert_eq!(old.frames[0].lines[0].end_x, restored.frames[0].lines[0].end_x); assert!(s.execute("style.compositeFont.set", &definition(-1.0)).is_err()); } } #[cfg(test)] mod delete_tests { use designcraft_doc::{CellStyle, ObjectStyle, StoryId, Toc, TocEntry}; use serde_json::json; use crate::Session; /// Is `name` still named anywhere in the document? fn named(s: &Session, name: &str) -> bool { serde_json::to_string(&*s.doc().unwrap().doc).unwrap().contains(&format!("\"{name}\"")) } fn char_doc() -> (Session, u64) { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("style.character.create", &json!({"name": "Base", "chars": {"fontStyle": "Bold"}})).unwrap(); s.execute("style.character.create", &json!({"name": "Emphasis", "basedOn": "Base", "chars": {"tracking": 20}})).unwrap(); s.execute("style.character.create", &json!({"name": "Child", "basedOn": "Emphasis"})).unwrap(); s.execute("style.character.create", &json!({"name": "Strong", "chars": {"size": 14}})).unwrap(); let refs = json!({ "nestedStyles": [{"style": "Emphasis", "through": true, "count": 1, "until": {"kind": "words"}}], "grepStyles": [{"style": "Emphasis", "pattern": "\\d+"}], "nestedLineStyles": [{"style": "Emphasis", "lines": 1}], "dropCapLines": 2, "dropCapChars": 1, "dropCapStyle": "Emphasis"}); s.execute("style.paragraph.create", &json!({"name": "Lead", "para": refs})).unwrap(); s.execute("footnote.options", &json!({"refCharStyle": "Emphasis"})).unwrap(); let sid = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hi there"})).unwrap()["story"] .as_u64() .unwrap(); s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 2})).unwrap(); s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); // Local paragraph overrides name it too. s.execute("type.para", &json!({"attrs": refs})).unwrap(); s.edit(|d, _| { d.styles_mut().default_character = "Emphasis".into(); Ok(()) }) .unwrap(); assert!(named(&s, "Emphasis")); (s, sid) } #[test] fn deleting_a_character_style_points_every_use_at_the_replacement() { let (mut s, sid) = char_doc(); let undo = s.doc().unwrap().history.undo.len(); s.execute("style.character.delete", &json!({"name": "Emphasis", "replaceWith": "Strong"})).unwrap(); assert!(!named(&s, "Emphasis"), "no dangling reference"); let d = s.doc().unwrap().doc.clone(); assert_eq!(d.styles.char_style("Child").unwrap().based_on.as_deref(), Some("Strong")); assert_eq!(d.story(StoryId(sid)).unwrap().chars[0].format.style, "Strong"); let lead = &d.styles.para("Lead").unwrap().para; assert_eq!(lead.nested_styles.as_ref().unwrap()[0].style, "Strong"); assert_eq!(lead.grep_styles.as_ref().unwrap()[0].style, "Strong"); assert_eq!(lead.nested_line_styles.as_ref().unwrap()[0].style, "Strong"); assert_eq!(lead.drop_cap_style.as_deref(), Some("Strong")); assert_eq!(d.footnote_options.ref_char_style, "Strong"); assert_eq!(d.styles.default_character, "Strong"); // One undo step brings the style and every use back. assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); s.execute("edit.undo", &json!({})).unwrap(); assert!(s.doc().unwrap().doc.styles.char_style("Emphasis").is_some()); assert_eq!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().chars[0].format.style, "Emphasis"); } #[test] fn deleting_a_character_style_for_none_unsets_based_on() { let (mut s, _) = char_doc(); s.execute("style.character.delete", &json!({"name": "Emphasis"})).unwrap(); assert!(!named(&s, "Emphasis"), "no dangling reference"); let d = s.doc().unwrap().doc.clone(); assert_eq!(d.styles.char_style("Child").unwrap().based_on, None); assert_eq!(d.footnote_options.ref_char_style, designcraft_doc::NO_CHAR_STYLE); } /// Replacing a style with its own descendant rebases the deleted style's children on its /// parent, so no based-on chain becomes a cycle. #[test] fn replacing_a_style_with_its_child_makes_no_cycle() { let (mut s, _) = char_doc(); s.execute("style.character.create", &json!({"name": "Grandchild", "basedOn": "Child"})).unwrap(); s.execute("style.character.delete", &json!({"name": "Emphasis", "replaceWith": "Grandchild"})).unwrap(); let d = s.doc().unwrap().doc.clone(); assert_eq!(d.styles.char_style("Child").unwrap().based_on.as_deref(), Some("Base")); assert_eq!(d.styles.char_style("Grandchild").unwrap().based_on.as_deref(), Some("Child")); assert!(!named(&s, "Emphasis")); } #[test] fn bad_style_deletes_are_errors_and_change_nothing() { let (mut s, _) = char_doc(); s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap(); let before = s.doc().unwrap().doc.clone(); let undo = s.doc().unwrap().history.undo.len(); for (id, p) in [ ("style.character.delete", json!({})), ("style.character.delete", json!({"name": ""})), ("style.character.delete", json!({"name": "Nope"})), ("style.character.delete", json!({"name": "[None]"})), ("style.character.delete", json!({"name": "Emphasis", "replaceWith": "Nope"})), ("style.character.delete", json!({"name": "Emphasis", "replaceWith": "Emphasis"})), ("style.paragraph.delete", json!({})), ("style.paragraph.delete", json!({"name": ""})), ("style.paragraph.delete", json!({"name": "Nope"})), ("style.paragraph.delete", json!({"name": "[Basic Paragraph]"})), ("style.paragraph.delete", json!({"name": "Head", "replaceWith": "Nope"})), ("style.paragraph.delete", json!({"name": "Head", "replaceWith": "Head"})), ] { assert!(s.execute(id, &p).is_err(), "{id} {p}"); } assert_eq!(*s.doc().unwrap().doc, *before); assert_eq!(s.doc().unwrap().history.undo.len(), undo); } #[test] fn deleting_a_paragraph_style_points_every_use_at_the_replacement() { let mut s = Session::new(); s.execute("file.newSample", &json!({})).unwrap(); s.execute("style.paragraph.create", &json!({"name": "Sub", "basedOn": "Table Body", "nextStyle": "Table Body"})).unwrap(); s.edit(|d, _| { let story = *d.stories.keys().next().ok_or(crate::EngineError::NoDocument)?; let st = d.styles_mut(); st.default_paragraph = "Table Body".into(); st.object.push(ObjectStyle { name: "Boxed".into(), paragraph_style: Some("Table Body".into()), ..Default::default() }); st.cell.push(CellStyle { name: "Body Cell".into(), paragraph_style: Some("Table Body".into()), ..Default::default() }); d.footnote_options.para_style = "Table Body".into(); d.endnote_options.para_style = "Table Body".into(); d.endnote_options.heading_style = "Table Body".into(); d.toc = Some(Toc { story, title: "Contents".into(), entries: vec![TocEntry { style: "Table Body".into(), level: 1 }], page_numbers: false }); Ok(()) }) .unwrap(); assert!(named(&s, "Table Body")); s.execute("style.paragraph.delete", &json!({"name": "Table Body", "replaceWith": "Body"})).unwrap(); assert!(!named(&s, "Table Body"), "no dangling reference"); let d = s.doc().unwrap().doc.clone(); let sub = d.styles.para("Sub").unwrap(); assert_eq!((sub.based_on.as_deref(), sub.next_style.as_deref()), (Some("Body"), Some("Body"))); assert_eq!(d.styles.default_paragraph, "Body"); assert_eq!(d.endnote_options.heading_style, "Body"); let after = s.execute("preflight.run", &json!({})).unwrap(); assert!(!after.to_string().contains("Table Body"), "{after}"); } } #[cfg(test)] mod swatch_options_tests { use designcraft_color::SwatchValue; use designcraft_doc::{ItemId, StoryId}; use serde_json::json; use crate::Session; /// A document whose frame, text, styles, tint swatch and colour group all use "Brand". fn brand_doc() -> (Session, u64, u64) { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("swatch.create", &json!({"name": "Brand", "color": {"c": 0, "m": 80, "y": 100, "k": 0}})).unwrap(); s.execute("swatch.create", &json!({"name": "Other", "color": "#108080"})).unwrap(); let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hello"})).unwrap(); let (id, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); s.execute("object.fill", &json!({"swatch": "Brand", "ids": [id]})).unwrap(); s.execute("object.stroke", &json!({"swatch": "Brand", "ids": [id]})).unwrap(); s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); s.execute("type.char", &json!({"attrs": {"fill": "Brand"}})).unwrap(); s.execute("text.select", &json!({"story": sid, "anchor": 5, "focus": 5})).unwrap(); s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); s.execute("table.select", &json!({"what": "table"})).unwrap(); s.execute("table.setCell", &json!({"stroke": {"weight": 2, "type": "dashed", "gapColor": "Brand", "edges": ["all"]}})).unwrap(); s.execute("swatch.newColorGroup", &json!({"name": "Brand Colors", "swatches": ["Brand"]})).unwrap(); s.execute("style.character.create", &json!({"name": "Accent"})).unwrap(); s.edit(|d, _| { d.swatches.push(designcraft_color::Swatch { name: "Brand 50%".into(), value: SwatchValue::Tint { base: "Brand".into(), tint: 0.5 }, locked: false, named: true, hidden: false, }); let st = d.styles_mut(); if let Some(p) = st.para_mut(designcraft_doc::BASIC_PARAGRAPH) { p.chars.underline_color = Some("Brand".into()); p.para.shading_color = Some("Brand".into()); p.para.border_gap_color = Some("Brand".into()); p.chars.ruby_stroke = Some("Brand".into()); } if let Some(c) = st.char_style_mut("Accent") { c.chars.fill = Some("Brand".into()); c.chars.kenten_fill = Some("Brand".into()); } Ok(()) }) .unwrap(); (s, id, sid) } fn uses(s: &Session, id: u64, sid: u64, name: &str) -> Vec { let d = &s.doc().unwrap().doc; let it = d.item(ItemId(id)).unwrap(); let story = d.story(StoryId(sid)).unwrap(); let basic = d.styles.para(designcraft_doc::BASIC_PARAGRAPH).unwrap(); vec![ it.fill.swatch == name, it.stroke.swatch == name, story.chars.iter().any(|r| r.format.over.fill.as_deref() == Some(name)), basic.chars.underline_color.as_deref() == Some(name), basic.para.shading_color.as_deref() == Some(name), d.styles.char_style("Accent").unwrap().chars.fill.as_deref() == Some(name), matches!(&d.swatch("Brand 50%").unwrap().value, SwatchValue::Tint { base, .. } if base == name), d.color_groups[0].swatches == [name], basic.para.border_gap_color.as_deref() == Some(name), basic.chars.ruby_stroke.as_deref() == Some(name), d.styles.char_style("Accent").unwrap().chars.kenten_fill.as_deref() == Some(name), story.tables.values().any(|t| t.cells.iter().all(|c| c.strokes.iter().all(|e| e.gap_color == name))), ] } #[test] fn renaming_a_swatch_renames_every_use_in_one_undo_step() { let (mut s, id, sid) = brand_doc(); assert!(uses(&s, id, sid, "Brand").iter().all(|u| *u)); let r = s.execute("swatch.options", &json!({"name": "Brand", "to": "Signal Orange", "spot": true})).unwrap(); assert_eq!(r["name"], "Signal Orange"); let d = &s.doc().unwrap().doc; assert!(d.swatch("Brand").is_none()); assert!(matches!(d.swatch("Signal Orange").unwrap().value, SwatchValue::Color { color_type: designcraft_color::ColorType::Spot, .. })); assert_eq!(uses(&s, id, sid, "Signal Orange"), vec![true; 12]); s.execute("edit.undo", &json!({})).unwrap(); assert!(s.doc().unwrap().doc.swatch("Brand").is_some()); assert_eq!(uses(&s, id, sid, "Brand"), vec![true; 12]); } #[test] fn bad_swatch_renames_change_nothing() { let (mut s, id, sid) = brand_doc(); let before = s.doc().unwrap().doc.clone(); for p in [ json!({"name": "Brand", "to": "Other"}), json!({"name": "Brand", "to": " "}), json!({"name": "Brand", "to": "[Paper]"}), json!({"name": "[Black]", "to": "Ink"}), json!({"name": "[Registration]", "to": "Reg"}), json!({"name": "[None]", "to": "Nothing"}), json!({"name": "[Black]", "color": "#ff0000"}), json!({"name": "Brand 50%", "color": "#ff0000"}), json!({"name": "Missing", "to": "X"}), ] { assert!(s.execute("swatch.options", &p).is_err(), "{p}"); } assert_eq!(s.doc().unwrap().doc.swatches, before.swatches); assert_eq!(uses(&s, id, sid, "Brand"), vec![true; 12]); // [Paper]'s colour can change; its name can't. s.execute("swatch.options", &json!({"name": "[Paper]", "to": "[Paper]", "color": "#fff8e0"})).unwrap(); } #[test] fn name_with_color_value_tracks_the_colour() { let (mut s, id, sid) = brand_doc(); let r = s.execute("swatch.options", &json!({"name": "Brand", "nameWithValue": true, "color": {"c": 10, "m": 20, "y": 30, "k": 40}})).unwrap(); assert_eq!(r["name"], "C=10 M=20 Y=30 K=40"); assert_eq!(uses(&s, id, sid, "C=10 M=20 Y=30 K=40"), vec![true; 12]); // Later colour edits keep the name in step until the swatch is named by hand. let r = s.execute("swatch.options", &json!({"name": "C=10 M=20 Y=30 K=40", "color": {"c": 0, "m": 0, "y": 0, "k": 50}})).unwrap(); assert_eq!(r["name"], "C=0 M=0 Y=0 K=50"); let r = s.execute("swatch.options", &json!({"name": "C=0 M=0 Y=0 K=50", "to": "Warm Gray"})).unwrap(); assert_eq!(r["name"], "Warm Gray"); assert!(s.doc().unwrap().doc.swatch("Warm Gray").unwrap().named); } } #[cfg(test)] mod grep_style_tests { use serde_json::json; use crate::Session; #[test] fn grep_style_pattern_that_cannot_compile_is_rejected() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("style.character.create", &json!({"name": "Keep"})).unwrap(); let bad = json!([{"style": "Keep", "pattern": "(\\d+"}]); let e = s.execute("style.paragraph.create", &json!({"name": "Broken", "para": {"grepStyles": bad}})).unwrap_err().to_string(); assert!(e.contains("bad GREP") && e.contains("(\\\\d+"), "{e}"); assert!(s.doc().unwrap().doc.styles.para("Broken").is_none()); // The lookbehind dialect compiles. let good = json!([{"style": "Keep", "pattern": "(?<=\\s)[aiouwzAIOUWZ]\\s"}]); s.execute("style.paragraph.create", &json!({"name": "Polish", "para": {"grepStyles": good}})).unwrap(); let e = s.execute("style.paragraph.edit", &json!({"name": "Polish", "para": {"grepStyles": bad, "spaceAfter": 6}})).unwrap_err().to_string(); assert!(e.contains("bad GREP"), "{e}"); let st = s.doc().unwrap().doc.styles.para("Polish").unwrap().clone(); assert_eq!(st.para.grep_styles.unwrap()[0].pattern, "(?<=\\s)[aiouwzAIOUWZ]\\s"); assert_eq!(st.para.space_after, None, "nothing changes"); // A paragraph override too. let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Ala i kot"})).unwrap(); s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); let e = s.execute("type.para", &json!({"grepStyles": bad})).unwrap_err().to_string(); assert!(e.contains("bad GREP"), "{e}"); } #[test] fn idml_import_keeps_a_broken_grep_style_and_warns() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); s.execute("style.character.create", &json!({"name": "Keep"})).unwrap(); s.execute("style.paragraph.create", &json!({"name": "Broken"})).unwrap(); // Written past the command, as another application could. s.edit(|d, _| { if let Some(st) = d.styles_mut().para_mut("Broken") { st.para.grep_styles = Some(vec![designcraft_doc::GrepStyle { style: "Keep".into(), pattern: "[a-".into() }]); } Ok(()) }) .unwrap(); let b64 = s.execute("file.exportIdml", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); let r = s.execute("file.openIdml", &json!({"base64": b64})).unwrap(); let warnings = r["warnings"].to_string(); assert!(warnings.contains("Broken") && warnings.contains("[a-") && warnings.contains("bad GREP"), "{warnings}"); let st = s.doc().unwrap().doc.styles.para("Broken").unwrap().clone(); assert_eq!(st.para.grep_styles.unwrap()[0].pattern, "[a-", "the style opens with its pattern"); } } #[cfg(test)] mod sample_color_tests { use designcraft_color::{Color, SwatchValue}; use designcraft_doc::ItemId; use serde_json::json; use crate::Session; /// A 2×1 image, red then blue, placed 144 pt wide at (72, 72): each pixel is 72 pt. fn session_with_image() -> (Session, u64) { let png = designcraft_render::Rendered { width: 2, height: 1, pixels: vec![255, 0, 0, 255, 0, 0, 255, 255] }.to_png(); let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let b64 = super::super::file::base64_encode(&png); let r = s.execute("file.place", &json!({"base64": b64, "name": "rb.png", "x": 72, "y": 72, "width": 144})).unwrap(); (s, r["id"].as_u64().unwrap()) } fn swatch_color(s: &Session, name: &str) -> Color { match &s.doc().unwrap().doc.swatch(name).unwrap().value { SwatchValue::Color { color, .. } => *color, other => panic!("{other:?}"), } } #[test] fn the_eyedropper_colours_selected_text_with_the_image_under_it() { let (mut s, image) = session_with_image(); let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Elevate."})).unwrap(); let fid = ItemId(f["id"].as_u64().unwrap()); let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 7})).unwrap(); let undo = s.doc().unwrap().history.undo.len(); let r = s.execute("color.sample", &json!({"at": [180, 100]})).unwrap(); assert_eq!((r["color"].as_str(), r["from"].as_u64()), (Some("#0000ff"), Some(image)), "{r}"); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); let name = r["swatch"].as_str().unwrap().to_string(); assert_eq!(swatch_color(&s, &name), Color::rgb8(0, 0, 255)); assert!(s.doc().unwrap().doc.swatch(&name).unwrap().hidden, "an unnamed colour"); let st = s.doc().unwrap().doc.story(story).unwrap().clone(); assert_eq!(st.format_after(0).over.fill.as_deref(), Some(name.as_str())); assert_eq!(st.format_after(7).over.fill, None, "the full stop keeps its colour"); // The frame itself is not coloured. assert!(s.doc().unwrap().doc.item(fid).unwrap().fill.is_none()); } #[test] fn the_eyedropper_fills_selected_objects_and_samples_locked_images() { let (mut s, _) = session_with_image(); let lid = s.doc().unwrap().doc.layers[0].id.0; s.execute("layer.set", &json!({"id": lid, "locked": true})).unwrap(); s.execute("layer.new", &json!({"name": "TEXT"})).unwrap(); let rect = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400]})).unwrap()["id"].as_u64().unwrap(); s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); let r = s.execute("color.sample", &json!({"at": [100, 100], "target": "stroke"})).unwrap(); assert_eq!(r["color"], "#ff0000", "{r}"); let it = s.doc().unwrap().doc.item(ItemId(rect)).unwrap().clone(); assert_eq!(swatch_color(&s, &it.stroke.swatch), Color::rgb8(255, 0, 0)); // Sampling the same colour again reuses its swatch. let again = s.execute("color.sample", &json!({"at": [90, 90]})).unwrap(); assert_eq!(again["swatch"], r["swatch"]); } #[test] fn a_caret_takes_the_sample_for_typing_and_nothing_to_sample_is_refused() { let (mut s, _) = session_with_image(); let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Hi"})).unwrap(); let fid = ItemId(f["id"].as_u64().unwrap()); let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; s.execute("text.select", &json!({"story": story.0, "anchor": 1, "focus": 1})).unwrap(); // A caret: the sampled colour is for the next typing. let r = s.execute("color.sample", &json!({"at": [100, 100]})).unwrap(); s.execute("text.insert", &json!({"text": "X"})).unwrap(); let st = s.doc().unwrap().doc.story(story).unwrap().clone(); assert_eq!(st.format_after(1).over.fill.as_deref(), r["swatch"].as_str(), "{r}"); assert_eq!(st.format_after(0).over.fill, None, "the text before the caret keeps its colour"); let undo = s.doc().unwrap().history.undo.len(); s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap(); let none = s.execute("color.sample", &json!({"at": [600, 600]})).unwrap_err().to_string(); assert!(none.contains("no placed image"), "{none}"); // Just past the image's right edge. assert!(s.execute("color.sample", &json!({"at": [216.5, 100]})).is_err()); assert_eq!(s.doc().unwrap().history.undo.len(), undo); } #[test] fn where_no_image_pixel_shows_the_clicked_frame_is_matched_instead() { let (mut s, image) = session_with_image(); let rect = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400]})).unwrap()["id"].as_u64().unwrap(); s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [image]})).unwrap(); // The frame reaches 72 pt below its image, and a clipping path shows the left (red) pixel only. s.edit(|d, _| { let it = d.item_mut(ItemId(image)).ok_or(crate::EngineError::NoDocument)?; let b = it.inner_bounds(); it.path = designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(b.x0, b.y0, b.x1, b.y1 + 72.0)); let designcraft_doc::Content::Graphic(g) = &mut it.content else { return Err(crate::EngineError::NoDocument) }; let left = designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, g.size.0 / 2.0, g.size.1)); g.clip = Some(std::sync::Arc::new(designcraft_doc::ClippingPath::new(designcraft_doc::ClippingKind::PhotoshopPath, left))); Ok(()) }) .unwrap(); s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); let r = s.execute("color.sample", &json!({"at": [100, 100], "matchFrom": image})).unwrap(); assert_eq!(r["color"], "#ff0000", "inside the clipping path the image is sampled"); // The clipped-out blue pixel and the empty frame below the image: nothing to sample, so the // frame's attributes apply. for at in [[180, 100], [100, 180]] { assert!(s.execute("color.sample", &json!({"at": at})).is_err(), "{at:?}"); s.execute("object.fill", &json!({"swatch": "[Paper]", "ids": [rect]})).unwrap(); s.execute("color.sample", &json!({"at": at, "matchFrom": image})).unwrap(); assert_eq!(s.doc().unwrap().doc.item(ItemId(rect)).unwrap().fill.swatch, "[Black]", "{at:?}"); } } #[test] fn sampling_and_explicit_colour_use_the_same_text_application_path() { for command in ["color.sample", "object.color"] { let (mut s, _) = session_with_image(); let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text"})).unwrap(); let story = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); s.execute("text.select", &json!({"story": story.0, "anchor": 0})).unwrap(); let undo = s.doc().unwrap().history.undo.len(); let r = s.execute(command, &json!({"at": [100, 100], "color": "#ff0000"})).unwrap(); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{command}"); s.execute("text.insert", &json!({"text": "Hi"})).unwrap(); let text = s.doc().unwrap().doc.story(story).unwrap(); assert_eq!(text.format_after(0).over.fill.as_deref(), r["swatch"].as_str(), "empty-story typing: {command}"); // Explicit ids override text targeting, including with a text selection active. let fid = ItemId(f["id"].as_u64().unwrap()); s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap(); let r = s.execute(command, &json!({"at": [180, 100], "color": "#0000ff", "id": fid.0})).unwrap(); assert_eq!(s.doc().unwrap().doc.item(fid).unwrap().fill.swatch, r["swatch"].as_str().unwrap()); let text = s.doc().unwrap().doc.story(story).unwrap(); assert_ne!(text.format_after(0).over.fill.as_deref(), r["swatch"].as_str(), "text unchanged: {command}"); let r = s.execute(command, &json!({"at": [180, 100], "color": "#0000ff", "target": "stroke"})).unwrap(); let text = s.doc().unwrap().doc.story(story).unwrap(); assert_eq!(text.format_after(0).over.stroke.as_deref(), r["swatch"].as_str(), "selected text stroke: {command}"); } } #[test] fn sampling_with_the_stroke_in_front_colours_a_lines_stroke() { let (mut s, _) = session_with_image(); let line = s.execute("line.create", &json!({"a": [300, 300], "b": [500, 300]})).unwrap()["id"].as_u64().unwrap(); s.execute("selection.set", &json!({"ids": [line]})).unwrap(); let r = s.execute("color.sample", &json!({"at": [100, 100], "target": "stroke"})).unwrap(); let it = s.doc().unwrap().doc.item(ItemId(line)).unwrap().clone(); assert_eq!(it.stroke.swatch, r["swatch"].as_str().unwrap(), "{r}"); assert!(it.fill.is_none(), "the fill stays empty"); } } #[cfg(test)] mod radial_gradient_tests { use serde_json::json; /// White at the side and the top of a 100×200 radial (black → white) rectangle at (50, 20) on /// a 200×240 page, rendered at 1 px/pt. fn sides(img: &designcraft_render::Rendered) -> (u8, u8, u8) { (img.pixel(100, 120)[0], img.pixel(52, 120)[0], img.pixel(100, 22)[0]) } #[test] fn a_radial_gradient_is_the_objects_ellipse_on_screen_and_in_pdf() { let mut s = crate::Session::new(); s.execute("file.new", &json!({"width": 200, "height": 240, "facingPages": false})).unwrap(); let id = s.execute("frame.create", &json!({"rect": [50, 20, 150, 220], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); s.execute("object.stroke", &json!({"ids": [id], "swatch": "[None]"})).unwrap(); s.execute( "object.gradient", &json!({"ids": [id], "kind": "radial", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}), ) .unwrap(); let d = s.doc().unwrap().doc.clone(); let mut r = designcraft_render::Renderer::new(); let (c, side, top) = sides(&r.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap()); assert!(c < 30 && side > 220 && top > 220, "screen: centre {c}, side {side}, top {top}"); // The PDF, placed back and drawn: the same ellipse. let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); let mut p = crate::Session::new(); p.execute("file.new", &json!({"width": 200, "height": 240, "facingPages": false})).unwrap(); p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "radial.pdf", "x": 0, "y": 0, "width": 200})) .unwrap(); let d = p.doc().unwrap().doc.clone(); let (c, side, top) = sides(&r.render_page(&d, &p.cache, 0, 1.0, false, &Default::default()).unwrap()); assert!(c < 30 && side > 220 && top > 220, "pdf: centre {c}, side {side}, top {top}"); } } #[cfg(test)] mod gradient_swatch_tests { use designcraft_color::SwatchValue; use serde_json::json; fn doc() -> crate::Session { let mut s = crate::Session::new(); s.execute("file.new", &json!({})).unwrap(); s } #[test] fn a_gradient_swatch_from_colour_swatches() { let mut s = doc(); // As the Gradient panel's stops take swatches dragged onto them. let r = s .execute( "swatch.create", &json!({"gradient": {"stops": [{"location": 100, "swatch": "C=100 M=0 Y=0 K=0"}, {"location": 0, "swatch": "C=0 M=100 Y=0 K=0"}]}}), ) .unwrap(); assert_eq!(r["name"], "New Gradient Swatch"); let d = &s.doc().unwrap().doc; let w = d.swatch("New Gradient Swatch").unwrap(); assert!(w.named && !w.hidden); let SwatchValue::Gradient { gradient } = &w.value else { panic!("{w:?}") }; assert_eq!(gradient.stops[0].color, d.resolve_color("C=0 M=100 Y=0 K=0", 1.0).unwrap(), "sorted: magenta first"); assert_eq!(gradient.stops[1].color, d.resolve_color("C=100 M=0 Y=0 K=0", 1.0).unwrap()); // A second one gets its own name; bad stops are refused. let r = s .execute( "swatch.create", &json!({"gradient": {"kind": "radial", "stops": [{"color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]}}), ) .unwrap(); assert_eq!(r["name"], "New Gradient Swatch 2"); for bad in [ json!({"stops": [{"swatch": "C=0 M=100 Y=0 K=0"}]}), json!({"stops": [{"swatch": "Nope"}, {"swatch": "[Black]"}]}), json!({"stops": [{"swatch": "New Gradient Swatch"}, {"swatch": "[Black]"}]}), json!({"kind": "conic", "stops": [{"color": "#000000"}, {"color": "#ffffff"}]}), ] { assert!(s.execute("swatch.create", &json!({"gradient": bad})).is_err(), "{bad}"); } } #[test] fn new_swatch_names_the_selections_gradient_or_duplicates_a_swatch() { let mut s = doc(); let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); // A gradient made in the Gradient panel is unnamed (and not listed) … s.execute("object.gradient", &json!({"ids": [id], "stops": [{"location": 0, "color": {"c": 0, "m": 100, "y": 0, "k": 0}}, {"location": 100, "color": {"c": 100, "m": 0, "y": 0, "k": 0}}]})).unwrap(); let fill = |s: &crate::Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch.clone(); assert!(s.doc().unwrap().doc.swatch(&fill(&s)).unwrap().hidden); // … until New Swatch: "New Gradient Swatch", listed, still the object's fill. s.execute("selection.set", &json!({"ids": [id]})).unwrap(); assert_eq!(s.execute("swatch.new", &json!({})).unwrap()["name"], "New Gradient Swatch"); assert_eq!(fill(&s), "New Gradient Swatch"); let w = s.doc().unwrap().doc.swatch("New Gradient Swatch").unwrap().clone(); assert!(w.named && !w.hidden && matches!(w.value, SwatchValue::Gradient { .. })); // A named swatch: a copy. assert_eq!(s.execute("swatch.new", &json!({})).unwrap()["name"], "New Gradient Swatch copy"); assert_eq!(s.execute("swatch.new", &json!({"swatch": "C=100 M=0 Y=0 K=0"})).unwrap()["name"], "C=100 M=0 Y=0 K=0 copy"); assert!(s.execute("swatch.new", &json!({"swatch": "[None]"})).is_err()); // The stroke in front: the stroke's swatch. s.execute("object.stroke", &json!({"ids": [id], "swatch": "C=100 M=0 Y=0 K=0"})).unwrap(); assert_eq!(s.execute("swatch.new", &json!({"target": "stroke"})).unwrap()["name"], "C=100 M=0 Y=0 K=0 copy 2"); // Nothing selected, no swatch given: a new colour swatch. s.execute("edit.deselectAll", &json!({})).unwrap(); let n = s.execute("swatch.new", &json!({})).unwrap()["name"].as_str().unwrap().to_string(); let w = s.doc().unwrap().doc.swatch(&n).unwrap(); assert!(w.named && !w.hidden && matches!(w.value, SwatchValue::Color { .. }), "{w:?}"); } }