1851 lines
88 KiB
Rust
1851 lines
88 KiB
Rust
//! 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?: {…}, 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}, 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!(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)",
|
||
has_text_or_selection,
|
||
apply_color
|
||
),
|
||
cmd!(
|
||
"color.sample",
|
||
"Sample Color (Eyedropper)",
|
||
[],
|
||
None,
|
||
"{at: [x,y] (spread coords), spread?, target?: fill|stroke, ids?} → {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",
|
||
has_text_or_selection,
|
||
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.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()
|
||
})
|
||
}
|
||
|
||
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(k, v))?;
|
||
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 = 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}))
|
||
})
|
||
}
|
||
|
||
#[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 para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, ¤t))?;
|
||
let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?;
|
||
st.para.merge(¶);
|
||
st.chars.merge(&chars);
|
||
match based {
|
||
Some(Value::String(b)) => st.based_on = Some(b),
|
||
Some(Value::Null) => st.based_on = None,
|
||
_ => {}
|
||
}
|
||
if let Some(n) = rename.clone() {
|
||
// Every use: other styles, stories, table cells, footnotes, object styles.
|
||
rename_style(d, true, &name, &n);
|
||
}
|
||
ok()
|
||
})
|
||
}
|
||
|
||
/// Rename a paragraph or character style everywhere it's used.
|
||
fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &str) {
|
||
let 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());
|
||
}
|
||
}
|
||
}
|
||
for o in &mut st.object {
|
||
if o.paragraph_style.as_deref() == Some(from) {
|
||
o.paragraph_style = Some(to.to_string());
|
||
}
|
||
}
|
||
} 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);
|
||
}
|
||
if d.footnote_options.ref_char_style == from {
|
||
d.footnote_options.ref_char_style = to.to_string();
|
||
}
|
||
}
|
||
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 += 1;
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Point the nested, nested line and GREP 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));
|
||
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> {
|
||
let name = str_param(p, "name").unwrap_or("").to_string();
|
||
let repl = str_param(p, "replaceWith").unwrap_or(designcraft_doc::BASIC_PARAGRAPH).to_string();
|
||
if name.starts_with('[') {
|
||
return Err(bad("style.paragraph.delete", "built-in styles can't be deleted"));
|
||
}
|
||
s.edit(|d, _| {
|
||
d.styles_mut().paragraph.retain(|x| x.name != name);
|
||
for sid in d.stories.keys().copied().collect::<Vec<_>>() {
|
||
if let Some(story) = d.story_mut(sid) {
|
||
for f in &mut story.paras {
|
||
if f.style == name {
|
||
f.style = repl.clone();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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()?;
|
||
// Match paragraph fromSelection's first-character/caret policy, but keep paragraph
|
||
// attributes inherited instead of freezing the fully resolved character properties.
|
||
let format = super::text::effective_char_format(s, target)?;
|
||
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> {
|
||
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(|| match color {
|
||
designcraft_color::Color::Cmyk { c, m, y, k } => designcraft_color::swatch::cmyk_name(c, m, y, k),
|
||
other => {
|
||
let [r, g, b] = other.to_rgb();
|
||
format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round())
|
||
}
|
||
});
|
||
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) => {
|
||
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 {
|
||
match color {
|
||
designcraft_color::Color::Cmyk { c, m, y, k } => designcraft_color::swatch::cmyk_name(c, m, y, k),
|
||
other => {
|
||
let [r, g, b] = other.to_rgb();
|
||
format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round())
|
||
}
|
||
}
|
||
}
|
||
|
||
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 named = p.get("ids").is_some() || p.get("id").is_some();
|
||
let text = !named
|
||
&& (p.get("text").and_then(Value::as_bool).unwrap_or(false) || s.doc()?.selection.text.is_some() || s.doc()?.selection.cells.is_some());
|
||
let (ids, texts) = if text { (vec![], super::text::format_targets_pub(s)) } else { (super::targets(s, p)?, vec![]) };
|
||
if text && texts.is_empty() {
|
||
return Err(bad("object.color", "select the text to colour"));
|
||
}
|
||
// 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, _| {
|
||
let name = color_swatch(d, color);
|
||
color_items(d, &ids, &name, stroke);
|
||
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)
|
||
}
|
||
|
||
/// A process swatch holding exactly `color`, else a new unnamed one (hidden from the Swatches
|
||
/// panel until Add to Swatches).
|
||
fn color_swatch(d: &mut designcraft_doc::Document, color: designcraft_color::Color) -> String {
|
||
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());
|
||
if let Some(n) = existing {
|
||
return n;
|
||
}
|
||
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
|
||
}
|
||
|
||
fn color_items(d: &mut designcraft_doc::Document, ids: &[designcraft_doc::ItemId], name: &str, stroke: bool) {
|
||
for id in ids {
|
||
if let Some(it) = d.item_mut(*id) {
|
||
if stroke {
|
||
it.stroke.swatch = name.to_string();
|
||
it.stroke.tint = 1.0;
|
||
} else {
|
||
it.fill.swatch = name.to_string();
|
||
it.fill.tint = 1.0;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn has_text_or_selection(s: &Session) -> std::result::Result<(), String> {
|
||
has_doc(s)?;
|
||
if s.active().is_some_and(|d| d.selection.text.is_some() || d.selection.cells.is_some()) { Ok(()) } else { super::has_selection(s) }
|
||
}
|
||
|
||
/// 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, "spread")?;
|
||
let st = s.doc()?;
|
||
let (from, color) = image_color_at(&st.doc, sr, at).ok_or_else(|| bad(ID, "no placed image under the point"))?;
|
||
let hex = color.to_hex();
|
||
let mut params = p.clone();
|
||
params["color"] = json!(hex);
|
||
let mut result = apply_color(s, ¶ms)?;
|
||
result["color"] = json!(hex);
|
||
result["from"] = json!(from.0);
|
||
Ok(result)
|
||
}
|
||
|
||
/// The colour of the placed raster image at `at` on spread `sr`: the frontmost visible image whose
|
||
/// frame shows that point (locked ones too, as sampling changes nothing). (image frame, colour);
|
||
/// None over no image, a transparent pixel, or a graphic that is not a raster (PDF, EPS).
|
||
pub(crate) fn image_color_at(
|
||
d: &designcraft_doc::Document,
|
||
sr: designcraft_doc::SpreadRef,
|
||
at: designcraft_geom::Point,
|
||
) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> {
|
||
let sp = d.spread(sr)?;
|
||
let mut items: Vec<&designcraft_doc::Item> = sp.items.iter().map(|i| &**i).collect();
|
||
items.sort_by_key(|i| d.layer_rank(i.layer));
|
||
items
|
||
.into_iter()
|
||
.rev()
|
||
.filter(|it| d.layer(it.layer).is_none_or(|l| l.visible))
|
||
.find_map(|it| sample_item(d, it, designcraft_geom::Affine::IDENTITY, at, 0))
|
||
}
|
||
|
||
fn sample_item(
|
||
d: &designcraft_doc::Document,
|
||
it: &designcraft_doc::Item,
|
||
outer: designcraft_geom::Affine,
|
||
at: designcraft_geom::Point,
|
||
depth: usize,
|
||
) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> {
|
||
use designcraft_doc::Content;
|
||
if it.hidden || depth > 64 {
|
||
return None;
|
||
}
|
||
let xf = outer * it.xf;
|
||
match &it.content {
|
||
Content::Group { items } => items.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;
|
||
}
|
||
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);
|
||
let result = 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;
|
||
for g in &mut d.color_groups {
|
||
for w in &mut g.swatches {
|
||
if *w == name {
|
||
*w = new.clone();
|
||
}
|
||
}
|
||
}
|
||
// Everything using the colour follows the rename.
|
||
for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) {
|
||
let sp = std::sync::Arc::make_mut(sp);
|
||
for top in &mut sp.items {
|
||
rename_in(std::sync::Arc::make_mut(top), &name, &new);
|
||
}
|
||
}
|
||
for st in d.stories.values_mut() {
|
||
let mut copy = (**st).clone();
|
||
if rename_in_story(&mut copy, &name, &new, 0)? {
|
||
*st = std::sync::Arc::new(copy);
|
||
}
|
||
}
|
||
Ok(json!({"name": new}))
|
||
})?;
|
||
if let Some(new) = result.get("name").and_then(Value::as_str) {
|
||
super::text::rename_typing_swatch(s, &name, new);
|
||
}
|
||
Ok(result)
|
||
}
|
||
|
||
/// Point text colours (character fill, stroke, underline and strikethrough; paragraph shading,
|
||
/// border and rules; table cells, borders and alternating fills) at a renamed swatch, in a story
|
||
/// and the texts inside it (table cells, footnotes, endnotes). Returns whether anything changed.
|
||
fn rename_in_story(st: &mut designcraft_doc::Story, from: &str, to: &str, depth: usize) -> Result<bool> {
|
||
// Refuse the whole edit instead of leaving deeper references pointing at a removed name.
|
||
if depth > 32 {
|
||
return Err(bad("swatch.addToSwatches", "story nesting exceeds 32 levels"));
|
||
}
|
||
fn fix(s: &mut String, from: &str, to: &str) -> bool {
|
||
let hit = s == from;
|
||
if hit {
|
||
*s = to.to_string();
|
||
}
|
||
hit
|
||
}
|
||
let mut n = 0;
|
||
let mut f = |s: &mut String| n += usize::from(fix(s, from, to));
|
||
for r in &mut st.chars {
|
||
let o = &mut r.format.over;
|
||
for c in [&mut o.fill, &mut o.stroke, &mut o.underline_color, &mut o.strikethrough_color].into_iter().flatten() {
|
||
f(c);
|
||
}
|
||
}
|
||
for pf in &mut st.paras {
|
||
let a = &mut pf.para;
|
||
for c in [&mut a.shading_color, &mut a.border_color].into_iter().flatten() {
|
||
f(c);
|
||
}
|
||
for r in [&mut a.rule_above, &mut a.rule_below].into_iter().flatten() {
|
||
f(&mut r.color);
|
||
}
|
||
}
|
||
let mut nested = false;
|
||
for t in st.tables.values_mut() {
|
||
let t = std::sync::Arc::make_mut(t);
|
||
let o = &mut t.options;
|
||
f(&mut o.border.color);
|
||
for b in o.borders.iter_mut().flatten() {
|
||
f(&mut b.color);
|
||
}
|
||
for a in [&mut o.alt_rows, &mut o.alt_cols].into_iter().flatten() {
|
||
f(&mut a.first_color);
|
||
f(&mut a.next_color);
|
||
}
|
||
for k in [&mut o.row_strokes, &mut o.column_strokes].into_iter().flatten() {
|
||
f(&mut k.first_stroke.color);
|
||
f(&mut k.next_stroke.color);
|
||
}
|
||
for c in &mut t.cells {
|
||
f(&mut c.fill);
|
||
for k in &mut c.strokes {
|
||
f(&mut k.color);
|
||
}
|
||
nested |= rename_in_story(&mut c.text, from, to, depth + 1)?;
|
||
}
|
||
}
|
||
for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) {
|
||
nested |= rename_in_story(&mut std::sync::Arc::make_mut(note).text, from, to, depth + 1)?;
|
||
}
|
||
let changed = n > 0 || nested;
|
||
if changed {
|
||
st.rev = st.rev.wrapping_add(1);
|
||
}
|
||
Ok(changed)
|
||
}
|
||
|
||
/// Point fills and strokes of an item (and its children) at a renamed swatch.
|
||
fn rename_in(it: &mut designcraft_doc::Item, from: &str, to: &str) {
|
||
if it.fill.swatch == from {
|
||
it.fill.swatch = to.to_string();
|
||
}
|
||
if it.stroke.swatch == from {
|
||
it.stroke.swatch = to.to_string();
|
||
}
|
||
if let Some(kids) = it.children_mut() {
|
||
for k in kids {
|
||
rename_in(std::sync::Arc::make_mut(k), from, to);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod color_tests {
|
||
use super::*;
|
||
|
||
fn fill_session() -> Session {
|
||
let mut session = Session::new();
|
||
session.execute("file.new", &json!({})).unwrap();
|
||
let frame = session.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "Hi"})).unwrap();
|
||
session.execute("text.select", &json!({"story": frame["story"], "anchor": 0, "focus": 2})).unwrap();
|
||
session.execute("style.paragraph.create", &json!({"name": "Existing"})).unwrap();
|
||
session.execute("style.character.create", &json!({"name": "Existing"})).unwrap();
|
||
session.execute("type.char", &json!({"size": 18})).unwrap();
|
||
session.execute("edit.undo", &json!({})).unwrap();
|
||
session
|
||
}
|
||
|
||
fn assert_fill_rejected(session: &mut Session, command: &str, params: Value) {
|
||
let before = session.doc().unwrap().clone();
|
||
let journal = session.journal.clone();
|
||
let fill = params
|
||
.pointer("/chars/fill")
|
||
.or_else(|| params.pointer("/attrs/fill"))
|
||
.or_else(|| params.get("fill"))
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("Missing Ink");
|
||
|
||
let result = session.execute(command, ¶ms);
|
||
|
||
match result {
|
||
Err(crate::EngineError::BadParams { cmd, msg }) => {
|
||
assert_eq!(cmd, command);
|
||
assert!(msg.contains(fill), "{msg}");
|
||
assert!(msg.contains("swatch.create") && msg.contains("returned name"), "{msg}");
|
||
}
|
||
other => panic!("expected BadParams for {command}, got {other:?}"),
|
||
}
|
||
let after = session.doc().unwrap();
|
||
assert!(std::sync::Arc::ptr_eq(&before.doc, &after.doc));
|
||
assert_eq!(before.selection, after.selection);
|
||
assert_eq!(before.revision, after.revision);
|
||
assert_eq!(format!("{:?}", before.history), format!("{:?}", after.history));
|
||
assert_eq!(journal, session.journal);
|
||
}
|
||
|
||
#[test]
|
||
fn char_fill_rejects_unknown_flat_and_nested_names_when_formatting() {
|
||
for fill in ["Missing Ink", "#ff0000", "[black]", " [Black]"] {
|
||
for params in [json!({"fill": fill, "size": 24}), json!({"attrs": {"fill": fill, "size": 24}})] {
|
||
let mut session = fill_session();
|
||
assert_fill_rejected(&mut session, "type.char", params);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn char_fill_rejects_unknown_names_when_creating_or_editing_styles() {
|
||
for command in ["style.paragraph.create", "style.paragraph.edit", "style.character.create", "style.character.edit"] {
|
||
for fill in ["Missing Ink", "#ff0000"] {
|
||
let mut session = fill_session();
|
||
session.execute("style.paragraph.apply", &json!({"name": "Existing"})).unwrap();
|
||
session.execute("style.character.apply", &json!({"name": "Existing"})).unwrap();
|
||
assert_fill_rejected(&mut session, command, json!({"name": "Existing", "rename": "Renamed", "chars": {"fill": fill, "size": 24}}));
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn char_fill_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", ¶ms).unwrap();
|
||
assert_eq!(session.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], fill);
|
||
}
|
||
for kind in ["paragraph", "character"] {
|
||
session.execute(&format!("style.{kind}.create"), &json!({"name": "Colored", "chars": {"fill": fill}})).unwrap();
|
||
session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": {"fill": fill}})).unwrap();
|
||
let document = &session.doc().unwrap().doc;
|
||
for name in ["Colored", "Existing"] {
|
||
let attrs = if kind == "paragraph" {
|
||
&document.styles.para(name).unwrap().chars
|
||
} else {
|
||
&document.styles.char_style(name).unwrap().chars
|
||
};
|
||
assert_eq!(attrs.fill.as_deref(), Some(fill));
|
||
}
|
||
}
|
||
assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": {"fill": fill}})).unwrap()["count"], 1);
|
||
let selected = session.doc().unwrap().selection.text.unwrap();
|
||
let story = session.doc().unwrap().doc.story(selected.story).unwrap();
|
||
assert_eq!(story.text, "Bye");
|
||
assert_eq!(story.format_after(0).over.fill.as_deref(), Some(fill));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn char_fill_accepts_omitted_and_null_attributes_when_used_at_all_boundaries() {
|
||
for chars in [json!({"size": 18}), json!({"fill": null})] {
|
||
let mut session = fill_session();
|
||
session.execute("type.char", &json!({"attrs": chars})).unwrap();
|
||
for kind in ["paragraph", "character"] {
|
||
session.execute(&format!("style.{kind}.create"), &json!({"name": "Nullable", "chars": chars})).unwrap();
|
||
session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": chars})).unwrap();
|
||
}
|
||
assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": chars})).unwrap()["count"], 1);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn percent_components_at_most_one_stay_percent() {
|
||
use designcraft_color::Color;
|
||
// {"c": 0.7, ..., "y": 12.2} is a pale cream (C 0.7 %, Y 12.2 %), not C 70 %.
|
||
let Some(Color::Cmyk { c, m, y, k }) = parse_color(&json!({"c": 0.7, "m": 0, "y": 12.2, "k": 0})) else { panic!("not CMYK") };
|
||
assert!((c - 0.007).abs() < 1e-6 && m == 0.0 && (y - 0.122).abs() < 1e-6 && k == 0.0, "got {c} {m} {y} {k}");
|
||
// Fractions (every component within 0..1) keep working.
|
||
let Some(Color::Cmyk { c, k, .. }) = parse_color(&json!({"c": 0.5, "m": 0, "y": 0, "k": 1})) else { panic!("not CMYK") };
|
||
assert!((c - 0.5).abs() < 1e-6 && (k - 1.0).abs() < 1e-6);
|
||
// RGB arrays follow the same rule: [1, 128, 255] is 0..255.
|
||
let Some(Color::Rgb { r, g, b }) = parse_color(&json!([1, 128, 255])) else { panic!("not RGB") };
|
||
assert!((r - 1.0 / 255.0).abs() < 1e-6 && (g - 128.0 / 255.0).abs() < 1e-6 && (b - 1.0).abs() < 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn 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 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");
|
||
}
|
||
|
||
#[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("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());
|
||
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"));
|
||
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 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 nothing_to_sample_is_refused_without_an_edit() {
|
||
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();
|
||
let sampled = s.execute("color.sample", &json!({"at": [100, 100]})).unwrap();
|
||
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], sampled["swatch"]);
|
||
assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "Hi", "caret color waits for typing");
|
||
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 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}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[path = "text_color_tests.rs"]
|
||
mod text_color_tests;
|