Files
vectorcraft/crates/engine/src/cmd/panelcmds.rs
T
VictorandClaude Opus 5.5 a275496ef4 G4: Paragraph panel menu › Justification… (word and letter spacing, glyph scaling, auto leading, single word justification)
The Paragraph panel menu's Justification… was a disabled stub, and justified
lines stretched word spaces between fixed limits (80% to 150%).

Each paragraph now has Justification options: Word Spacing, Letter Spacing
and Glyph Scaling (Minimum, Desired, Maximum, in percent), Auto Leading and
Single Word Justification. Desired applies to every line; a justified line
fills its measure with word spacing first, then letter spacing, then glyph
scaling, each within its range, and past them its word spaces take the
rest. The Every-line Composer scores lines by that flexibility. Auto
Leading sets the leading of characters whose leading is Auto. A justified
line holding one word is spread between its letters (Full Justify) or
aligned left, centre or right.

The defaults are the documented ones (80/100/133, 0/0/0, 100/100/100, 120,
Full Justify), saved only when they differ. Because the default maximum
word spacing is 133%, justified paragraphs break differently than before;
the Every-line Composer's second pass is now unbounded for justified text
(it used to give up past four times the stretch and fall back to the
Single-line Composer, which the narrower default range would make common),
and lines that can't stretch at all are still refused.

text.setFormat takes wordSpacing, letterSpacing, glyphScaling ([min,
desired, max] or {min?, desired?, max?}), autoLeading and
singleWordJustification; Minimum ≤ Desired ≤ Maximum must hold or nothing
changes. Paragraph styles carry the options (New and Redefine capture
them), and Reset Panel resets them. Justification… opens a dialog
(ui.justification) with Preview; OK is disabled while a row is out of
order. Live SVG text places characters the options move, and a glyph that
Glyph Scaling scales is stretched to its width; PDF uses the glyphs'
transforms. Vertical type isn't glyph scaled; type on a path doesn't use
the options.

Docs: docs/mcp.md, docs/type-parity.md, docs/gaps.md.

Closes #2755.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:40:14 +07:00

543 lines
27 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.
//! Commands the panels need beyond the core set: artboard reorder, duplicate, copy and cut, and the advanced
//! Character/Paragraph attributes.
use serde_json::{Value, json};
use vectorcraft_doc::{Composer, JustificationOptions, NodeKind, Scaling, SingleWordJustify};
use vectorcraft_geom::{Rect, Vec2};
use super::*;
use crate::{Clipboard, EngineError};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("artboard.reorder", "Move Artboard Up/Down", [], None, "{index, to} change an artboard's number", has_doc, artboard_reorder),
cmd!(
"artboard.duplicate",
"Duplicate Artboard",
["Window", "Artboards"],
None,
"{index?: n | indices?: [n…] (default: the artboards the Artboard tool has selected), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} a copy of each artboard, the copies placed together right of the last artboard as they lay, with copies of the art fully inside each when `art` (locked and hidden art only with prefs moveLockedWithArtboard), in one undo step → {index: the first copy's index, indices: the copies' indices, ids: the art's copies}",
has_doc,
artboard_duplicate
),
cmd!(
"artboard.copy",
"Copy Artboard",
[],
None,
"{index? | indices?, art?} (defaults as artboard.duplicate) put the artboards, and the art fully inside each when `art`, on the clipboard: edit.paste adds a copy of each, together right of the last artboard as they lay, edit.pasteInPlace/InFront/InBack where they were, in this or another document (one undo step; edit.pasteOnAllArtboards pastes only the art); edit.copy runs this while the Artboard tool is chosen → {copied: objects}",
has_doc,
|s, p| artboard_copy(s, p, false)
),
cmd!(
"artboard.cut",
"Cut Artboard",
[],
None,
"{index? | indices?, art?} artboard.copy, then delete the artboards and that art in one undo step (one artboard is always left: the cut fails when it names every artboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}",
has_doc,
|s, p| artboard_copy(s, p, true)
),
cmd!(
"text.setFormat",
"Character / Paragraph",
[],
None,
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
has_doc,
set_format
),
]
}
// ---------- artboards ----------
fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "artboard.reorder";
let i = artboard_index(&s.doc()?.doc, p, C)?;
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing to"))? as usize;
s.edit("Reorder Artboards", |d, _| {
if i >= d.artboards.len() {
return Err(EngineError::Other("no such artboard".into()));
}
let a = d.artboards.remove(i);
let to = to.min(d.artboards.len());
d.artboards.insert(to, a);
Ok(())
})?;
ok()
}
/// The artboards (`index` or `indices`) and whether their art comes along (`art`) for the artboard
/// copy commands: by default the artboards the Artboard tool has selected (its active one when none
/// of them is in the document) and its Move/Copy Artwork with Artboard option.
fn boards_and_art(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<usize>, bool)> {
let t = s.tool_options_of("artboard");
let d = &s.doc()?.doc;
let indices = if p.get("index").is_some() || p.get("indices").is_some() {
artboard_indices(d, p, cmd)?
} else {
let mut picked: Vec<u64> = t["selected"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_u64)
.filter(|i| usize::try_from(*i).is_ok_and(|i| i < d.artboards.len()))
.collect();
if picked.is_empty() {
picked.push(t["active"].as_u64().unwrap_or(0));
}
artboard_indices(d, &json!({ "indices": picked }), cmd)?
};
Ok((indices, bool_or(p, "art", t["moveArt"].as_bool().unwrap_or(true))))
}
/// The one artboard `cmd` acts on: `index`, an artboard of `d`, with the errors of
/// [`artboard_indices`] (`missing index`, `no such artboard`).
pub(crate) fn artboard_index(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result<usize> {
let i = p.get("index").ok_or_else(|| bad(cmd, "missing index"))?;
artboard_indices(d, &json!({ "index": i }), cmd)?.into_iter().next().ok_or_else(|| EngineError::Other("no such artboard".into()))
}
/// The artboards `cmd` acts on: `indices` (several), else `index` (one), each an artboard of `d`,
/// in order and once each.
pub(crate) fn artboard_indices(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result<Vec<usize>> {
let listed = match (p.get("indices"), p.get("index")) {
(Some(v), _) => point_indices(v),
(None, Some(i)) => point_indices(&json!([i])),
(None, None) => return Err(bad(cmd, "missing index")),
};
let mut indices =
listed.filter(|v| !v.is_empty() && v.iter().all(|i| *i < d.artboards.len())).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
indices.sort_unstable();
indices.dedup();
Ok(indices)
}
/// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden
/// ones only with prefs moveLockedWithArtboard), in paint order.
pub(crate) fn artboard_art(s: &Session, i: usize, art: bool) -> Result<Vec<NodeId>> {
let d = &s.doc()?.doc;
let rect = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
Ok(if art { d.art_on_artboard(rect, s.prefs.move_locked_with_artboard) } else { vec![] })
}
/// Each of `indices` with the art that goes with it when `art` ([`artboard_art`]), an object
/// counted once, with the first of them it is on (artboards can overlap).
pub(crate) fn artboards_art(s: &Session, indices: &[usize], art: bool) -> Result<Vec<(usize, Vec<NodeId>)>> {
let mut seen = std::collections::HashSet::new();
let mut arts = vec![];
for &i in indices {
let art = artboard_art(s, i, art)?.into_iter().filter(|id| seen.insert(*id)).collect();
arts.push((i, art));
}
Ok(arts)
}
/// How far a copy of `rect` moves to sit right of the last artboard (Duplicate Artboards, Paste).
pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec2 {
let right = d.artboards.iter().map(|a| a.rect.x1).fold(f64::MIN, f64::max);
Vec2::new(right + 20.0 - rect.x0, 0.0)
}
/// Duplicate Artboards: a copy of each artboard with its art, in one undo step. The copies keep
/// their places relative to each other, together right of the last artboard.
fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let (indices, art) = boards_and_art(s, p, "artboard.duplicate")?;
let arts = artboards_art(s, &indices, art)?;
let all: Vec<NodeId> = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect();
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &all)?, ..Scaling::default() };
let (index, ids) = s.edit("Duplicate Artboard", |d, _| {
let dv = arts
.iter()
.filter_map(|(i, _)| d.artboards.get(*i))
.map(|a| a.rect)
.reduce(|a, b| a.union(b))
.map(|r| beside_artboards(d, r))
.unwrap_or_default();
let (mut index, mut ids) = (vec![], vec![]);
for (i, art) in &arts {
let (at, copies) = copy_artboard(d, *i, dv, art, sc)?;
index.push(at);
ids.extend(copies);
}
Ok((index, ids))
})?;
Ok(json!({"index": index.first(), "indices": index, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
/// Artboard Copy / Cut: the artboards and their art onto the clipboard (pasting brings them all);
/// a cut then deletes them.
pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result<Value> {
let (indices, art) = boards_and_art(s, p, if cut { "artboard.cut" } else { "artboard.copy" })?;
let arts = artboards_art(s, &indices, art)?;
let st = s.doc()?;
if cut && st.doc.artboards.len() <= indices.len() {
return Err(EngineError::Other("a document needs at least one artboard".into()));
}
let art = st.doc.paint_order(arts.into_iter().flat_map(|(_, a)| a));
let boards: Vec<_> = indices.iter().filter_map(|i| st.doc.artboards.get(*i).cloned()).collect();
let clip = Clipboard { source_artboard: boards.first().map(|a| a.rect), artboards: boards, ..Clipboard::copy(st, &art) };
let copied = clip.nodes.len();
if cut {
s.edit("Cut Artboard", |d, sel| {
if indices.iter().any(|i| *i >= d.artboards.len()) {
return Err(EngineError::Other("no such artboard".into()));
}
// The last first, so the others keep their places; their guides go with them.
for &i in indices.iter().rev() {
let gone = d.artboards.remove(i).id;
d.retain_guides(sel, |_, g| g.artboard != Some(gone));
}
d.remove_many(&art)?;
sel.prune(d);
Ok(())
})?;
if s.tool_id() == "artboard" {
s.set_tool_option("active", &json!(indices.first().map_or(0, |i| i.saturating_sub(1))));
}
}
s.clipboard = clip;
Ok(json!({"copied": copied}))
}
/// Add a copy of artboard `i` moved by `dv`, with copies of its guides and of its `art` (each just
/// above its original) moved along → (the copy's index, the art's copies). When `sc.patterns`, the
/// pattern tiles of the art's copies move with them. Duplicate Artboards and Alt-drag.
pub(crate) fn copy_artboard(d: &mut vectorcraft_doc::Document, i: usize, dv: Vec2, art: &[NodeId], sc: Scaling) -> Result<(usize, Vec<NodeId>)> {
let src = d.artboards.get(i).cloned().ok_or_else(|| EngineError::Other("no such artboard".into()))?;
let index = push_artboard_copy(d, &src, src.rect + dv);
// Its guides come along.
if let Some(id) = d.artboards.get(index).map(|a| a.id) {
d.copy_artboard_guides(src.id, id, dv);
}
let copies = super::edit::copy_above(d, art, |_, _, c| c.transform(Affine::translate(dv), sc))?;
Ok((index, copies))
}
/// Add a copy of artboard `src` at `rect`, with an id of its own and its name, or `<name> copy`
/// (`<name> copy 2`…) when that is taken → its index.
pub(crate) fn push_artboard_copy(d: &mut vectorcraft_doc::Document, src: &vectorcraft_doc::Artboard, rect: Rect) -> usize {
let mut a = src.clone();
a.id = d.next_artboard_id();
let taken = |name: &str| d.artboards.iter().any(|a| a.name == name);
a.name = [src.name.clone(), format!("{} copy", src.name)]
.into_iter()
.chain((2u64..).map(|i| format!("{} copy {i}", src.name)))
.find(|name| !taken(name))
.unwrap_or_default();
a.rect = rect;
d.artboards.push(a);
d.artboards.len() - 1
}
// ---------- text ----------
/// Largest indent or paragraph spacing (points; Illustrator's limit).
const MAX_PARA: f64 = 1296.0;
/// A justification spacing of `text.setFormat` (`wordSpacing`, `letterSpacing`, `glyphScaling`):
/// `[min, desired, max]` or `{min?, desired?, max?}`, in percent. The values not given are kept.
fn spacing_param(p: &Value, key: &str, c: &str) -> Result<Option<[Option<f64>; 3]>> {
let number = |v: &Value| v.as_f64().filter(|v| v.is_finite());
let err = || bad(c, format!("`{key}` must be [min, desired, max] or {{min?, desired?, max?}}, in percent"));
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::Array(a)) if a.len() == 3 => {
let mut out = [None; 3];
for (o, v) in out.iter_mut().zip(a) {
*o = Some(number(v).ok_or_else(err)?);
}
Ok(Some(out))
}
Some(Value::Object(o)) => {
let mut out = [None; 3];
for (k, v) in o {
let i = ["min", "desired", "max"].iter().position(|n| n == k).ok_or_else(err)?;
if let Some(slot) = out.get_mut(i) {
*slot = Some(number(v).ok_or_else(err)?);
}
}
Ok(Some(out))
}
Some(_) => Err(err()),
}
}
fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.setFormat";
let ids = super::typecmd::text_targets(s, p, C)?;
let num = |k: &str| p.get(k).and_then(Value::as_f64);
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
let kerning = match p.get("kerning") {
None | Some(Value::Null) => None,
Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Some(None),
Some(v) => Some(Some(v.as_f64().ok_or_else(|| bad(C, "kerning must be a number or \"auto\""))?.clamp(-1000.0, 10000.0))),
};
let keys = [
"kerning",
"baselineShift",
"hScale",
"vScale",
"rotation",
"underline",
"strikethrough",
"allCaps",
"smallCaps",
"position",
"leftIndent",
"rightIndent",
"firstLineIndent",
"spaceBefore",
"spaceAfter",
"hyphenate",
"wordsLongerThan",
"afterFirst",
"beforeLast",
"hyphenLimit",
"hyphenationZone",
"hyphenateCapitalized",
"wordSpacing",
"letterSpacing",
"glyphScaling",
"autoLeading",
"singleWordJustification",
"mojikumi",
"kinsoku",
"direction",
"leadingModel",
"charAlign",
"proportionalMetrics",
"digits",
"fontAxes",
"burasagari",
"romanHangingPunctuation",
"composer",
];
if !keys.iter().any(|k| p.get(*k).is_some()) {
return Err(bad(C, "nothing to change"));
}
let composer = match p.get("composer") {
None | Some(Value::Null) => None,
Some(v) => Some(match v.as_str() {
Some("singleLine") => Composer::SingleLine,
Some("everyLine") => Composer::EveryLine,
_ => return Err(bad(C, "composer must be \"singleLine\" or \"everyLine\"")),
}),
};
let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?;
let char_align = super::textedit::char_align_param(p, C)?;
let digits = super::textedit::digits_param(p, C)?;
let font_axes = super::textedit::font_axes_param(p, C)?;
let mojikumi = match p.get("mojikumi") {
None => None,
Some(v) => Some(match v.as_str() {
Some("none") => vectorcraft_doc::Mojikumi::None,
Some("lineEndHalf") => vectorcraft_doc::Mojikumi::LineEndHalf,
_ => return Err(bad(C, "`mojikumi` must be \"none\" or \"lineEndHalf\"")),
}),
};
let kinsoku = match p.get("kinsoku") {
None => None,
Some(v) => Some(match v.as_str() {
Some("none") => vectorcraft_doc::Kinsoku::None,
Some("hard") => vectorcraft_doc::Kinsoku::Hard,
Some("soft") => vectorcraft_doc::Kinsoku::Soft,
_ => return Err(bad(C, "`kinsoku` must be \"none\", \"hard\" or \"soft\"")),
}),
};
let direction = match p.get("direction") {
None => None,
Some(v) => Some(match v.as_str() {
Some("auto") => None,
Some("leftToRight") => Some(vectorcraft_doc::ParaDirection::LeftToRight),
Some("rightToLeft") => Some(vectorcraft_doc::ParaDirection::RightToLeft),
_ => return Err(bad(C, "`direction` must be \"auto\", \"leftToRight\" or \"rightToLeft\"")),
}),
};
let leading_model = match p.get("leadingModel") {
None => None,
Some(v) => Some(match v.as_str() {
Some("romanBaseline") => vectorcraft_doc::LeadingModel::RomanBaseline,
Some("emBoxTop") => vectorcraft_doc::LeadingModel::EmBoxTop,
_ => return Err(bad(C, "`leadingModel` must be \"romanBaseline\" or \"emBoxTop\"")),
}),
};
let burasagari = match p.get("burasagari") {
None => None,
Some(v) => Some(match v.as_str() {
Some("none") => vectorcraft_doc::Burasagari::None,
Some("standard") => vectorcraft_doc::Burasagari::Standard,
Some("forced") => vectorcraft_doc::Burasagari::Forced,
_ => return Err(bad(C, "`burasagari` must be \"none\", \"standard\" or \"forced\"")),
}),
};
let spacing = |k: &str| spacing_param(p, k, C);
let (word_spacing, letter_spacing, glyph_scaling) = (spacing("wordSpacing")?, spacing("letterSpacing")?, spacing("glyphScaling")?);
let single_word = match p.get("singleWordJustification") {
None => None,
Some(v) => Some(match v.as_str() {
Some("fullJustify") => SingleWordJustify::FullJustify,
Some("alignLeft") => SingleWordJustify::AlignLeft,
Some("alignCenter") => SingleWordJustify::AlignCenter,
Some("alignRight") => SingleWordJustify::AlignRight,
_ => return Err(bad(C, "`singleWordJustification` must be \"fullJustify\", \"alignLeft\", \"alignCenter\" or \"alignRight\"")),
}),
};
let auto_leading = match p.get("autoLeading") {
None => None,
Some(v) => Some(v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(C, "`autoLeading` must be a number"))?),
};
// A paragraph whose Minimum, Desired and Maximum would no longer be in order.
let mut out_of_order: Option<&str> = None;
let range = super::typecmd::TextRange::parse(p, C)?;
// The character attributes, given the baseline shift and horizontal scale in the type's own units.
let chars = |st: &mut vectorcraft_doc::CharStyle, baseline_shift: Option<f64>, h_scale: Option<f64>| {
if let Some(k) = kerning {
st.kerning = k;
}
if let Some(v) = baseline_shift {
st.baseline_shift = v;
}
if let Some(v) = h_scale {
st.h_scale = v;
}
if let Some(v) = num("vScale") {
st.v_scale = v.clamp(1.0, 10000.0);
}
if let Some(v) = num("rotation") {
st.rotation = ((v + 180.0).rem_euclid(360.0)) - 180.0;
}
if let Some(v) = flag("underline") {
st.underline = v;
}
if let Some(v) = flag("strikethrough") {
st.strikethrough = v;
}
if let Some(v) = flag("allCaps") {
st.all_caps = v;
}
if let Some(v) = position {
st.position = v;
}
if let Some(v) = small_caps {
st.small_caps = v;
}
if let Some(v) = char_align {
st.char_align = v;
}
if let Some(v) = digits {
st.digits = v;
}
if let Some(v) = flag("proportionalMetrics") {
st.proportional_metrics = v;
}
if let Some(v) = &font_axes {
super::textedit::change_font_axes(st, v);
}
};
s.edit("Character", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let baseline_shift = num("baselineShift").map(|v| super::typecmd::local_type_value(t, v, (-1296.0, 1296.0), false, C)).transpose()?;
let h_scale = num("hScale").map(|v| super::typecmd::local_type_value(t, v, (1.0, 10000.0), true, C)).transpose()?;
super::typecmd::style_chars(t, range, |st| chars(st, baseline_shift, h_scale));
let span = super::typecmd::para_span(range, t);
t.edit_paras(span, |para| {
if let Some(v) = num("leftIndent") {
para.left_indent = v.clamp(-MAX_PARA, MAX_PARA);
}
if let Some(v) = num("rightIndent") {
para.right_indent = v.clamp(-MAX_PARA, MAX_PARA);
}
if let Some(v) = num("firstLineIndent") {
para.first_line_indent = v.clamp(-MAX_PARA, MAX_PARA);
}
if let Some(v) = num("spaceBefore") {
para.space_before = v.clamp(-MAX_PARA, MAX_PARA);
}
if let Some(v) = num("spaceAfter") {
para.space_after = v.clamp(-MAX_PARA, MAX_PARA);
}
if let Some(v) = flag("hyphenate") {
para.hyphenate = v;
}
let letters = |k: &str, lo: f64, hi: f64| num(k).map(|v| v.round().clamp(lo, hi) as u32);
if let Some(v) = letters("wordsLongerThan", 1.0, 25.0) {
para.hyphenation.words_longer_than = v;
}
if let Some(v) = letters("afterFirst", 1.0, 15.0) {
para.hyphenation.after_first = v;
}
if let Some(v) = letters("beforeLast", 1.0, 15.0) {
para.hyphenation.before_last = v;
}
if let Some(v) = letters("hyphenLimit", 0.0, 25.0) {
para.hyphenation.hyphen_limit = v;
}
if let Some(v) = num("hyphenationZone") {
para.hyphenation.zone = v.clamp(0.0, MAX_PARA);
}
if let Some(v) = flag("hyphenateCapitalized") {
para.hyphenation.capitalized = v;
}
let j = &mut para.justification;
for (name, given, value, limits) in [
("wordSpacing", &word_spacing, &mut j.word_spacing, JustificationOptions::WORD_SPACING),
("letterSpacing", &letter_spacing, &mut j.letter_spacing, JustificationOptions::LETTER_SPACING),
("glyphScaling", &glyph_scaling, &mut j.glyph_scaling, JustificationOptions::GLYPH_SCALING),
] {
let Some(given) = given else { continue };
for (v, g) in value.iter_mut().zip(given) {
if let Some(g) = g {
*v = g.clamp(limits.0, limits.1);
}
}
if !(value[0] <= value[1] && value[1] <= value[2]) {
out_of_order.get_or_insert(name);
}
}
if let Some(v) = auto_leading {
j.auto_leading = v.clamp(JustificationOptions::AUTO_LEADING.0, JustificationOptions::AUTO_LEADING.1);
}
if let Some(v) = single_word {
j.single_word = v;
}
if let Some(v) = mojikumi {
para.mojikumi = v;
}
if let Some(v) = kinsoku {
para.kinsoku = v;
}
if let Some(v) = direction {
para.direction = v;
}
if let Some(v) = leading_model {
para.leading_model = v;
}
if let Some(v) = burasagari {
para.burasagari = v;
}
if let Some(v) = flag("romanHangingPunctuation") {
para.roman_hanging = v;
}
if let Some(v) = composer {
para.composer = v;
}
});
if let Some(name) = out_of_order {
return Err(bad(C, format!("`{name}`: Minimum must not exceed Desired, nor Desired Maximum")));
}
super::typecmd::refresh_bounds(t);
}
// Graph labels keep a change made to all of their text through the graph being drawn again.
if range.is_none() {
let shift = num("baselineShift").map(|v| v.clamp(-1296.0, 1296.0));
let scale = num("hScale").map(|v| v.clamp(1.0, 10000.0));
super::graph::capture_label_styles(d, &ids, &|st| chars(st, shift, scale));
}
Ok(())
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}