Files
vectorcraft/crates/engine/src/cmd/textstyles.rs
T
dotCooCoo 0ce616da4b Merge main into feat/kinsoku-shori-type
# Conflicts:
#	crates/engine/src/cmd/panelcmds.rs
#	crates/text/src/layout/breaking.rs
#	crates/ui-egui/src/panels/paragraph.rs
#	docs/target-app-parity.md
2026-11-08 16:13:35 -08:00

1004 lines
46 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Character Styles and Paragraph Styles panels.
//!
//! A style is a named, partial set of attributes ([`TextStyleDef`]); text using it records the
//! name in `CharStyle::style_name` / `ParaStyle::style_name`. Applying a style sets its attributes
//! (Clear Overrides first resets to the Normal style). Redefining a style updates its users but keeps
//! their local overrides: an attribute changes only where it still had the old style's value.
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::{Map, Value, json};
use vectorcraft_doc::{CharStyle, Document, Node, NodeId, NodeKind, ParaStyle, TextStyleDef};
use super::clipboard::resources::import_text_styles;
use super::typecmd::{refresh_bounds, text_targets};
use super::{CommandSpec, Result, Run, Session, bad, bool_or, has_doc, index, object_param, ok, str_param};
pub const NORMAL_CHAR: &str = "[Normal Character Style]";
pub const NORMAL_PARA: &str = "[Normal Paragraph Style]";
#[derive(Clone, Copy, PartialEq)]
enum Kind {
Char,
Para,
}
impl Kind {
fn cmd(self, verb: &str) -> String {
format!("{}.{verb}", if self == Kind::Char { "charStyle" } else { "paraStyle" })
}
fn normal(self) -> &'static str {
if self == Kind::Char { NORMAL_CHAR } else { NORMAL_PARA }
}
fn defs(self, d: &Document) -> &Vec<TextStyleDef> {
if self == Kind::Char { &d.char_styles } else { &d.para_styles }
}
fn defs_mut(self, d: &mut Document) -> &mut Vec<TextStyleDef> {
if self == Kind::Char { &mut d.char_styles } else { &mut d.para_styles }
}
/// Attributes of style `name` (the Normal style is built in: empty unless redefined).
fn attrs(self, d: &Document, name: &str) -> Option<Map<String, Value>> {
match self.defs(d).iter().find(|s| s.name == name) {
Some(s) => Some(s.attrs.clone()),
None if name == self.normal() => Some(Map::new()),
None => None,
}
}
}
/// One command spec (the `cmd!` macro wants literals; these ids are built with `concat!`).
const fn spec(id: &'static str, label: &'static str, menu: &'static [&'static str], params: &'static str, run: Run, journal: bool) -> CommandSpec {
CommandSpec { id, label, menu, shortcut: None, params, enabled: has_doc, run, journal }
}
macro_rules! style_cmds {
($kind:expr, $p:literal, $menu:literal, $what:literal, $bounds:literal) => {
vec![
spec(concat!($p, ".list"), concat!($what, " Styles"), &[], "{} → {styles: [{name, attrs, uses}]}", |s, _| list(s, $kind), false),
spec(
concat!($p, ".new"),
concat!("New ", $what, " Style"),
&["Window", "Type", $menu],
concat!("{name? (trimmed, as rename trims `to`), attrs?: {…} (default: selected text), id?, start?: byte, end?: byte} → {name}; a nonempty character range samples its first selected character; start alone keeps insertion affinity; paragraph styles sample start", $bounds),
|s, p| new(s, p, $kind),
true,
),
spec(
concat!($p, ".apply"),
concat!("Apply ", $what, " Style"),
&[],
"{name, clearOverrides?, ids?|id?, start?, end? (id with start and end: a Type tool range; character styles style it, paragraph styles apply to the paragraphs it touches)} → {count}",
|s, p| apply(s, p, $kind),
true,
),
spec(
concat!($p, ".redefine"),
concat!("Redefine ", $what, " Style"),
&["Window", "Type", $menu],
"{name, id?, start?: byte, end?: byte} from selected text; nonempty character ranges sample their first selected character, start alone keeps insertion affinity, paragraph styles sample start; users keep their overrides",
|s, p| redefine(s, p, $kind),
true,
),
spec(
concat!($p, ".setAttrs"),
concat!($what, " Style Options"),
&[],
concat!("{name, attrs: {…}} replace the style's attributes; users keep their overrides", $bounds),
|s, p| set_attrs(s, p, $kind),
true,
),
spec(
concat!($p, ".duplicate"),
concat!("Duplicate ", $what, " Style"),
&["Window", "Type", $menu],
"{name} → {name}",
|s, p| duplicate(s, p, $kind),
true,
),
spec(concat!($p, ".rename"), concat!("Rename ", $what, " Style"), &[], "{name, to}", |s, p| rename(s, p, $kind), true),
spec(
concat!($p, ".delete"),
concat!("Delete ", $what, " Style"),
&["Window", "Type", $menu],
"{name} | {names: [name…]} delete styles in one undo step (text keeps its look; never the Normal style)",
|s, p| delete(s, p, $kind),
true,
),
spec(
concat!($p, ".unused"),
"Select All Unused",
&["Window", "Type", $menu],
"{} → {names: [the styles no text uses, in panel order; never the Normal style]} (the panel highlights them)",
|s, _| unused(s, $kind),
false,
),
spec(
concat!($p, ".load"),
concat!("Load ", $what, " Styles…"),
&["Window", "Type", $menu],
LOAD_PARAMS,
|s, p| load(s, p, &[$kind]),
true,
),
]
};
}
/// The params of `charStyle.load`, `paraStyle.load` and `textStyle.loadAll`.
const LOAD_PARAMS: &str = "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} copy the styles of this kind (textStyle.loadAll: character and paragraph styles) from a document VectorCraft opens (see document.formats) into this one, after its own, in one undo step, with the swatches and patterns they paint with, the symbols and brushes those patterns use, and the styles of either kind that type in those patterns and symbols is set in, as Paste brings those of pasted type (a global or spot color whose name this document gives another color is this document's). A style that is the same as the style here of its name, or of its name with a number (`Body 2`), isn't added again; any other is added under a free name (`Body 2`). The file's Normal styles aren't loaded, and a style whose attributes don't read or are out of the range charStyle.new takes, or whose name is empty, is skipped → {added: [names here], existing: [names already here], skipped: [names in the file]} (textStyle.loadAll: {charStyles: {…}, paraStyles: {…}}); charStyle.load and paraStyle.load list only the styles of their own kind";
pub fn specs() -> Vec<CommandSpec> {
let mut v = style_cmds!(
Kind::Char,
"charStyle",
"Character Styles",
"Character",
"; changed numeric attrs: size 0.1..=1296 pt, explicit leading 0.1..=5000 pt, h/v scale 1..=10000%, baseline shift and Tate-chu-yoko offsets -1296..=1296 pt"
);
v.push(spec(
"charStyle.edit",
"Edit Character Style",
&[],
"{name, to, attrs: {…}} replace name and sparse attributes in one undo step; users keep local overrides; changed numeric attrs: size 0.1..=1296 pt, explicit leading 0.1..=5000 pt, h/v scale 1..=10000%, baseline shift and Tate-chu-yoko offsets -1296..=1296 pt",
|s, p| edit_style(s, p, Kind::Char),
true,
));
v.extend(style_cmds!(Kind::Para, "paraStyle", "Paragraph Styles", "Paragraph", ""));
v.push(spec("textStyle.loadAll", "Load All Styles…", &["Window", "Type"], LOAD_PARAMS, |s, p| load(s, p, &[Kind::Char, Kind::Para]), true));
v
}
// ---------- attribute plumbing ----------
/// `v` as a JSON map with one key per attribute: Auto alignment, which files save as its
/// direction's alignment plus `justify_auto` (see `vectorcraft_doc::ParaStyle`), is `"justify":
/// "Auto"`, so a style's `justify` replaces it.
fn to_map<T: Serialize>(v: &T) -> Option<Map<String, Value>> {
let Ok(Value::Object(mut m)) = serde_json::to_value(v) else { return None };
if m.remove("justify_auto") == Some(Value::Bool(true)) {
m.insert("justify".into(), Value::from("Auto"));
}
Some(m)
}
/// A style's attributes as a JSON map, without the style name.
fn to_attrs<T: Serialize>(v: &T) -> Map<String, Value> {
let mut m = to_map(v).unwrap_or_default();
m.remove("style_name");
m
}
/// Every character attribute of `v` as a JSON map, without the style name: those the run's JSON
/// leaves out at their defaults (Auto leading and kerning, no OpenType features, Roman baseline
/// alignment…) are written out. A style captured from text sets them all; an attribute missing
/// from a style instead leaves the text's own value alone.
pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
let mut m = to_attrs(v);
// A chosen glyph belongs to one character, not to a reusable character style.
m.remove("glyphAlternate");
for (k, x) in [
("font_version", json!(v.font_version)),
("fontAxes", json!(v.font_axes)),
("leading", json!(v.leading)),
("kerning", json!(v.kerning)),
("opticalKerning", json!(v.optical_kerning)),
("features", json!(v.features)),
("overprint_fill", json!(v.overprint_fill)),
("overprint_stroke", json!(v.overprint_stroke)),
("stroke_cap", json!(v.stroke_cap)),
("stroke_join", json!(v.stroke_join)),
("stroke_miter_limit", json!(v.stroke_miter_limit)),
("stroke_dash", json!(v.stroke_dash)),
("stroke_align", json!(v.stroke_align)),
("position", json!(v.position)),
("small_caps", json!(v.small_caps)),
("charAlign", json!(v.char_align)),
("charDirection", json!(v.char_direction)),
("kashidas", json!(v.kashidas)),
("proportionalMetrics", json!(v.proportional_metrics)),
("tateChuYoko", json!(v.tate_chu_yoko)),
("tcyUpDown", json!(v.tcy_up_down)),
("tcyLeftRight", json!(v.tcy_left_right)),
("standardVerticalRoman", json!(v.standard_vertical_roman)),
("noBreak", json!(v.no_break)),
("digits", json!(v.digits)),
("fontHeightReference", json!(v.font_height_reference)),
("language", json!(v.language)),
] {
m.insert(k.into(), x);
}
m
}
/// Every paragraph attribute of `v` as a JSON map, without the style name: those the paragraph's
/// JSON leaves out at their defaults (the Hyphenation and Justification options, the Every-line Composer, no tab
/// stops, no list, Auto direction…) are written out, as [`char_attrs`] does for characters.
pub fn para_attrs(v: &ParaStyle) -> Map<String, Value> {
let mut m = to_attrs(v);
for (k, x) in [
("hyphenation", json!(v.hyphenation)),
("justification", json!(v.justification)),
("composer", json!(v.composer)),
("tabs", json!(v.tabs)),
("list", json!(v.list)),
("mojikumi", json!(v.mojikumi)),
("direction", json!(v.direction)),
("leading_model", json!(v.leading_model)),
("burasagari", json!(v.burasagari)),
("kinsoku", json!(v.kinsoku)),
("kinsoku_type", json!(v.kinsoku_type)),
("roman_hanging", json!(v.roman_hanging)),
("kashida", json!(v.kashida)),
] {
m.insert(k.into(), x);
}
m
}
/// Every attribute name `T` reads, including those its JSON leaves out at their defaults (Auto
/// leading and kerning, no mojikumi…): the field names serde's derive hands `deserialize_struct`.
/// Not `justify_auto`, which [`to_map`] turns into `"justify": "Auto"`.
fn attr_names<T: DeserializeOwned>() -> Vec<&'static str> {
use serde::de::{Error as _, Visitor, value::Error};
struct Names<'a>(&'a mut &'static [&'static str]);
impl<'de> serde::Deserializer<'de> for Names<'_> {
type Error = Error;
fn deserialize_any<V: Visitor<'de>>(self, _: V) -> std::result::Result<V::Value, Error> {
Err(Error::custom("not a struct"))
}
fn deserialize_struct<V: Visitor<'de>>(self, _: &'static str, fields: &'static [&'static str], _: V) -> std::result::Result<V::Value, Error> {
*self.0 = fields;
Err(Error::custom("field names only"))
}
serde::forward_to_deserialize_any! {
bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string bytes byte_buf option unit
unit_struct newtype_struct seq tuple tuple_struct map enum identifier ignored_any
}
}
let mut fields: &'static [&'static str] = &[];
// Always an error: the probe only records the names.
let _ = T::deserialize(Names(&mut fields));
fields.iter().copied().filter(|&f| f != "justify_auto").collect()
}
/// `base` with `attrs` written over it (unknown or ill-typed attributes are an error).
fn with_attrs<T: Serialize + DeserializeOwned>(base: &T, attrs: &Map<String, Value>, cmd: &str) -> Result<T> {
let mut m = to_attrs(base);
if let Some(full) = to_map(base) {
m.insert("style_name".into(), full.get("style_name").cloned().unwrap_or(Value::Null));
}
let names = attr_names::<T>();
for (k, v) in attrs {
// Not only the keys `base` writes: those at their defaults (Auto leading…) are left out.
if !m.contains_key(k) && !names.contains(&k.as_str()) {
return Err(bad(cmd, format!("unknown attribute `{k}`")));
}
m.insert(k.clone(), v.clone());
}
serde_json::from_value(Value::Object(m)).map_err(|e| bad(cmd, e.to_string()))
}
/// `cur` after its style changed from `old` to `new`: attributes still equal to the old style's
/// value (or not set by it) take the new value, and removed attributes return to `base`;
/// overrides stay. `attrs` lists every attribute of `cur` ([`char_attrs`], [`para_attrs`]), so
/// one at its default matches a style that sets the default. Unchanged if the style doesn't
/// round-trip through JSON.
fn restyle<T: Serialize + DeserializeOwned + Clone>(
cur: &T,
attrs: fn(&T) -> Map<String, Value>,
old: &Map<String, Value>,
new: &Map<String, Value>,
base: &Map<String, Value>,
) -> T {
// Starts from every key `cur` writes, so values no style sets (a glyph choice) are kept.
let Some(mut m) = to_map(cur) else { return cur.clone() };
m.extend(attrs(cur));
for (k, v) in new {
if old.get(k).is_none_or(|o| m.get(k) == Some(o)) {
m.insert(k.clone(), v.clone());
}
}
for (k, v) in old {
if !new.contains_key(k)
&& m.get(k) == Some(v)
&& let Some(base_value) = base.get(k)
{
m.insert(k.clone(), base_value.clone());
}
}
serde_json::from_value(Value::Object(m)).unwrap_or_else(|_| cur.clone())
}
/// `st`, written over `before` by a character style's `attrs`: when its font or style changed,
/// it keeps none of the old one's versions or Variable Font settings unless the style sets them.
fn font_change(before: &CharStyle, mut st: CharStyle, attrs: &Map<String, Value>) -> CharStyle {
if st.font_family == before.font_family && st.font_style == before.font_style {
return st;
}
if st.font_version.as_ref().is_some_and(|v| attrs.get("font_version") != Some(&json!(v))) {
st.font_version = None;
}
if !st.font_axes.is_empty() && attrs.get("fontAxes") != Some(&json!(st.font_axes)) {
st.font_axes.clear();
}
st
}
/// Change text in layers, symbols, graph designs, pattern tiles, saved pattern tiles restored by
/// Cancel, and brush art, including opacity masks in those trees.
pub(crate) fn each_text_mut(d: &mut Document, f: &mut impl FnMut(&mut vectorcraft_doc::TextObject) -> Result<()>) -> Result<()> {
fn has_text(n: &Node) -> bool {
matches!(n.kind, NodeKind::Text(_))
|| n.extras().mask.as_ref().is_some_and(|mask| has_text(&mask.art))
|| n.children().is_some_and(|children| children.iter().any(|child| has_text(child)))
}
fn visit(n: &mut Node, f: &mut impl FnMut(&mut vectorcraft_doc::TextObject) -> Result<()>) -> Result<()> {
if let NodeKind::Text(t) = &mut n.kind {
f(t)?;
}
if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut())
&& has_text(&mask.art)
{
visit(std::sync::Arc::make_mut(&mut mask.art), f)?;
}
for child in n.children_mut().into_iter().flatten() {
if has_text(child) {
visit(std::sync::Arc::make_mut(child), f)?;
}
}
Ok(())
}
let trees = d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.graph_designs.iter_mut().map(|g| &mut g.art));
let patterns = d.patterns.iter_mut().chain(d.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()));
for root in trees.chain(patterns.flat_map(|p| &mut p.art)) {
if has_text(root) {
visit(std::sync::Arc::make_mut(root), f)?;
}
}
let mut brushes = vectorcraft_brush::library(d);
let original_brushes = brushes.clone();
for art in brushes.iter_mut().flat_map(|b| b.art_mut()) {
visit(art, f)?;
}
if brushes != original_brushes {
vectorcraft_brush::store(d, &brushes);
}
Ok(())
}
fn text_mut(d: &mut Document, id: NodeId) -> Option<&mut vectorcraft_doc::TextObject> {
match d.node_mut(id).map(|n| &mut n.kind) {
Some(NodeKind::Text(t)) => Some(t),
_ => None,
}
}
/// How many runs (char) or paragraphs (para) use each style, by style name (`None`: the Normal
/// style). Type in the art (opacity masks included), symbols, graph designs, pattern tiles and the
/// art of brushes counts.
fn uses(d: &Document, kind: Kind) -> std::collections::HashMap<Option<String>, usize> {
let mut n = std::collections::HashMap::new();
let mut count = |name: &Option<String>| *n.entry(name.clone()).or_insert(0) += 1;
let brushes = vectorcraft_brush::library(d);
let trees = d.layers.iter().map(|l| &**l).chain(d.symbols.iter().map(|s| &*s.art)).chain(d.graph_designs.iter().map(|g| &*g.art));
let trees = trees.chain(d.patterns.iter().flat_map(|p| p.art.iter().map(|a| &**a))).chain(brushes.iter().flat_map(|b| b.art()));
for tree in trees {
vectorcraft_doc::swatches::walk_with_masks(tree, &mut |node| {
if let NodeKind::Text(t) = &node.kind {
match kind {
Kind::Char => t.runs.iter().for_each(|r| count(&r.style.style_name)),
Kind::Para => (0..t.paragraph_count()).for_each(|i| count(&t.para_at(i).style_name)),
}
}
});
}
n
}
/// How many runs (char) or paragraphs (para) use `name` (see [`uses`]).
fn uses_of(uses: &std::collections::HashMap<Option<String>, usize>, kind: Kind, name: &str) -> usize {
uses.get(&(name != kind.normal()).then(|| name.to_string())).copied().unwrap_or(0)
}
/// Update every user of `name` from `old` to `new` attributes (overrides kept).
fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map<String, Value>, new: &Map<String, Value>) -> Result<()> {
let target = (name != kind.normal()).then_some(name.to_string());
let normal = kind.attrs(d, kind.normal()).unwrap_or_default();
let base = match kind {
Kind::Char => char_attrs(&with_attrs(&CharStyle::default(), &normal, &kind.cmd("edit"))?),
Kind::Para => para_attrs(&with_attrs(&ParaStyle::default(), &normal, &kind.cmd("edit"))?),
};
each_text_mut(d, &mut |t| {
let mut changed = false;
match kind {
Kind::Char => {
for r in &mut t.runs {
if r.style.style_name == target {
r.style = font_change(&r.style, restyle(&r.style, char_attrs, old, new, &base), new);
changed = true;
}
}
}
Kind::Para => {
for pa in t.para_styles_mut() {
if pa.style_name == target {
*pa = restyle(pa, para_attrs, old, new, &base);
changed = true;
}
}
}
}
if changed {
refresh_bounds(t);
}
Ok(())
})
}
/// The selected text's attributes: the first selected character for a nonempty range,
/// the insertion style at a lone `start`, or the first selected text object's first run.
/// Paragraph capture always samples the paragraph at `start`. An `id` that isn't an object id is
/// an error under the `verb` command of `kind`.
fn selection_attrs(s: &Session, p: &Value, kind: Kind, verb: &str) -> Result<Option<Map<String, Value>>> {
let d = &s.doc()?.doc;
let id = match object_param(p, "id", &kind.cmd(verb))? {
Some(id) => Some(id),
None => text_targets(s, &json!({}), "").ok().and_then(|v| v.first().copied()),
};
let Some(NodeKind::Text(t)) = id.and_then(|i| d.node(i)).map(|n| &n.kind) else { return Ok(None) };
let at = p.get("start").and_then(Value::as_u64).map_or(0, index);
Ok(Some(match kind {
Kind::Char => {
let style = match p.get("end").and_then(Value::as_u64).map(index) {
Some(end) => vectorcraft_text::edit::insertion_style(&t.runs, at.min(end), at.max(end)),
None => vectorcraft_text::edit::style_at(&t.runs, at),
};
char_attrs(&style)
}
// The paragraph at `start`.
Kind::Para => para_attrs(t.para_at(t.paragraphs_in(at, at).start)),
}))
}
/// The selected text's character (`para`: paragraph) attributes, every one of them: what a
/// library keeps of a text style. `None` without text selected.
pub(crate) fn selected_style_attrs(s: &Session, para: bool) -> Result<Option<Map<String, Value>>> {
selection_attrs(s, &json!({}), if para { Kind::Para } else { Kind::Char }, "new")
}
/// The attributes `charStyle.new` (`para`: `paraStyle.new`) would give a style made now from
/// `target` (its `id`, `start` and `end`; empty for the selected objects). `None` without text
/// there.
pub fn target_style_attrs(s: &Session, target: &Value, para: bool) -> Option<Map<String, Value>> {
selection_attrs(s, target, if para { Kind::Para } else { Kind::Char }, "new").ok().flatten()
}
/// The attributes a new character or paragraph style receives when no text is selected.
pub fn default_new_style_attrs(s: &Session, para: bool) -> Map<String, Value> {
new_style_attrs(s, if para { Kind::Para } else { Kind::Char })
}
/// Whether the document has a character (`para`: paragraph) style `name`, and its attributes.
pub(crate) fn style_attrs(d: &Document, para: bool, name: &str) -> Option<Map<String, Value>> {
if para { Kind::Para } else { Kind::Char }.attrs(d, name)
}
/// The name `charStyle.new` (`para`: `paraStyle.new`) gives a style when it is given none: the
/// first "Character Style N" ("Paragraph Style N") that no character style (paragraph style) of the
/// document has.
pub fn new_style_name(d: &Document, para: bool) -> String {
let kind = if para { Kind::Para } else { Kind::Char };
let base = if para { "Paragraph Style" } else { "Character Style" };
(1..).map(|i| format!("{base} {i}")).find(|n| kind.attrs(d, n).is_none()).unwrap_or_else(|| base.to_string())
}
fn name_param<'a>(p: &'a Value, kind: Kind, verb: &str) -> Result<&'a str> {
str_param(p, "name").ok_or_else(|| bad(&kind.cmd(verb), "missing `name`"))
}
/// The `attrs` of `p`, validated. `kept` holds the style's current attributes (`charStyle.edit`,
/// `charStyle.setAttrs`), the target text's attributes (`charStyle.new`) or the attributes of the
/// style being copied (`charStyle.duplicate`): a language tag that is not in the list, a swatch link the document does
/// not have, or an OpenType feature tag the shaper does not know, which a file can give a style or
/// a run, stays when `attrs` keeps it.
fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Map<String, Value>>) -> Result<Option<Map<String, Value>>> {
match p.get("attrs") {
None | Some(Value::Null) => Ok(None),
Some(Value::Object(m)) => {
// Validate against the attribute set, and store values in their canonical serialized
// form (so comparing a style with its text's attributes is exact).
let c = kind.cmd(verb);
let full = match kind {
Kind::Char => {
if m.contains_key("glyphAlternate") {
return Err(bad(&c, "`glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it)"));
}
let mut st = with_attrs(&CharStyle::default(), m, &c)?;
let changed = |key: &str| m.contains_key(key) && kept.and_then(|k| k.get(key)) != m.get(key);
for (key, value, lo, hi) in
[("size", st.size, 0.1, 1296.0), ("h_scale", st.h_scale, 1.0, 10000.0), ("v_scale", st.v_scale, 1.0, 10000.0)]
{
if changed(key) && !(lo..=hi).contains(&value) {
return Err(bad(&c, format!("`{key}` must be from {lo} to {hi}")));
}
}
if changed("leading") && st.leading.is_some_and(|value| !(0.1..=5000.0).contains(&value)) {
return Err(bad(&c, "`leading` must be from 0.1 to 5000, or null for Auto"));
}
let shift_max = 1296.0;
for (key, value) in [("baseline_shift", st.baseline_shift), ("tcyUpDown", st.tcy_up_down), ("tcyLeftRight", st.tcy_left_right)] {
if changed(key) && !(-shift_max..=shift_max).contains(&value) {
return Err(bad(&c, format!("`{key}` must be from {} to {shift_max}", -shift_max)));
}
}
// A language of the list, stored as its tag (as text.setFormat takes it).
if let Some(l) = &st.language
&& kept.and_then(|k| k.get("language")) != m.get("language")
{
st.language = vectorcraft_doc::setup::char_language(l).map_err(|e| bad(&c, e))?.map(str::to_string);
}
for (key, paint) in [("fill", &mut st.fill), ("stroke", &mut st.stroke)] {
if changed(key) {
style_paint(d, paint, key, &c)?;
}
}
let (key, lo, hi) = ("stroke_width", 0.0, vectorcraft_doc::MAX_COORD);
if changed(key) && !(lo..=hi).contains(&st.stroke_width) {
return Err(bad(&c, format!("`{key}` must be from {lo} to {hi}")));
}
if changed("features") {
style_features(&st.features, kept, &c)?;
}
char_attrs(&st)
}
Kind::Para => para_attrs(&with_attrs(&ParaStyle::default(), m, &c)?),
};
Ok(Some(m.keys().filter_map(|k| full.get(k).map(|v| (k.clone(), v.clone()))).collect()))
}
Some(_) => Err(bad(&kind.cmd(verb), "attrs must be an object")),
}
}
/// Check a character style's `fill` or `stroke` (`key`): each solid color and gradient stop has a
/// tint from 0 to 1, and a link to a global swatch of `d`, whose color at that tint it takes.
fn style_paint(d: &Document, paint: &mut vectorcraft_color::Paint, key: &str, c: &str) -> Result<()> {
match paint {
vectorcraft_color::Paint::Solid { color, swatch, tint } => style_color(d, color, swatch.as_deref(), *tint, key, c),
vectorcraft_color::Paint::Gradient(g) => {
g.gradient.stops.iter_mut().try_for_each(|stop| style_color(d, &mut stop.color, stop.swatch.as_deref(), stop.tint, key, c))
}
_ => Ok(()),
}
}
/// Check one color of a character style's `key` paint for [`style_paint`].
fn style_color(d: &Document, color: &mut vectorcraft_color::Color, swatch: Option<&str>, tint: f32, key: &str, c: &str) -> Result<()> {
if !(0.0..=1.0).contains(&tint) {
let (key, lo, hi) = (format!("{key}.tint"), 0, 1);
return Err(bad(c, format!("`{key}` must be from {lo} to {hi}")));
}
if let Some(name) = swatch {
if d.swatch(name).is_none() {
return Err(bad(c, format!("no swatch `{name}`")));
}
let base = d.global_color(name).ok_or_else(|| bad(c, format!("`{name}` is a process colour: tints apply to global and spot colours")))?;
*color = base.tinted(tint);
}
Ok(())
}
/// Check a character style's OpenType `features`: at most [`vectorcraft_text::MAX_STYLE_FEATURES`]
/// tags, or no more than the `kept` attributes already list, each one the shaper knows or one the
/// `kept` attributes already list.
fn style_features(features: &[String], kept: Option<&Map<String, Value>>, c: &str) -> Result<()> {
let kept: Vec<&str> = kept.and_then(|k| k.get("features")).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect();
let max = vectorcraft_text::MAX_STYLE_FEATURES;
if features.len() > max.max(kept.len()) {
return Err(bad(c, format!("features lists at most {max} tags")));
}
// One `-` or `+` at most, as the shaper reads a tag.
let known = |t: &str| vectorcraft_text::OtFeatures::known_tag(t) && !t.strip_prefix(['-', '+']).unwrap_or(t).starts_with(['-', '+']);
match features.iter().find(|t| !known(t) && !kept.contains(&t.as_str())) {
Some(t) => Err(bad(c, format!("unknown OpenType feature `{t}`"))),
None => Ok(()),
}
}
/// The attributes of a new style made from nothing (no `attrs`, no text selected): while the
/// interface is in Japanese, new type's em box top-to-top leading (paragraph styles) and em box
/// centre alignment (character styles); else none (the defaults).
fn new_style_attrs(s: &Session, kind: Kind) -> Map<String, Value> {
if !s.japanese_interface() {
return Map::new();
}
let (set, default) = match kind {
Kind::Char => {
let st = CharStyle { char_align: vectorcraft_doc::CharAlign::EmBoxCenter, ..CharStyle::default() };
(to_attrs(&st), to_attrs(&CharStyle::default()))
}
Kind::Para => {
let st = ParaStyle { leading_model: vectorcraft_doc::LeadingModel::EmBoxTop, ..ParaStyle::default() };
(to_attrs(&st), to_attrs(&ParaStyle::default()))
}
};
set.into_iter().filter(|(k, v)| default.get(k) != Some(v)).collect()
}
// ---------- commands ----------
fn list(s: &mut Session, kind: Kind) -> Result<Value> {
let d = &s.doc()?.doc;
let mut names = vec![kind.normal().to_string()];
names.extend(kind.defs(d).iter().map(|s| s.name.clone()).filter(|n| n != kind.normal()));
let uses = uses(d, kind);
let styles: Vec<Value> = names.iter().map(|n| json!({ "name": n, "attrs": kind.attrs(d, n), "uses": uses_of(&uses, kind, n) })).collect();
Ok(json!({ "styles": styles }))
}
fn new(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
new_from(s, p, kind, None)
}
/// `charStyle.new`, `paraStyle.new`: `copied` holds the attributes of the style a copy is made of
/// (`charStyle.duplicate`), which `attrs` keeps as [`attrs_param`] keeps the target text's.
fn new_from(s: &mut Session, p: &Value, kind: Kind, copied: Option<&Map<String, Value>>) -> Result<Value> {
let c = kind.cmd("new");
let target = selection_attrs(s, p, kind, "new")?;
let attrs = match attrs_param(&s.doc()?.doc, p, kind, "new", copied.or(target.as_ref()))? {
Some(a) => a,
None => match target {
Some(a) => a,
None => {
let a = new_style_attrs(s, kind);
s.note_journal("attrs", Value::Object(a.clone()));
a
}
},
};
let d = &s.doc()?.doc;
let name = match str_param(p, "name") {
Some(n) => n.trim().to_string(),
None => new_style_name(d, kind == Kind::Para),
};
if name.is_empty() {
return Err(bad(&c, "give the style a name"));
}
if kind.attrs(d, &name).is_some() {
return Err(bad(&c, format!("a style named `{name}` already exists")));
}
let n2 = name.clone();
s.edit(if kind == Kind::Char { "New Character Style" } else { "New Paragraph Style" }, |d, _| {
kind.defs_mut(d).push(TextStyleDef { name: n2, attrs });
Ok(())
})?;
Ok(json!({ "name": name }))
}
fn apply(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
let c = kind.cmd("apply");
let name = name_param(p, kind, "apply")?.to_string();
let d = &s.doc()?.doc;
let attrs = kind.attrs(d, &name).ok_or_else(|| bad(&c, format!("no style `{name}`")))?;
let normal = kind.attrs(d, kind.normal()).unwrap_or_default();
let clear = bool_or(p, "clearOverrides", false);
let style_name = (name != kind.normal()).then(|| name.clone());
// A Type tool range or whole text objects.
let text_range = super::typecmd::TextRange::parse(p, &c)?;
let given = object_param(p, "id", &c)?;
let range = given.filter(|_| text_range.is_some());
let ids = match given {
Some(id) => vec![id],
None => text_targets(s, p, &c)?,
};
// Characters: Normal then the style over the defaults (clear) or over the current attributes.
let char_fn = |cur: &CharStyle| -> Result<CharStyle> {
let base = if clear { with_attrs(&CharStyle::default(), &normal, &c)? } else { cur.clone() };
let mut st = font_change(&base, with_attrs(&base, &attrs, &c)?, &attrs);
st.style_name = style_name.clone();
Ok(st)
};
let para_fn = |cur: &ParaStyle| -> Result<ParaStyle> {
let base = if clear { with_attrs(&ParaStyle::default(), &normal, &c)? } else { cur.clone() };
let mut st = with_attrs(&base, &attrs, &c)?;
st.style_name = style_name.clone();
Ok(st)
};
let count = ids.len();
s.edit_named("Apply {name}", &name, name == kind.normal(), |d, _| {
for id in &ids {
let Some(t) = text_mut(d, *id) else { continue };
match kind {
Kind::Char => {
let len = vectorcraft_text::edit::runs_len(&t.runs);
let (a, b) = match (range, text_range) {
(Some(_), Some(r)) => r.bytes(t),
_ => (0, len),
};
let mut err = None;
vectorcraft_text::edit::style_range(&mut t.runs, a.min(b), a.max(b), |st| match char_fn(st) {
Ok(n) => *st = n,
Err(e) => err = Some(e),
});
if len == 0 {
for r in &mut t.runs {
r.style = char_fn(&r.style)?;
}
}
if let Some(e) = err {
return Err(e);
}
}
Kind::Para => {
let mut v = t.paragraph_styles();
let span = match (range, text_range) {
(Some(_), Some(r)) => r.paras(t),
_ => 0..v.len(),
};
for st in v.iter_mut().take(span.end).skip(span.start) {
*st = para_fn(st)?;
}
t.set_paragraph_styles(v);
}
}
refresh_bounds(t);
}
Ok(())
})?;
Ok(json!({ "count": count }))
}
fn redefine_to(s: &mut Session, kind: Kind, name: &str, new_attrs: Map<String, Value>, verb: &str) -> Result<Value> {
let d = &s.doc()?.doc;
let old = kind.attrs(d, name).ok_or_else(|| bad(&kind.cmd(verb), format!("no style `{name}`")))?;
if new_attrs == old {
return ok();
}
let name = name.to_string();
s.edit_named("Redefine {name}", &name, name == kind.normal(), |d, _| {
match kind.defs_mut(d).iter_mut().find(|s| s.name == name) {
Some(def) => def.attrs = new_attrs.clone(),
None => kind.defs_mut(d).push(TextStyleDef { name: name.clone(), attrs: new_attrs.clone() }),
}
update_users(d, kind, &name, &old, &new_attrs)?;
Ok(())
})?;
ok()
}
fn redefine(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
let name = name_param(p, kind, "redefine")?.to_string();
let attrs = selection_attrs(s, p, kind, "redefine")?.ok_or_else(|| bad(&kind.cmd("redefine"), "select text to redefine the style from"))?;
redefine_to(s, kind, &name, attrs, "redefine")
}
fn set_attrs(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
let name = name_param(p, kind, "setAttrs")?.to_string();
let d = &s.doc()?.doc;
let old = kind.attrs(d, &name);
let attrs = attrs_param(d, p, kind, "setAttrs", old.as_ref())?.ok_or_else(|| bad(&kind.cmd("setAttrs"), "missing `attrs`"))?;
redefine_to(s, kind, &name, attrs, "setAttrs")
}
fn edit_style(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
let c = kind.cmd("edit");
let name = name_param(p, kind, "edit")?.to_string();
let to = str_param(p, "to").ok_or_else(|| bad(&c, "missing `to`"))?.trim().to_string();
let d = &s.doc()?.doc;
let old = kind.attrs(d, &name).ok_or_else(|| bad(&c, format!("no style `{name}`")))?;
let attrs = attrs_param(d, p, kind, "edit", Some(&old))?.ok_or_else(|| bad(&c, "missing `attrs`"))?;
if name == kind.normal() {
return Err(bad(&c, format!("`{name}` can't be edited this way")));
}
if to.is_empty() {
return Err(bad(&c, "give the style a name"));
}
if to != name && kind.attrs(d, &to).is_some() {
return Err(bad(&c, format!("a style named `{to}` already exists")));
}
if to == name && attrs == old {
return ok();
}
s.edit(if kind == Kind::Char { "Character Style Options" } else { "Paragraph Style Options" }, |d, _| {
if let Some(def) = kind.defs_mut(d).iter_mut().find(|def| def.name == name) {
def.name = to.clone();
def.attrs = attrs.clone();
}
update_users(d, kind, &name, &old, &attrs)?;
if to != name {
each_text_mut(d, &mut |t| {
let fix = |style_name: &mut Option<String>| {
if style_name.as_deref() == Some(name.as_str()) {
*style_name = Some(to.clone());
}
};
match kind {
Kind::Char => t.runs.iter_mut().for_each(|r| fix(&mut r.style.style_name)),
Kind::Para => t.para_styles_mut().for_each(|pa| fix(&mut pa.style_name)),
}
Ok(())
})?;
}
Ok(())
})?;
ok()
}
fn duplicate(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
let name = name_param(p, kind, "duplicate")?.to_string();
let d = &s.doc()?.doc;
let attrs = kind.attrs(d, &name).ok_or_else(|| bad(&kind.cmd("duplicate"), format!("no style `{name}`")))?;
// `new` trims the name, so the copy is named from the trimmed one.
let stem = name.trim();
let copy = (1..)
.map(|i| if i == 1 { format!("{stem} copy") } else { format!("{stem} copy {i}") })
.find(|n| kind.attrs(d, n).is_none())
.unwrap_or_else(|| format!("{stem} copy"));
new_from(s, &json!({ "name": copy, "attrs": attrs }), kind, Some(&attrs))
}
fn rename(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
let c = kind.cmd("rename");
let name = name_param(p, kind, "rename")?.to_string();
let to = str_param(p, "to").ok_or_else(|| bad(&c, "missing `to`"))?.trim().to_string();
let d = &s.doc()?.doc;
if name == kind.normal() || !kind.defs(d).iter().any(|s| s.name == name) {
return Err(bad(&c, format!("`{name}` can't be renamed")));
}
if to.is_empty() || kind.attrs(d, &to).is_some() {
return Err(bad(&c, format!("`{to}` is empty or already used")));
}
s.edit("Rename Style", |d, _| {
if let Some(def) = kind.defs_mut(d).iter_mut().find(|s| s.name == name) {
def.name = to.clone();
}
each_text_mut(d, &mut |t| {
let fix = |n: &mut Option<String>| {
if n.as_deref() == Some(name.as_str()) {
*n = Some(to.clone());
}
};
match kind {
Kind::Char => t.runs.iter_mut().for_each(|r| fix(&mut r.style.style_name)),
Kind::Para => t.para_styles_mut().for_each(|pa| fix(&mut pa.style_name)),
}
Ok(())
})?;
Ok(())
})?;
ok()
}
fn delete(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
let c = kind.cmd("delete");
let names: Vec<String> = match p.get("names") {
Some(v) => v
.as_array()
.and_then(|a| a.iter().map(|n| n.as_str().map(str::to_string)).collect::<Option<Vec<_>>>())
.ok_or_else(|| bad(&c, "`names` must be an array of style names"))?,
None => vec![name_param(p, kind, "delete")?.to_string()],
};
if names.is_empty() {
return Err(bad(&c, "missing `name`"));
}
let defs = kind.defs(&s.doc()?.doc);
if let Some(name) = names.iter().find(|n| *n == kind.normal() || !defs.iter().any(|s| s.name == **n)) {
return Err(bad(&c, format!("`{name}` can't be deleted")));
}
s.edit(if names.len() > 1 { "Delete Styles" } else { "Delete Style" }, |d, _| {
kind.defs_mut(d).retain(|s| !names.contains(&s.name));
each_text_mut(d, &mut |t| {
let fix = |n: &mut Option<String>| {
if n.as_ref().is_some_and(|n| names.contains(n)) {
*n = None;
}
};
match kind {
Kind::Char => t.runs.iter_mut().for_each(|r| fix(&mut r.style.style_name)),
Kind::Para => t.para_styles_mut().for_each(|pa| fix(&mut pa.style_name)),
}
Ok(())
})?;
Ok(())
})?;
ok()
}
/// The styles no text uses (see [`uses`]), in the order the panel lists them, without the Normal
/// style.
fn unused(s: &mut Session, kind: Kind) -> Result<Value> {
let d = &s.doc()?.doc;
let uses = uses(d, kind);
let names: Vec<&str> = kind.defs(d).iter().map(|s| s.name.as_str()).filter(|n| *n != kind.normal() && uses_of(&uses, kind, n) == 0).collect();
Ok(json!({ "names": names }))
}
/// The styles of `kind` in `src` that load, with their source names so that art in the file keeps
/// referring to them: not the Normal style, and not one whose attributes don't read, whose sizes,
/// scales, leading, shifts or stroke width are out of the range `charStyle.new` takes, or whose name
/// is empty, which are skipped. → (the styles, the names in `src` of those skipped)
fn loadable(src: &Document, kind: Kind) -> (Vec<TextStyleDef>, Vec<String>) {
const RANGED: [&str; 8] = ["size", "h_scale", "v_scale", "leading", "baseline_shift", "tcyUpDown", "tcyLeftRight", "stroke_width"];
let (mut defs, mut skipped) = (vec![], vec![]);
for def in kind.defs(src) {
let name = def.name.as_str();
if name.trim() == kind.normal() {
continue;
}
// Paints, features and language are taken as the file stores them; the ranged values are checked.
let mut kept = def.attrs.clone();
kept.retain(|k, _| !RANGED.contains(&k.as_str()));
match attrs_param(src, &json!({ "attrs": def.attrs }), kind, "load", Some(&kept)).ok().flatten().filter(|_| !name.trim().is_empty()) {
Some(attrs) => defs.push(TextStyleDef { name: name.to_string(), attrs }),
None => skipped.push(def.name.clone()),
}
}
(defs, skipped)
}
/// Load Character Styles, Load Paragraph Styles and Load All Styles: the `kinds` of style of the
/// document `p` gives (`path`, `data` or `dataBase64`) added to the active one with the swatches and
/// patterns they paint with, as Paste adds those of pasted type ([`import_text_styles`]).
fn load(s: &mut Session, p: &Value, kinds: &[Kind]) -> Result<Value> {
let label = match kinds {
[Kind::Char] => "Load Character Styles",
[Kind::Para] => "Load Paragraph Styles",
_ => "Load All Styles",
};
let c = &match kinds {
[kind] => kind.cmd("load"),
_ => "textStyle.loadAll".to_string(),
};
let path = str_param(p, "path");
let bytes = match (path, str_param(p, "data"), str_param(p, "dataBase64")) {
(Some(path), ..) => super::fileio::read_file(path)?,
(None, Some(text), _) => text.as_bytes().to_vec(),
(None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(c, "bad dataBase64"))?,
_ => return Err(bad(c, "give `path`, `data` or `dataBase64`")),
};
let file = str_param(p, "name").or(path).unwrap_or("Styles");
let src = super::fileio::load(file, &bytes).map_err(|e| bad(c, e.to_string()))?;
if src.preview_only {
return Err(bad(c, "only this Affinity file's embedded preview could be read; use File › Open to see it with its warning"));
}
// The source with only the styles that load. Those of a kind not loaded still come along when
// a pattern or symbol that a loaded style paints with sets type in them.
let mut src = src.doc;
let mut skipped = [vec![], vec![]];
for kind in [Kind::Char, Kind::Para] {
let (defs, names) = loadable(&src, kind);
*kind.defs_mut(&mut src) = defs;
if kinds.contains(&kind) {
skipped[usize::from(kind == Kind::Para)] = names;
}
}
let (chars, paras) = (kinds.contains(&Kind::Char), kinds.contains(&Kind::Para));
let mut trial = (*s.doc()?.doc).clone();
let mut placed = import_text_styles(&src, &mut trial, chars, paras);
// An undo step only when the document changes. The step takes the document the import made,
// unless the edit first ends a typing session, which can change the document it starts from.
if trial != *s.doc()?.doc {
if super::typing_in_progress(s) {
placed = s.edit(label, |d, _| Ok(import_text_styles(&src, d, chars, paras)))?;
} else {
s.edit(label, |d, _| {
*d = trial;
Ok(())
})?;
}
}
let reply = |para: bool| {
let names = |added: bool| placed.iter().filter(|p| p.para == para && p.added == added).map(|p| p.name.as_str()).collect::<Vec<_>>();
json!({ "added": names(true), "existing": names(false), "skipped": skipped[usize::from(para)] })
};
Ok(match kinds {
[kind] => reply(*kind == Kind::Para),
_ => json!({ "charStyles": reply(false), "paraStyles": reply(true) }),
})
}
#[cfg(test)]
mod tests;