Files
designcraft/crates/engine/src/cmd/style.rs
T

2857 lines
144 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<CommandSpec> {
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::<Vec<_>>(),
"character": st.doc.styles.character.iter().map(|p| &p.name).collect::<Vec<_>>(),
"object": st.doc.styles.object.iter().map(|p| &p.name).collect::<Vec<_>>(),
"cell": st.doc.styles.cell.iter().map(|p| &p.name).collect::<Vec<_>>(),
"table": st.doc.styles.table.iter().map(|p| &p.name).collect::<Vec<_>>(),
}))
}),
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<Value> = 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<String> = 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<String> = 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<T: Default>(v: Option<&Value>, set: impl Fn(&mut T, &str, &Value) -> std::result::Result<(), String>) -> Result<T> {
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<Value> {
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<Value> {
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<Value> {
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", &para)?;
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::<designcraft_doc::ParaProps>(cur["para"].clone()) {
let mut full = ParaProps_to_attrs(&pp);
full.merge(&para);
para = full;
}
if let Ok(cp) = serde_json::from_value::<designcraft_doc::CharProps>(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<String> {
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<String> {
let mut out: Vec<String> = 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<Value> {
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, &current))?;
continue_numbering(p.get("para"), &mut para);
explicit_default_kinsoku(&mut para, p.get("para"));
check_grep_styles("style.paragraph.edit", &para)?;
let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?;
st.para.merge(&para);
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::<Vec<_>>() {
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<Value> {
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<String> =
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<Value> {
delete_style(s, p, true)
}
fn delete_char(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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::<Vec<_>>()
} 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<Value> {
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<Value> {
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<String> = 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<Option<String>> = 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<Value> {
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<Value> {
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 "<name> copy".
fn new_swatch(s: &mut Session, p: &Value) -> Result<Value> {
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<designcraft_color::Color> {
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<designcraft_color::Color> {
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<Value> {
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<Value> {
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<Item> = 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.<key>`.
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<Option<Effects>> {
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<Value> {
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<Value> {
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<Value> {
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<String, usize> = 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<Value> {
let d = &s.doc()?.doc;
let names: Option<Vec<String>> =
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<Swatch> = 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<Value> = 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<Value> {
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<Value> {
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<Value> {
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, &params)
}
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, &params)?;
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(&region, 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<Value> {
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<Value> {
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, &params);
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", &params).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", &params).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("<h1 class=\"chapter\">Origins</h1>"), "{html}");
assert!(html.contains("<strong class=\"key-term\">word</strong>"), "{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<bool> {
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:?}");
}
}