mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-02 06:36:51 -06:00
354 lines
16 KiB
Rust
354 lines
16 KiB
Rust
//! Load Styles: the paragraph and character styles of another document (`.designcraft` or IDML)
|
|
//! come into this one, with the styles, swatches and lists they name.
|
|
|
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
|
|
|
use designcraft_color::{Swatch, SwatchValue};
|
|
use designcraft_doc::{CharAttrs, Document, NO_CHAR_STYLE, NO_PARA_STYLE, NumberedList, ParaAttrs, ParagraphStyle, Styles};
|
|
use serde_json::{Value, json};
|
|
|
|
use super::{CommandSpec, bad, cmd, has_doc, str_param};
|
|
use crate::{Result, Session};
|
|
|
|
const ID: &str = "style.load";
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
cmd!(
|
|
"style.load",
|
|
"Load Styles…",
|
|
[],
|
|
None,
|
|
"{path | base64, name?: the file's name (`.idml` reads IDML), paragraph?: [name], character?: [name], conflicts?: redefine|useExisting|autoRename} — paragraph and character styles of another document (.designcraft or .idml): all of a kind, or the named ones (`[]`: none). The styles, swatches and lists they name come along where this document has none. A style this document defines differently under the same name takes the loaded definition (redefine, the default), stays as it is (useExisting), or the loaded one gets a new name (autoRename) → {paragraph: {added, replaced, renamed: {name: new name}}, character: {…}, swatches: [name]}",
|
|
has_doc,
|
|
load
|
|
),
|
|
cmd!(query "style.load.list", "List Styles in File", [], None,
|
|
"{path | base64, name?} → {paragraph: [{name, conflict}], character: [{name, conflict}]} — the styles `style.load` would bring; `conflict`: this document defines a style of that name differently",
|
|
has_doc, list),
|
|
]
|
|
}
|
|
|
|
/// What happens to a loaded style when the document defines its name differently.
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum Conflict {
|
|
/// The loaded definition replaces the document's.
|
|
Redefine,
|
|
/// The document's style stays.
|
|
UseExisting,
|
|
/// The loaded style comes in under a free name (`Name 2`).
|
|
AutoRename,
|
|
}
|
|
|
|
/// What loading does with one style of the file.
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum Action {
|
|
/// The document keeps what it has.
|
|
Skip,
|
|
Add,
|
|
Replace,
|
|
/// Added under a new name.
|
|
Rename,
|
|
}
|
|
|
|
/// The loaded styles of one kind (paragraph or character), by their name in the file.
|
|
#[derive(Default)]
|
|
struct Kind {
|
|
actions: HashMap<String, Action>,
|
|
renames: HashMap<String, String>,
|
|
added: Vec<String>,
|
|
replaced: Vec<String>,
|
|
}
|
|
|
|
impl Kind {
|
|
/// The name a style of the file has in this document.
|
|
fn follow(&self, name: &mut String) {
|
|
if let Some(new) = self.renames.get(name.as_str()) {
|
|
*name = new.clone();
|
|
}
|
|
}
|
|
|
|
fn changes(&self) -> bool {
|
|
!(self.added.is_empty() && self.replaced.is_empty() && self.renames.is_empty())
|
|
}
|
|
|
|
fn report(&self) -> Value {
|
|
let renamed: BTreeMap<&String, &String> = self.renames.iter().collect();
|
|
json!({"added": self.added, "replaced": self.replaced, "renamed": renamed})
|
|
}
|
|
}
|
|
|
|
/// The document a command reads styles from.
|
|
fn source(p: &Value, id: &str) -> Result<Document> {
|
|
let (bytes, name) = match (str_param(p, "path"), str_param(p, "base64")) {
|
|
(_, Some(b)) => (super::file::base64_decode(b), str_param(p, "name").unwrap_or("").to_string()),
|
|
(Some(path), None) => (crate::io::read_file(path).map_err(|e| bad(id, e))?, path.to_string()),
|
|
_ => return Err(bad(id, "give `path` or `base64`")),
|
|
};
|
|
if name.to_lowercase().ends_with(".idml") {
|
|
designcraft_idml::import_idml(&bytes).map_err(|e| bad(id, e.to_string()))
|
|
} else {
|
|
super::file::from_bytes(&bytes).map_err(|e| bad(id, e.to_string()))
|
|
}
|
|
}
|
|
|
|
/// The styles of one kind to load: every one (`None`) or the named ones.
|
|
fn names(p: &Value, key: &str) -> Result<Option<Vec<String>>> {
|
|
let list = match p.get(key) {
|
|
None | Some(Value::Null) => return Ok(None),
|
|
Some(v) => v.as_array().and_then(|a| a.iter().map(|n| n.as_str().map(str::to_string)).collect::<Option<Vec<_>>>()),
|
|
};
|
|
list.map(Some).ok_or_else(|| bad(ID, format!("`{key}` must be a list of style names")))
|
|
}
|
|
|
|
/// The styles a paragraph style names: its base, its next style and its nested, line and GREP
|
|
/// styles. `true` marks a paragraph style.
|
|
fn para_refs(s: &ParagraphStyle) -> Vec<(bool, String)> {
|
|
let p = &s.para;
|
|
let chars = p
|
|
.nested_styles
|
|
.iter()
|
|
.flatten()
|
|
.map(|n| &n.style)
|
|
.chain(p.grep_styles.iter().flatten().map(|g| &g.style))
|
|
.chain(p.nested_line_styles.iter().flatten().map(|l| &l.style));
|
|
s.based_on.iter().chain(&s.next_style).map(|n| (true, n.clone())).chain(chars.map(|n| (false, n.clone()))).collect()
|
|
}
|
|
|
|
fn char_swatches(c: &CharAttrs) -> impl Iterator<Item = &String> {
|
|
[&c.fill, &c.stroke, &c.underline_color, &c.strikethrough_color].into_iter().flatten()
|
|
}
|
|
|
|
fn para_swatches(p: &ParaAttrs) -> impl Iterator<Item = &String> {
|
|
p.rule_above.iter().chain(&p.rule_below).map(|r| &r.color).chain(&p.shading_color).chain(&p.border_color)
|
|
}
|
|
|
|
/// Decide what happens to every asked-for style and to the styles those name. A style the
|
|
/// document defines the same way is left alone; one that is only named by another is added when
|
|
/// the document has none, and never replaces one.
|
|
fn plan(doc: &Document, src: &Document, paragraph: Option<&[String]>, character: Option<&[String]>, conflict: Conflict) -> Result<(Kind, Kind)> {
|
|
let (theirs, mine) = (&src.styles, &doc.styles);
|
|
let mut todo: Vec<(bool, String)> = Vec::new();
|
|
match paragraph {
|
|
Some(names) => {
|
|
for n in names {
|
|
if n == NO_PARA_STYLE || theirs.para(n).is_none() {
|
|
return Err(bad(ID, format!("the file has no paragraph style `{n}`")));
|
|
}
|
|
todo.push((true, n.clone()));
|
|
}
|
|
}
|
|
None => todo.extend(theirs.paragraph.iter().filter(|s| s.name != NO_PARA_STYLE).map(|s| (true, s.name.clone()))),
|
|
}
|
|
match character {
|
|
Some(names) => {
|
|
for n in names {
|
|
if n == NO_CHAR_STYLE || theirs.char_style(n).is_none() {
|
|
return Err(bad(ID, format!("the file has no character style `{n}`")));
|
|
}
|
|
todo.push((false, n.clone()));
|
|
}
|
|
}
|
|
None => todo.extend(theirs.character.iter().filter(|s| s.name != NO_CHAR_STYLE).map(|s| (false, s.name.clone()))),
|
|
}
|
|
let asked: HashSet<(bool, String)> = todo.iter().cloned().collect();
|
|
let (mut para, mut chars) = (Kind::default(), Kind::default());
|
|
while let Some(key) = todo.pop() {
|
|
let (is_para, name) = (key.0, key.1.as_str());
|
|
let kind = if is_para { &mut para } else { &mut chars };
|
|
if kind.actions.contains_key(name) {
|
|
continue;
|
|
}
|
|
// Is the document's style of that name the same, and which styles does the file's name?
|
|
let (same, refs) = if is_para {
|
|
let Some(s) = theirs.para(name).filter(|s| s.name != NO_PARA_STYLE) else { continue };
|
|
(mine.para(name).map(|m| m == s), para_refs(s))
|
|
} else {
|
|
let Some(s) = theirs.char_style(name).filter(|s| s.name != NO_CHAR_STYLE) else { continue };
|
|
(mine.char_style(name).map(|m| m == s), s.based_on.iter().map(|b| (false, b.clone())).collect())
|
|
};
|
|
let action = match same {
|
|
None => Action::Add,
|
|
Some(false) if asked.contains(&key) => match conflict {
|
|
Conflict::Redefine => Action::Replace,
|
|
Conflict::UseExisting => Action::Skip,
|
|
Conflict::AutoRename => Action::Rename,
|
|
},
|
|
Some(_) => Action::Skip,
|
|
};
|
|
if action != Action::Skip {
|
|
todo.extend(refs);
|
|
}
|
|
kind.actions.insert(key.1, action);
|
|
}
|
|
// Free names for the renamed styles, in the file's order.
|
|
let order = theirs.paragraph.iter().map(|s| (true, &s.name)).chain(theirs.character.iter().map(|s| (false, &s.name)));
|
|
for (is_para, name) in order {
|
|
let kind = if is_para { &mut para } else { &mut chars };
|
|
if kind.actions.get(name) != Some(&Action::Rename) || kind.renames.contains_key(name) {
|
|
continue;
|
|
}
|
|
// A built-in style comes in as an ordinary one, without its brackets.
|
|
let base = name.strip_prefix('[').and_then(|n| n.strip_suffix(']')).filter(|n| !n.is_empty()).unwrap_or(name);
|
|
let taken = |n: &str| {
|
|
(if is_para { mine.para(n).is_some() } else { mine.char_style(n).is_some() })
|
|
|| kind.actions.get(n).is_some_and(|a| *a != Action::Skip)
|
|
|| kind.renames.values().any(|r| r == n)
|
|
};
|
|
let new = Styles::unique_name(taken, base);
|
|
kind.renames.insert(name.clone(), new);
|
|
}
|
|
Ok((para, chars))
|
|
}
|
|
|
|
/// The `conflicts` parameter: how a style that differs under the same name is handled.
|
|
fn conflict_param(p: &Value) -> Result<Conflict> {
|
|
Ok(match p.get("conflicts") {
|
|
None | Some(Value::Null) => Conflict::Redefine,
|
|
Some(v) => match v.as_str() {
|
|
Some("redefine") => Conflict::Redefine,
|
|
Some("useExisting") => Conflict::UseExisting,
|
|
Some("autoRename") => Conflict::AutoRename,
|
|
_ => return Err(bad(ID, "`conflicts` must be redefine, useExisting or autoRename")),
|
|
},
|
|
})
|
|
}
|
|
|
|
/// Give the loaded style `ours` the export tag the source had for `theirs` (`kind` is `p` or `c`).
|
|
fn carry_export_tag(src: &Styles, dst: &mut Styles, kind: char, theirs: &str, ours: &str) {
|
|
let tag = format!("{kind}:{ours}");
|
|
match src.export_tags.get(&format!("{kind}:{theirs}")) {
|
|
Some(t) => dst.export_tags.insert(tag, t.clone()),
|
|
None => dst.export_tags.remove(&tag),
|
|
};
|
|
}
|
|
|
|
/// A loaded style and one the document keeps can name each other as their base.
|
|
fn check_based_on_cycles(styles: &Styles, loaded: &[(bool, String)]) -> Result<()> {
|
|
for (is_para, name) in loaded {
|
|
let looped = if *is_para {
|
|
styles.para(name).and_then(|s| s.based_on.as_deref()).is_some_and(|b| styles.para_based_on_cycles(name, b))
|
|
} else {
|
|
styles.char_style(name).and_then(|s| s.based_on.as_deref()).is_some_and(|b| styles.char_based_on_cycles(name, b))
|
|
};
|
|
if looped {
|
|
let kind = if *is_para { "paragraph" } else { "character" };
|
|
return Err(bad(ID, format!("{kind} style `{name}` would be based on itself; load the style it is based on as well")));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn load(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let src = source(p, ID)?;
|
|
let conflict = conflict_param(p)?;
|
|
let (paragraph, character) = (names(p, "paragraph")?, names(p, "character")?);
|
|
let doc = s.doc()?.doc.clone();
|
|
let (mut para, mut chars) = plan(&doc, &src, paragraph.as_deref(), character.as_deref(), conflict)?;
|
|
|
|
let mut styles = (*doc.styles).clone();
|
|
let (mut swatches, mut lists): (HashSet<String>, HashSet<String>) = Default::default();
|
|
// (kind, name here) of every style that came in, for the based-on check.
|
|
let mut loaded: Vec<(bool, String)> = Vec::new();
|
|
for theirs in &src.styles.paragraph {
|
|
let Some(action) = para.actions.remove(&theirs.name).filter(|a| *a != Action::Skip) else { continue };
|
|
let mut style = theirs.clone();
|
|
para.follow(&mut style.name);
|
|
style.based_on.iter_mut().chain(&mut style.next_style).for_each(|n| para.follow(n));
|
|
let p = &mut style.para;
|
|
p.nested_styles.iter_mut().flatten().for_each(|n| chars.follow(&mut n.style));
|
|
p.grep_styles.iter_mut().flatten().for_each(|g| chars.follow(&mut g.style));
|
|
p.nested_line_styles.iter_mut().flatten().for_each(|l| chars.follow(&mut l.style));
|
|
swatches.extend(char_swatches(&style.chars).chain(para_swatches(&style.para)).cloned());
|
|
lists.extend(style.para.list_name.iter().cloned());
|
|
carry_export_tag(&src.styles, &mut styles, 'p', &theirs.name, &style.name);
|
|
loaded.push((true, style.name.clone()));
|
|
match styles.para_mut(&style.name) {
|
|
Some(m) if action == Action::Replace => {
|
|
*m = style;
|
|
para.replaced.push(theirs.name.clone());
|
|
}
|
|
_ => {
|
|
if action == Action::Add {
|
|
para.added.push(theirs.name.clone());
|
|
}
|
|
styles.paragraph.push(style);
|
|
}
|
|
}
|
|
}
|
|
for theirs in &src.styles.character {
|
|
let Some(action) = chars.actions.remove(&theirs.name).filter(|a| *a != Action::Skip) else { continue };
|
|
let mut style = theirs.clone();
|
|
chars.follow(&mut style.name);
|
|
style.based_on.iter_mut().for_each(|n| chars.follow(n));
|
|
swatches.extend(char_swatches(&style.chars).cloned());
|
|
carry_export_tag(&src.styles, &mut styles, 'c', &theirs.name, &style.name);
|
|
loaded.push((false, style.name.clone()));
|
|
match styles.char_style_mut(&style.name) {
|
|
Some(m) if action == Action::Replace => {
|
|
*m = style;
|
|
chars.replaced.push(theirs.name.clone());
|
|
}
|
|
_ => {
|
|
if action == Action::Add {
|
|
chars.added.push(theirs.name.clone());
|
|
}
|
|
styles.character.push(style);
|
|
}
|
|
}
|
|
}
|
|
check_based_on_cycles(&styles, &loaded)?;
|
|
|
|
let swatches = missing_swatches(&doc, &src, swatches);
|
|
let lists: Vec<NumberedList> = {
|
|
let mut seen = HashSet::new();
|
|
let has = |n: &str| doc.settings.lists.iter().any(|l| l.name == n);
|
|
src.settings.lists.iter().filter(|l| lists.contains(&l.name) && !has(&l.name) && seen.insert(l.name.as_str())).cloned().collect()
|
|
};
|
|
let report = json!({"paragraph": para.report(), "character": chars.report(), "swatches": swatches.iter().map(|w| &w.name).collect::<Vec<_>>()});
|
|
// Nothing to load leaves no undo step.
|
|
if para.changes() || chars.changes() {
|
|
s.edit(|d, _| {
|
|
*d.styles_mut() = styles;
|
|
d.swatches.extend(swatches);
|
|
d.settings.lists.extend(lists);
|
|
Ok(())
|
|
})?;
|
|
}
|
|
Ok(report)
|
|
}
|
|
|
|
/// The swatches of the file that the loaded styles name and the document lacks, in the file's
|
|
/// order; a tint brings its base colour.
|
|
fn missing_swatches(doc: &Document, src: &Document, mut used: HashSet<String>) -> Vec<Swatch> {
|
|
let mut todo: Vec<String> = used.iter().cloned().collect();
|
|
while let Some(name) = todo.pop() {
|
|
if let Some(Swatch { value: SwatchValue::Tint { base, .. }, .. }) = src.swatch(&name)
|
|
&& used.insert(base.clone())
|
|
{
|
|
todo.push(base.clone());
|
|
}
|
|
}
|
|
let mut seen = HashSet::new();
|
|
src.swatches.iter().filter(|w| used.contains(&w.name) && doc.swatch(&w.name).is_none() && seen.insert(w.name.as_str())).cloned().collect()
|
|
}
|
|
|
|
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let src = source(p, "style.load.list")?;
|
|
let mine = &s.doc()?.doc.styles;
|
|
let row = |name: &str, conflict: bool| json!({"name": name, "conflict": conflict});
|
|
let paragraph: Vec<Value> =
|
|
src.styles.paragraph.iter().filter(|x| x.name != NO_PARA_STYLE).map(|x| row(&x.name, mine.para(&x.name).is_some_and(|m| m != x))).collect();
|
|
let character: Vec<Value> = src
|
|
.styles
|
|
.character
|
|
.iter()
|
|
.filter(|x| x.name != NO_CHAR_STYLE)
|
|
.map(|x| row(&x.name, mine.char_style(&x.name).is_some_and(|m| m != x)))
|
|
.collect();
|
|
Ok(json!({"paragraph": paragraph, "character": character}))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|