Files
designcraft/crates/engine/src/cmd/style.rs
T
aiongg 8f1c60e4fc Merge upstream main 835edae6
Brings #514 (MCP request replay), #523 final (already in next), #403
(character fill must name a swatch), #520 and #521 finals (already in
next as their PR heads).

Conflict: crates/engine/src/cmd/object.rs: next's side throughout (transform.move doc with content?, object.fill with tint alone from #525, move_content, reorder/content_move/group/tint_alone test modules); upstream's copy_ids_tests already in next
Conflict: crates/engine/src/cmd/text.rs: both; #403's validate_char_fill after next's ruby_cmd
Conflict: crates/pdf/src/export.rs: next's side (item/item_guarded cycle guard, frame_objects; upstream's anchored loop dropped as in d11ccad3)
Conflict: crates/render/src/lib.rs: next's side (drawing_items, draw_item/draw_item_inner)
2026-10-12 04:15:40 +00:00

2054 lines
100 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, 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?: {…}} — the style sets only `chars` → {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?}", super::has_selection, apply_object),
cmd!("style.object.create", "New Object Style…", [], None, "{name, fromSelection?: true, fill?, paragraphStyle?}", has_doc, create_object),
cmd!(
"swatch.create",
"New Color Swatch…",
[],
None,
"{name?, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b], spot?: bool}",
has_doc,
create_swatch
),
cmd!(
"object.gradient",
"Gradient",
[],
None,
"{kind?: linear|radial, stops?: [{location: 0–100, color: \"#rrggbb\"|{c,m,y,k}|[r,g,b], 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), 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!(
"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))?;
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;
}
}
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.create")?;
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 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.create")?;
// 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> {
let c = p.get("color").ok_or_else(|| bad("swatch.create", "missing color"))?;
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}))
})
}
pub fn parse_color(v: &Value) -> Option<designcraft_color::Color> {
use designcraft_color::Color;
match v {
Value::String(s) => Color::from_hex(s),
// 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,
}
}
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();
if let Some(f) = &os.fill {
it.fill = f.clone();
}
if let Some(st) = &os.stroke {
it.stroke = st.clone();
}
if let Some(tf) = it.text_frame_mut() {
if let Some(o) = &os.text_frame {
tf.options = o.clone();
}
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 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 = designcraft_doc::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());
}
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());
}
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}))
})
}
/// 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, _| {
let (mut added, mut replaced) = (0, 0);
for w in incoming {
match d.swatches.iter_mut().find(|x| x.name == w.name) {
Some(x) if x.locked => {}
Some(x) => {
if replace {
x.value = w.value;
x.hidden = false;
replaced += 1;
}
}
None => {
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 mut v = Vec::new();
for st in a {
let color = st.get("color").and_then(parse_color).ok_or_else(|| bad("object.gradient", "a stop needs a color"))?;
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();
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 exactly this colour, else add an unnamed one.
let existing = d
.swatches
.iter()
.find(|w| matches!(&w.value, SwatchValue::Color { color: c, color_type: designcraft_color::ColorType::Process } if *c == color))
.map(|w| w.name.clone());
let name = match existing {
Some(n) => n,
None => {
let name = Styles::unique_name(|n| d.swatch(n).is_some(), &value_name(color));
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)
}
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_rejects_inherited_unknown_name_when_creating_from_selection() {
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();
assert_fill_rejected(&mut session, "style.paragraph.create", json!({"name": "Inherited", "fromSelection": true}));
}
#[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 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 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");
}
}
#[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");
}
}