Files
vectorcraft/crates/engine/src/cmd/textedit.rs
T
dotCooCoo b5da39d7b5 Commands: true/false flags reject other values everywhere
The remaining flags under crates/engine/src/cmd that read a boolean with
get(..).and_then(Value::as_bool).unwrap_or(..) now go through switch_or,
so a value such as "false" or 1 is an error instead of the default.
Omitting a flag or giving null keeps the old behavior.

- effect.move: copy
- aligned on the appearance.*, stroke.set, transparency.set and effect.*
  commands, checked before any early return
- transparency.viewOpacityMask: on
- view.printTiling: on
- plugin.install: replace and allowHeavy, checked for every plug-in
- edit.clear: guides, and path.deleteAnchors' read of it
- text.create and text.createInPath: vertical (already refused by
  check_switches, so their behavior is unchanged)
2026-11-06 13:47:16 +07:00

1970 lines
101 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.
//! Rich text editing commands: range edits and range styling (what the Type tool and the
//! Character panel use while editing), creating area / on-path type from a path, and Fit Headline.
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, NodeKind, TextKind, TextObject, TextRun};
use vectorcraft_geom::{Affine, Point, Vec2};
use vectorcraft_text::edit;
use super::menucmds::whole_or;
use super::stroke::StrokeChange;
use super::typecmd::refresh_bounds;
use super::{
CommandSpec, EngineError, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, has_selection, id_param,
index, number_or, point_param, str_param, switch_or, targets,
};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"text.editRange",
"Edit Text",
[],
None,
"{id, start: byte, end: byte, insert?: string, runs?: [{text, style}], clear?: [id…] (other type objects whose whole text goes in the same edit, as when typing replaces a threaded story that Select All selected)} replace bytes start..end of the plain text (inserted text takes the replaced text's style unless styled `runs` are given) → {id, caret}",
has_doc,
edit_range
),
cmd!(
"text.startList",
"Bullets and Numbering",
[],
None,
"{id, at?: byte (default: the end of the text)} start a list from typed characters, what the Type tool runs when a space is typed on its own: when the text from the start of the paragraph that `at` is in up to `at` is \"1. \" or \"1) \" (a numbered list in 1, 2, 3), \"A. \" or \"A) \" (a numbered list in A, B, C), or \"* \", \"# \", \"> \" or \"+ \" (a bulleted list with the filled dot), and the paragraph is in no list, those characters are removed and the paragraph joins that list as one undo step (left and first-line indents of 0 become 18 and -18, as text.setFormat sets them); otherwise nothing changes → {id, started: bool, caret: byte (the paragraph's start when started, else `at`)}",
has_doc,
start_list
),
cmd!(
"text.setRangeStyle",
"Character",
[],
None,
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
has_doc,
set_range_style
),
cmd!(
"text.touchType",
"Touch Type",
[],
None,
"{id, start: byte (a character of it), dx?: pt, dy?: pt, hScale?: %, vScale?: %, rotation?: deg} change one character (a ligature, one glyph for several characters, as a whole) as a Touch Type tool drag does, as one undo step: dx moves it along its line in reading order by changing the kerning of the character before it on the line and its own kerning, and the characters after it stay where they are (an error for the first logical character of a line or at a reading-direction boundary; supported in either reading direction; limited to what kerning from -1000 to 10000 can give); dy raises it (baseline shift, up from its line whatever its rotation, also for the first character of a line and a right-to-left character); dx and dy are document points; hScale (including the object transform), vScale and rotation set its scales and rotation as text.setRangeStyle does. The kerning of the two characters also keeps the character and the ones after it where they were along the line when the font kerned them with their neighbors; a wider or narrower character moves the ones after it by the change in its width → {id, start, end} (the character's bytes)",
has_doc,
touch_type
),
cmd!(
"text.setGlyphAlternate",
"Glyphs",
[],
None,
"{id, start: byte (a character of it), gid: glyph id|null} draw that one character with another glyph of its font as one undo step, what a double-click in the Glyphs panel's Show › Alternates for Current Selection does: gid is one of the glyphs text.glyphAlternates lists for it; its own default glyph, or null, puts the default glyph back. The text keeps the character, and its other attributes do not change. The choice is saved with the character's style and names the face it was made in (PostScript name and version): set in another font or version, or after the font goes missing, the character takes its default glyph; typed characters take their default glyphs. Choosing the glyph the character already draws is not an edit. An error when the character's font is not available, the glyph is not one of its alternates, or the character's style does not draw it (All Caps, Small Caps, another numeral system) → {id, start, end (the character's bytes), gid (the glyph drawn)}",
has_doc,
set_glyph_alternate
),
cmd!(
query "text.glyphAlternates",
"Glyphs",
[],
None,
"{id, start: byte (a character of it)} the alternate glyphs of that character in its font, what the Glyphs panel's Show › Alternates for Current Selection lists: from the font's OpenType GSUB table, the glyphs that single and alternate substitutions put in place of the character's default glyph, `aalt` first, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`, `jalt`, `nalt`, `ornm` and `titl`, each glyph once, the default glyph only when `aalt` lists it → {char, font: family, style: style, default: glyph id, chosen: glyph id (the glyph drawn now), alternates: [{gid, feature}]} (empty when the font has none for it; an error when the character's font is not available)",
has_doc,
glyph_alternates
),
cmd!(
query "text.getRange",
"Get Text Range",
[],
None,
"{id, start?, end?, effective?: bool = false, story?: bool = false (the whole text of the threaded story `id` is a frame of, its frames in thread order; no `start` or `end`)} → {text, runs: [{text, style}], length}; effective uses document-point size/leading/baseline shift and transformed horizontal scale; default returns stored local styles for rich-text copying",
has_doc,
get_range
),
cmd!(
"text.createInPath",
"Area / Path Type",
[],
None,
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color?, keepModel? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}",
has_doc,
create_in_path
),
cmd!(
"type.fitHeadline",
"Fit Headline",
["Type"],
None,
"{ids?|id?} track the first line of area type so it fills the frame width → {ids, tracking}",
has_selection,
fit_headline
),
cmd!(
"type.step",
"Step Type",
[],
None,
"{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; horizontal: ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift; vertical: ↓/↑ with or without Shift kerning or tracking, ←/→ leading, Shift+→/← baseline shift; mixed selected text objects use the horizontal mapping). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the character before it, leading its paragraph), else every selected type object → {ids}",
has_doc,
type_step
),
cmd!(
"type.size.increase",
"Increase Font Size",
[],
Some("Cmd+Shift+."),
"{by?: steps (1)} type.step {attribute: size}",
has_selection,
|s, p| type_step(s, &json!({"attribute": "size", "by": number_or(p, "by", 1.0, "type.size.increase")?}))
),
cmd!(
"type.size.decrease",
"Decrease Font Size",
[],
Some("Cmd+Shift+,"),
"{by?: steps (1)} type.step {attribute: size, by: -by}",
has_selection,
|s, p| type_step(s, &json!({"attribute": "size", "by": -number_or(p, "by", 1.0, "type.size.decrease")?}))
),
cmd!(
"type.size.increaseByFive",
"Increase Font Size by Five Steps",
[],
Some("Cmd+Alt+Shift+."),
"{} type.step {attribute: size, by: 5}: five times the Size/Leading increment (typeSizeIncrement)",
has_selection,
|s, _| type_step(s, &json!({"attribute": "size", "by": 5.0}))
),
cmd!(
"type.size.decreaseByFive",
"Decrease Font Size by Five Steps",
[],
Some("Cmd+Alt+Shift+,"),
"{} type.step {attribute: size, by: -5}: five times the Size/Leading increment (typeSizeIncrement)",
has_selection,
|s, _| type_step(s, &json!({"attribute": "size", "by": -5.0}))
),
cmd!(
"type.resetTracking",
"Reset Tracking and Kerning",
[],
Some("Cmd+Alt+Q"),
"{ids?: [id…]} set tracking to 0 and clear manual kerning without changing the automatic method in all the text of the type objects in `ids`, else in the text the Type tool has selected (at a caret: only the kerning of the character before it), else in every selected type object, as one undo step → {ids}",
has_selection,
reset_tracking
),
cmd!(
"type.resetHorizontalScale",
"Reset Horizontal Scale",
[],
Some("Cmd+Shift+X"),
"{ids?: [id…]} set the horizontal scale to 100% (as the Character panel shows it, the object's own transform included) in all the text of the type objects in `ids`, else in the text the Type tool has selected (nothing at a caret), else in every selected type object, as one undo step → {ids}",
has_selection,
reset_horizontal_scale
),
cmd!(
"text.discardEmpty",
"Discard Empty Type",
[],
None,
"{id} remove text object `id` if it has no characters (what the Type tool does with point type it placed when editing ends). When nothing but that text changed since the step that created it, the steps since are dropped instead, leaving no trace in the history → {removed}",
has_doc,
discard_empty
),
]
}
pub(crate) fn text_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Option<&mut TextObject> {
match d.node_mut(id).map(|n| &mut n.kind) {
Some(NodeKind::Text(t)) => Some(t),
_ => None,
}
}
pub(crate) fn text_ref(s: &Session, id: NodeId) -> Result<TextObject> {
match s.doc()?.doc.node(id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Ok((**t).clone()),
Some(_) => Err(EngineError::Other(format!("node {} is not text", id.0))),
None => Err(EngineError::NoNode(id)),
}
}
/// Replace a type object's whole content: the new text takes the replaced text's
/// style and paragraph styles follow, exactly as `text.editRange` over the full
/// range does. Dataset apply and friends share this so Variables edits behave
/// like Type tool edits.
pub(crate) fn set_plain_text(d: &mut vectorcraft_doc::Document, id: NodeId, text: &str) -> Result<()> {
let len = match d.node(id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => edit::runs_len(&t.runs),
Some(_) => return Err(EngineError::Other(format!("node {} is not text", id.0))),
None => return Err(EngineError::NoNode(id)),
};
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
t.splice_paras(0, len, text);
edit::replace_range(&mut t.runs, 0, len, text);
refresh_bounds(t);
Ok(())
}
/// Replace the whole story of the type object `id` as [`set_plain_text`] replaces one type
/// object's content: for a frame of threaded text, every frame's text. The new text goes into the
/// thread's first frame and the other frames are emptied; the edit's reflow then pours it through
/// them. → the story's last frame, which holds any text that does not fit.
pub(crate) fn set_story_text(d: &mut vectorcraft_doc::Document, id: NodeId, text: &str) -> Result<NodeId> {
let story = d.story_of(id);
let (Some((&first, rest)), Some(&last)) = (story.split_first(), story.last()) else {
return Err(EngineError::NoNode(id));
};
if rest.is_empty() {
set_plain_text(d, id, text)?;
return Ok(id);
}
let frames: Vec<TextObject> = story
.iter()
.filter_map(|f| match d.node(*f).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some((**t).clone()),
_ => None,
})
.collect();
let refs: Vec<&TextObject> = frames.iter().collect();
// The first frame takes the whole story first, so the replace keeps the story's first style
// and paragraph styles follow as for one type object.
let t = text_mut(d, first).ok_or(EngineError::NoNode(first))?;
t.runs = vectorcraft_text::thread::story(&refs);
t.set_paragraph_styles(vectorcraft_text::thread::story_paras(&refs, &[]));
set_plain_text(d, first, text)?;
// An emptied frame's paragraph takes the new text's last paragraph style, so it does not
// restyle that paragraph when the story is joined again.
let tail = match d.node(first).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => t.paragraph_styles().last().cloned().unwrap_or_default(),
_ => Default::default(),
};
for f in rest {
let t = text_mut(d, *f).ok_or(EngineError::NoNode(*f))?;
t.runs.clear();
t.set_paragraph_styles(vec![tail.clone()]);
refresh_bounds(t);
}
Ok(last)
}
/// The byte offset that `k` holds, read with [`index`].
fn byte_param(p: &Value, k: &str) -> Option<usize> {
p.get(k).and_then(Value::as_u64).map(index)
}
/// The byte offset that `p` gives for `k`, or `default` when `p` leaves it out or gives null. Any
/// other value that is not a whole number of at least 0, such as `"2"`, `-1` or `1.5`, is an error.
fn offset_or(p: &Value, k: &str, default: usize, cmd: &str) -> Result<usize> {
match p.get(k) {
None | Some(Value::Null) => Ok(default),
Some(_) => Ok(index(whole_or(p, k, 0, cmd)?)),
}
}
/// The bytes `start..end` of `p` in text of `len` bytes, clamped and ordered (without them, all of
/// it). A `start` or `end` that is not a whole number of at least 0 or null, such as `"2"`, `-1`
/// or `1.5`, is an error.
fn range_of(p: &Value, len: usize, cmd: &str) -> Result<(usize, usize)> {
let a = offset_or(p, "start", 0, cmd)?.min(len);
let b = offset_or(p, "end", len, cmd)?.min(len);
Ok((a.min(b), a.max(b)))
}
fn edit_range(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.editRange";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let t = text_ref(s, id)?;
let len = edit::runs_len(&t.runs);
let (a, b) = range_of(p, len, C)?;
let styled: Option<Vec<TextRun>> = match p.get("runs") {
None | Some(Value::Null) => None,
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(C, format!("bad `runs`: {e}")))?),
};
let insert = str_param(p, "insert").unwrap_or("").to_string();
let clear = clear_param(s, p, id, C)?;
let caret = s.edit("Typing", |d, _| {
for other in &clear {
let t = text_mut(d, *other).ok_or(EngineError::NoNode(*other))?;
let len = edit::runs_len(&t.runs);
t.splice_paras(0, len, "");
edit::replace_range(&mut t.runs, 0, len, "");
t.ruby_after_replace(0, len, 0);
refresh_bounds(t);
}
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
// Paragraph styles follow the edit: a split paragraph's new one continues its style, a
// merge keeps the first paragraph's.
let inserted: String = match &styled {
Some(r) => r.iter().map(|r| r.text.as_str()).collect(),
None => insert.clone(),
};
t.splice_paras(a, b, &inserted);
let caret = match &styled {
Some(r) => edit::replace_range_styled(&mut t.runs, a, b, r),
None => edit::replace_range(&mut t.runs, a, b, &insert),
};
// Ruby over the edited text moves with it (its ranges are plain-text bytes).
t.ruby_after_replace(a, b, inserted.len());
refresh_bounds(t);
Ok(caret)
})?;
Ok(json!({"id": id.0, "caret": caret}))
}
/// The `clear` param of `text.editRange`: other type objects whose text the edit removes, each
/// once. An error when it is not a list of ids of type objects other than `id`.
fn clear_param(s: &Session, p: &Value, id: NodeId, c: &str) -> Result<Vec<NodeId>> {
let ids = match p.get("clear") {
None | Some(Value::Null) => return Ok(vec![]),
Some(Value::Array(ids)) => ids,
Some(v) => return Err(bad(c, format!("`clear` must be a list of type object ids, not {v}"))),
};
let mut out = vec![];
for v in ids {
let other = v.as_u64().map(NodeId).ok_or_else(|| bad(c, format!("`clear` must be a list of type object ids, not {v}")))?;
if other == id {
return Err(bad(c, "`clear` must not list `id`"));
}
if !matches!(s.doc()?.doc.node(other).map(|n| &n.kind), Some(NodeKind::Text(_))) {
return Err(bad(c, format!("`clear` must list type objects; {} is not one", other.0)));
}
if !out.contains(&other) {
out.push(other);
}
}
Ok(out)
}
fn start_list(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.startList";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let t = text_ref(s, id)?;
let len = edit::runs_len(&t.runs);
let at = offset_or(p, "at", len, C)?.min(len);
let Some((start, list)) = t.typed_list_at(at) else {
return Ok(json!({"id": id.0, "started": false, "caret": at}));
};
s.edit("Bullets and Numbering", |d, _| {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
t.splice_paras(start, at, "");
edit::replace_range(&mut t.runs, start, at, "");
t.ruby_after_replace(start, at, 0);
let i = t.paragraphs_in(start, start).start;
t.edit_paras(Some(i..i + 1), |para| {
para.list = Some(list);
// A paragraph with no left or first-line indent gets the hanging indent.
if para.left_indent == 0.0 && para.first_line_indent == 0.0 {
para.left_indent = vectorcraft_doc::text::LIST_HANGING_INDENT;
para.first_line_indent = -vectorcraft_doc::text::LIST_HANGING_INDENT;
}
});
refresh_bounds(t);
Ok(())
})?;
Ok(json!({"id": id.0, "started": true, "caret": start}))
}
fn get_range(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("text.getRange", "missing `id`"))?;
if switch_or(p, "story", false, "text.getRange")? {
return get_story(s, p, id);
}
let t = text_ref(s, id)?;
let len = edit::runs_len(&t.runs);
let (a, b) = range_of(p, len, "text.getRange")?;
let mut runs = edit::slice_runs(&t.runs, a, b);
if switch_or(p, "effective", false, "text.getRange")? {
for run in &mut runs {
run.style = t.effective_char_style(&run.style).ok_or_else(|| bad("text.getRange", "text transform is collapsed or unrepresentable"))?;
}
}
let text: String = runs.iter().map(|r| r.text.as_str()).collect();
Ok(json!({"text": text, "runs": runs, "length": len}))
}
/// `text.getRange {id, story: true}`: the whole text of the story `id` is a frame of, its thread's
/// frames in order (`id` alone when it is in no thread).
fn get_story(s: &mut Session, p: &Value, id: NodeId) -> Result<Value> {
const C: &str = "text.getRange";
if ["start", "end"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
return Err(bad(C, "`story` takes the whole story: give no `start` or `end`"));
}
let effective = switch_or(p, "effective", false, C)?;
let story = super::threads::story_of(&s.doc()?.doc, id);
let frames = if story.is_empty() { vec![id] } else { story };
let mut runs = vec![];
for f in frames {
let t = text_ref(s, f)?;
for mut run in edit::slice_runs(&t.runs, 0, edit::runs_len(&t.runs)) {
if effective {
run.style = t.effective_char_style(&run.style).ok_or_else(|| bad(C, "text transform is collapsed or unrepresentable"))?;
}
runs.push(run);
}
}
let text: String = runs.iter().map(|r| r.text.as_str()).collect();
let len = text.len();
Ok(json!({"text": text, "runs": runs, "length": len}))
}
/// Keep `st`'s font style when its family has it, else take the family's closest one: a family
/// whose styles are numbered (W3, W6) has no Regular (#4128). A family that isn't installed keeps
/// the style as it is.
pub(crate) fn fit_font_style(st: &mut CharStyle) {
let db = vectorcraft_text::FontDb::global();
let styles = db.styles(&st.font_family);
if !styles.is_empty()
&& !styles.iter().any(|s| s.eq_ignore_ascii_case(&st.font_style))
&& let Some(face) = db.face(&st.font_family, &st.font_style)
{
st.font_style = face.style.clone();
}
}
/// Character attribute changes parsed from params (shared by range and whole-object styling).
#[derive(Default)]
pub(crate) struct CharChange {
font: Option<String>,
style: Option<String>,
size: Option<f64>,
leading: Option<Option<f64>>,
tracking: Option<f64>,
kerning: Option<Option<f64>>,
optical_kerning: Option<bool>,
baseline_shift: Option<f64>,
h_scale: Option<f64>,
v_scale: Option<f64>,
rotation: Option<f64>,
fill: Option<Paint>,
stroke: Option<Paint>,
/// Weight (`strokeWidth`), cap, join, miter limit and dashes of the character stroke.
stroke_opts: StrokeChange,
/// Document points per text-space point for a stroke edit.
stroke_scale: Option<f64>,
underline: Option<bool>,
strikethrough: Option<bool>,
all_caps: Option<bool>,
features: Option<Vec<String>>,
/// Features turned on or off on top of each character's own (`featureChanges`).
feature_changes: Option<Vec<String>>,
position: Option<vectorcraft_doc::CharPosition>,
small_caps: Option<Option<f64>>,
char_align: Option<vectorcraft_doc::CharAlign>,
char_direction: Option<Option<vectorcraft_doc::ParaDirection>>,
kashidas: Option<bool>,
proportional_metrics: Option<bool>,
tate_chu_yoko: Option<bool>,
/// Tate-chu-yoko Settings › Up/Down and Left/Right, in points.
tcy_up_down: Option<f64>,
tcy_left_right: Option<f64>,
standard_vertical_roman: Option<bool>,
no_break: Option<bool>,
digits: Option<vectorcraft_doc::Digits>,
/// The language to set: a tag, or `None` for the default.
language: Option<Option<String>>,
font_axes: Option<AxisChange>,
font_height_reference: Option<vectorcraft_doc::FontHeightReference>,
}
/// `charDirection: "default"|"leftToRight"|"rightToLeft"` → the Character Direction to set (`None`
/// inside: Default).
pub(crate) fn char_direction_param(p: &Value, cmd: &str) -> Result<Option<Option<vectorcraft_doc::ParaDirection>>> {
use vectorcraft_doc::ParaDirection;
let Some(v) = p.get("charDirection").filter(|v| !v.is_null()) else { return Ok(None) };
Ok(Some(match v.as_str() {
Some("default") => None,
Some("leftToRight") => Some(ParaDirection::LeftToRight),
Some("rightToLeft") => Some(ParaDirection::RightToLeft),
_ => return Err(bad(cmd, "`charDirection` must be \"default\", \"leftToRight\" or \"rightToLeft\"")),
}))
}
/// `tcyUpDown` or `tcyLeftRight` (Tate-chu-yoko Settings): a number of points from -1296 to 1296.
/// Any other value is an error.
pub(crate) fn tcy_offset_param(p: &Value, key: &str, cmd: &str) -> Result<Option<f64>> {
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) };
let v = v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a number")))?;
let (lo, hi) = (-1296.0, 1296.0);
if (lo..=hi).contains(&v) { Ok(Some(v)) } else { Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))) }
}
/// `digits: "default"|"arabic"|"hindi"|"farsi"|"devanagari"|…` ([`Digits::id`]) → the numeral
/// system the digits are drawn in.
///
/// [`Digits::id`]: vectorcraft_doc::Digits::id
pub(crate) fn digits_param(p: &Value, cmd: &str) -> Result<Option<vectorcraft_doc::Digits>> {
use vectorcraft_doc::Digits;
let Some(v) = p.get("digits").filter(|v| !v.is_null()) else { return Ok(None) };
let Some(d) = v.as_str().and_then(Digits::from_id) else {
let ids: Vec<String> = Digits::ALL.iter().map(|d| format!("\"{}\"", d.id())).collect();
return Err(bad(cmd, format!("`digits` must be one of {}", ids.join(", "))));
};
Ok(Some(d))
}
/// `language: name|tag` (one of Document Setup's languages, case-insensitive) → the language to
/// set ([`vectorcraft_doc::CharStyle::language`]: `None` inside for the default).
pub(crate) fn language_param(p: &Value, cmd: &str) -> Result<Option<Option<String>>> {
let Some(v) = p.get("language").filter(|v| !v.is_null()) else { return Ok(None) };
let l = v.as_str().ok_or_else(|| bad(cmd, "`language` must be a language name or tag, such as \"Turkish\" or \"tr\""))?;
let tag = vectorcraft_doc::setup::char_language(l).map_err(|e| bad(cmd, e))?;
Ok(Some(tag.map(str::to_string)))
}
/// Variable Font settings to change: by axis tag, a value in the axis's units, or `None` to go
/// back to the style's own setting of that axis.
pub(crate) type AxisChange = std::collections::BTreeMap<String, Option<f64>>;
/// `fontAxes: {"wght": 650, "wdth": null, …}` → the Variable Font settings to change. A tag is four
/// printable ASCII characters (`wght`); a value is a number, or null.
pub(crate) fn font_axes_param(p: &Value, cmd: &str) -> Result<Option<AxisChange>> {
let Some(v) = p.get("fontAxes").filter(|v| !v.is_null()) else { return Ok(None) };
let obj = v.as_object().ok_or_else(|| bad(cmd, "`fontAxes` must be an object of axis tags and values, such as {\"wght\": 650}"))?;
let mut out = AxisChange::new();
for (tag, value) in obj {
if tag.len() != 4 || !tag.bytes().all(|b| (0x20..=0x7e).contains(&b)) {
return Err(bad(cmd, format!("`{tag}` is not an axis tag: a tag is four characters, such as \"wght\"")));
}
let value = match value {
Value::Null => None,
v => Some(v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("the value of axis `{tag}` must be a number or null")))?),
};
out.insert(tag.clone(), value);
}
Ok(Some(out))
}
/// Change the Variable Font settings of `st` by `change`. With its font available, a value is
/// clamped to its axis's range, a tag its font has no axis for is left out, and a value equal to
/// the style's own setting is removed; with the font missing, the values are kept as given.
pub(crate) fn change_font_axes(st: &mut CharStyle, change: &AxisChange) {
let db = vectorcraft_text::FontDb::global();
let face = match db.resolve(&st.font_family, &st.font_style) {
Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => {
Some(db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()).unwrap_or(f))
}
_ => None,
};
let axes = face.as_ref().map(|f| f.axes()).unwrap_or_default();
for (tag, value) in change {
let Some(v) = value else {
st.font_axes.remove(tag);
continue;
};
let Some(face) = &face else {
st.font_axes.insert(tag.clone(), *v);
continue;
};
let Some(axis) = axes.iter().find(|a| a.tag.as_slice() == tag.as_bytes()) else { continue };
let v = v.clamp(f64::from(axis.min.min(axis.max)), f64::from(axis.max.max(axis.min)));
if face.axis_value(axis.tag).is_some_and(|own| f64::from(own) == v) {
st.font_axes.remove(tag);
} else {
st.font_axes.insert(tag.clone(), v);
}
}
}
/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults).
pub(crate) fn features_param(p: &Value, cmd: &str) -> Result<Option<Vec<String>>> {
let Some(v) = p.get("features").filter(|v| !v.is_null()) else { return Ok(None) };
let tags: Vec<&str> = v.as_array().ok_or_else(|| bad(cmd, "features must be a list of tags"))?.iter().filter_map(Value::as_str).collect();
if let Some(t) = tags.iter().find(|t| !vectorcraft_text::OtFeatures::known_tag(t)) {
return Err(bad(
cmd,
format!(
"unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom; prefix - to turn off)"
),
));
}
Ok(Some(vectorcraft_text::OtFeatures::default().with_tags(tags).to_tags()))
}
/// The most entries a `featureChanges` list may have.
const MAX_FEATURE_CHANGES: usize = 256;
/// A feature tag without its `-` or `+` prefix.
fn feature_name(tag: &str) -> &str {
tag.strip_prefix(['-', '+']).unwrap_or(tag)
}
/// `featureChanges: ["tnum", "-onum", …]` → the features to turn on (or, with `-`, off) on top of
/// each character's own, one change for each feature (the last one listed).
pub(crate) fn feature_changes_param(p: &Value, cmd: &str) -> Result<Option<Vec<String>>> {
let Some(v) = p.get("featureChanges").filter(|v| !v.is_null()) else { return Ok(None) };
let not_tags = || bad(cmd, "featureChanges must be a list of tags");
let list = v.as_array().ok_or_else(not_tags)?;
if list.len() > MAX_FEATURE_CHANGES {
return Err(bad(cmd, format!("featureChanges lists at most {MAX_FEATURE_CHANGES} tags")));
}
let mut tags: Vec<String> = Vec::new();
for t in list {
let t = t.as_str().ok_or_else(not_tags)?;
if !vectorcraft_text::OtFeatures::known_tag(t) || feature_name(t).starts_with(['-', '+']) {
return Err(bad(cmd, format!("unknown OpenType feature `{t}` in featureChanges (the tags `features` takes)")));
}
tags.retain(|o| feature_name(o) != feature_name(t));
tags.push(t.to_string());
}
Ok(Some(tags))
}
/// `features` with `changes` (a `featureChanges` list) made: the tags of the features `changes`
/// names are replaced by the ones that differ from the defaults, as `features` writes them, and
/// every other tag stays as it was. Standard ligatures turned on are written as `liga`, which keeps
/// them on in tracked type. The new tags go after the others. When the list would run past the
/// [`vectorcraft_text::MAX_STYLE_FEATURES`] entries that shaping reads, the other tags are first cut
/// to the ones shaping uses: the entries it reads, without tags it does not know and with only the
/// last tag of each feature.
pub(crate) fn change_features(features: &[String], changes: &[String]) -> Vec<String> {
let named = |t: &String| changes.iter().any(|c| feature_name(c) == feature_name(t));
let mut made = vectorcraft_text::OtFeatures::from_tags(changes.iter().map(String::as_str)).to_tags();
if changes.iter().any(|c| c == "liga" || c == "+liga") {
made.push("liga".into());
}
let mut kept: Vec<String> = features.iter().filter(|t| !named(t)).cloned().collect();
if kept.len() + made.len() > vectorcraft_text::MAX_STYLE_FEATURES {
let read = features.get(..vectorcraft_text::MAX_STYLE_FEATURES).unwrap_or(features);
let shaped = |t: &String| vectorcraft_text::OtFeatures::known_tag(t) || feature_name(t) == "clig";
kept = read
.iter()
.enumerate()
.filter(|(i, t)| !named(t) && shaped(t) && !read.iter().skip(i + 1).any(|l| feature_name(l) == feature_name(t)))
.map(|(_, t)| t.clone())
.collect();
}
kept.extend(made);
kept
}
/// `charAlign: "romanBaseline"|"emBoxTop"|"emBoxCenter"|"emBoxBottom"|"icfTop"|"icfBottom"` (Character Alignment).
pub(crate) fn char_align_param(p: &Value, cmd: &str) -> Result<Option<vectorcraft_doc::CharAlign>> {
use vectorcraft_doc::CharAlign;
let Some(v) = p.get("charAlign").filter(|v| !v.is_null()) else { return Ok(None) };
Ok(Some(match v.as_str() {
Some("romanBaseline") => CharAlign::RomanBaseline,
Some("emBoxTop") => CharAlign::EmBoxTop,
Some("emBoxCenter") => CharAlign::EmBoxCenter,
Some("emBoxBottom") => CharAlign::EmBoxBottom,
Some("icfTop") => CharAlign::IcfTop,
Some("icfBottom") => CharAlign::IcfBottom,
_ => {
return Err(bad(
cmd,
"`charAlign` must be \"romanBaseline\", \"emBoxTop\", \"emBoxCenter\", \"emBoxBottom\", \"icfTop\" or \"icfBottom\"",
));
}
}))
}
/// `fontHeightReference: "emBox"|"capHeight"|"xHeight"|"icfBox"` (Character panel › Set Font Height
/// Reference): the height of the font that the font size measures.
pub(crate) fn font_height_reference_param(p: &Value, cmd: &str) -> Result<Option<vectorcraft_doc::FontHeightReference>> {
use vectorcraft_doc::FontHeightReference;
let Some(v) = p.get("fontHeightReference").filter(|v| !v.is_null()) else { return Ok(None) };
Ok(Some(match v.as_str() {
Some("emBox") => FontHeightReference::EmBox,
Some("capHeight") => FontHeightReference::CapHeight,
Some("xHeight") => FontHeightReference::XHeight,
Some("icfBox") => FontHeightReference::IcfBox,
_ => return Err(bad(cmd, "`fontHeightReference` must be \"emBox\", \"capHeight\", \"xHeight\" or \"icfBox\"")),
}))
}
fn paint_param(p: &Value, k: &str, cmd: &str) -> Result<Option<Paint>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(n)) if n.eq_ignore_ascii_case("none") => Ok(Some(Paint::None)),
Some(v) => Ok(Some(Paint::solid(color_value(v).ok_or_else(|| bad(cmd, format!("bad {k} colour")))?))),
}
}
fn auto_or_num(p: &Value, k: &str, cmd: &str) -> Result<Option<Option<f64>>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Ok(Some(None)),
Some(v) => Ok(Some(Some(v.as_f64().ok_or_else(|| bad(cmd, format!("{k} must be a number or \"auto\"")))?))),
}
}
impl CharChange {
/// `setup` gives the superscript, subscript and small caps proportions.
pub(crate) fn parse(p: &Value, cmd: &str, setup: &vectorcraft_doc::DocSetup) -> Result<Self> {
check_numbers(p, &["size", "tracking", "baselineShift", "hScale", "vScale", "rotation", "strokeWidth"], cmd)?;
check_switches(
p,
&["underline", "strikethrough", "allCaps", "proportionalMetrics", "tateChuYoko", "standardVerticalRoman", "noBreak", "kashidas"],
cmd,
)?;
let num = |k: &str| p.get(k).and_then(Value::as_f64);
let (position, small_caps) = super::docsetup::script_params(p, setup, cmd)?;
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
let mut stroke_opts = match p.get("strokeOptions") {
None | Some(Value::Null) => StrokeChange::default(),
Some(o) if o.is_object() => StrokeChange::parse(o, cmd)?,
Some(_) => return Err(bad(cmd, "strokeOptions must be an object of stroke.set options")),
};
if let Some(w) = num("strokeWidth") {
stroke_opts.weight = Some(w.clamp(0.0, 1000.0));
}
let c = Self {
font: str_param(p, "font").map(str::to_string),
style: str_param(p, "style").map(str::to_string),
size: num("size"),
leading: auto_or_num(p, "leading", cmd)?,
tracking: num("tracking"),
kerning: match p.get("kerning") {
None | Some(Value::Null) => None,
// Auto and Optical both clear manual pair values.
Some(Value::String(v)) if v.eq_ignore_ascii_case("optical") || v.eq_ignore_ascii_case("auto") => Some(None),
Some(v) => Some(Some(v.as_f64().ok_or_else(|| bad(cmd, "kerning must be a number, \"auto\", or \"optical\""))?)),
},
optical_kerning: match p.get("kerning") {
Some(Value::String(v)) if v.eq_ignore_ascii_case("optical") => Some(true),
Some(Value::String(v)) if v.eq_ignore_ascii_case("auto") => Some(false),
_ => None,
},
baseline_shift: num("baselineShift"),
h_scale: num("hScale"),
v_scale: num("vScale"),
rotation: num("rotation"),
fill: paint_param(p, "fill", cmd)?,
stroke: paint_param(p, "stroke", cmd)?,
stroke_opts,
stroke_scale: None,
underline: flag("underline"),
strikethrough: flag("strikethrough"),
all_caps: flag("allCaps"),
features: features_param(p, cmd)?,
feature_changes: feature_changes_param(p, cmd)?,
position,
small_caps,
char_align: char_align_param(p, cmd)?,
char_direction: char_direction_param(p, cmd)?,
kashidas: flag("kashidas"),
proportional_metrics: flag("proportionalMetrics"),
tate_chu_yoko: flag("tateChuYoko"),
tcy_up_down: tcy_offset_param(p, "tcyUpDown", cmd)?,
tcy_left_right: tcy_offset_param(p, "tcyLeftRight", cmd)?,
standard_vertical_roman: flag("standardVerticalRoman"),
no_break: flag("noBreak"),
digits: digits_param(p, cmd)?,
language: language_param(p, cmd)?,
font_axes: font_axes_param(p, cmd)?,
font_height_reference: font_height_reference_param(p, cmd)?,
};
if c.size.is_some_and(|v| v <= 0.0) {
return Err(bad(cmd, "size must be positive"));
}
Ok(c)
}
/// The `fill` and `stroke` colors as the document stores them: an RGB color in a CMYK
/// document becomes CMYK through the color settings ([`super::paint::new_color`]).
fn store_colors(&mut self, s: &Session, p: &Value) {
for paint in [&mut self.fill, &mut self.stroke].into_iter().flatten() {
if let Paint::Solid { color, .. } = paint {
*color = super::paint::new_color(s, p, *color);
}
}
}
pub(crate) fn is_empty(&self) -> bool {
self.font.is_none()
&& self.style.is_none()
&& self.size.is_none()
&& self.leading.is_none()
&& self.tracking.is_none()
&& self.kerning.is_none()
&& self.optical_kerning.is_none()
&& self.baseline_shift.is_none()
&& self.h_scale.is_none()
&& self.v_scale.is_none()
&& self.rotation.is_none()
&& self.fill.is_none()
&& self.stroke.is_none()
&& self.stroke_opts.is_empty()
&& self.underline.is_none()
&& self.strikethrough.is_none()
&& self.all_caps.is_none()
&& self.features.is_none()
&& self.feature_changes.is_none()
&& self.position.is_none()
&& self.small_caps.is_none()
&& self.char_align.is_none()
&& self.char_direction.is_none()
&& self.kashidas.is_none()
&& self.proportional_metrics.is_none()
&& self.tate_chu_yoko.is_none()
&& self.tcy_up_down.is_none()
&& self.tcy_left_right.is_none()
&& self.standard_vertical_roman.is_none()
&& self.no_break.is_none()
&& self.digits.is_none()
&& self.language.is_none()
&& self.font_axes.is_none()
&& self.font_height_reference.is_none()
}
pub(crate) fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> {
use super::typecmd::local_type_value;
self.size = self.size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, cmd)).transpose()?;
self.leading = self.leading.map(|v| v.map(|v| local_type_value(t, v, (0.1, 5000.0), false, cmd)).transpose()).transpose()?;
self.baseline_shift = self.baseline_shift.map(|v| local_type_value(t, v, (-1296.0, 1296.0), false, cmd)).transpose()?;
self.tcy_up_down = self.tcy_up_down.map(|v| local_type_value(t, v, (-1296.0, 1296.0), false, cmd)).transpose()?;
self.tcy_left_right = self.tcy_left_right.map(|v| local_type_value(t, v, (-1296.0, 1296.0), false, cmd)).transpose()?;
self.h_scale = self.h_scale.map(|v| local_type_value(t, v, (1.0, 10000.0), true, cmd)).transpose()?;
self.stroke_scale = Some(vectorcraft_doc::Scaling::factor(t.xf).filter(|k| k.is_finite() && *k > 1e-12).unwrap_or(1.0));
Ok(())
}
/// The font family, style and Variable Font settings of [`Self::apply`].
fn apply_font(&self, st: &mut CharStyle) {
if let Some(f) = &self.font {
st.font_family = f.clone();
// Keep the style when the new family has it, else its closest match.
if self.style.is_none() {
fit_font_style(st);
}
}
if let Some(v) = &self.style {
st.font_style = v.clone();
}
// Another font: none of the old one's versions or Variable Font settings.
if self.font.is_some() || self.style.is_some() {
st.font_version = None;
st.font_axes.clear();
}
if let Some(v) = &self.font_axes {
change_font_axes(st, v);
}
}
/// The character alignment, direction and East Asian settings of [`Self::apply`].
fn apply_cjk(&self, st: &mut CharStyle) {
if let Some(v) = self.char_align {
st.char_align = v;
}
if let Some(v) = self.char_direction {
st.char_direction = v;
}
if let Some(v) = self.kashidas {
st.kashidas = v;
}
if let Some(v) = self.proportional_metrics {
st.proportional_metrics = v;
}
if let Some(v) = self.tate_chu_yoko {
st.tate_chu_yoko = v;
}
if let Some(v) = self.tcy_up_down {
st.tcy_up_down = v;
}
if let Some(v) = self.tcy_left_right {
st.tcy_left_right = v;
}
if let Some(v) = self.standard_vertical_roman {
st.standard_vertical_roman = v;
}
}
pub(crate) fn apply(&self, st: &mut CharStyle) {
self.apply_font(st);
if let Some(v) = self.size {
st.size = v;
}
if let Some(v) = self.leading {
st.leading = v;
}
if let Some(v) = self.tracking {
st.tracking = v.clamp(-1000.0, 10000.0);
}
if let Some(v) = self.kerning {
st.kerning = v.map(|k| k.clamp(-1000.0, 10000.0));
}
if let Some(v) = self.optical_kerning {
st.optical_kerning = v;
}
if let Some(v) = self.baseline_shift {
st.baseline_shift = v;
}
if let Some(v) = self.h_scale {
st.h_scale = v;
}
if let Some(v) = self.v_scale {
st.v_scale = v.clamp(1.0, 10000.0);
}
if let Some(v) = self.rotation {
st.rotation = (v + 180.0).rem_euclid(360.0) - 180.0;
}
if let Some(v) = &self.fill {
st.fill = v.clone();
}
if let Some(v) = &self.stroke {
if !v.is_none() {
super::gradient::run_stroke_weight(st);
}
st.stroke = v.clone();
}
if !self.stroke_opts.is_empty() {
self.stroke_opts.apply_char_scaled(st, self.stroke_scale.unwrap_or(1.0));
}
if let Some(v) = self.underline {
st.underline = v;
}
if let Some(v) = self.strikethrough {
st.strikethrough = v;
}
if let Some(v) = self.all_caps {
st.all_caps = v;
}
if let Some(v) = &self.features {
st.features = v.clone();
}
if let Some(v) = &self.feature_changes {
st.features = change_features(&st.features, v);
}
if let Some(v) = self.position {
st.position = v;
}
if let Some(v) = self.small_caps {
st.small_caps = v;
}
self.apply_cjk(st);
if let Some(v) = self.no_break {
st.no_break = v;
}
if let Some(v) = self.digits {
st.digits = v;
}
if let Some(v) = self.font_height_reference {
st.font_height_reference = v;
}
if let Some(v) = &self.language {
st.language.clone_from(v);
}
}
}
/// Type › Enable Missing Glyph Protection: after a font change, the characters of `runs` their new
/// font has no glyph for, but their font `before` the change had, keep that font: its family,
/// style, version and Variable Font settings. Returns the byte ranges that kept their font, in order.
pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>) -> Vec<(usize, usize)> {
fn chars(runs: &[TextRun]) -> impl Iterator<Item = (usize, char, &CharStyle)> {
runs.iter()
.scan(0, |at, r| {
let start = *at;
*at += r.text.len();
Some(r.text.char_indices().map(move |(i, c)| (start + i, c, &r.style)))
})
.flatten()
}
let db = vectorcraft_text::FontDb::global();
let mut faces = std::collections::HashMap::new();
let mut covers = |st: &CharStyle, c: char| {
let face = faces
.entry((st.font_family.clone(), st.font_style.clone(), st.font_version.clone()))
.or_insert_with(|| db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()));
face.as_ref().map(|f| f.covers(c))
};
// Byte ranges that keep a font (family, style, version and Variable Font settings).
let font = |st: &CharStyle| (st.font_family.clone(), st.font_style.clone(), st.font_version.clone(), st.font_axes.clone());
let mut keep: Vec<(usize, usize, &CharStyle)> = vec![];
for ((at, c, old), (_, _, new)) in chars(before).zip(chars(runs)) {
let changed = (&old.font_family, &old.font_style) != (&new.font_family, &new.font_style);
if !changed || c.is_whitespace() || c.is_control() || covers(new, c) != Some(false) || covers(old, c) != Some(true) {
continue;
}
let end = at + c.len_utf8();
match keep.last_mut() {
Some((_, e, st)) if *e == at && font(st) == font(old) => *e = end,
_ => keep.push((at, end, old)),
}
}
let kept = keep.iter().map(|(a, b, _)| (*a, *b)).collect();
for (a, b, old) in keep {
edit::style_range(runs, a, b, |st| {
st.font_family.clone_from(&old.font_family);
st.font_style.clone_from(&old.font_style);
st.font_version.clone_from(&old.font_version);
st.font_axes.clone_from(&old.font_axes);
});
}
kept
}
/// The runs of `runs` from byte `a` to `b`, leaving out the ranges `kept` (in order) that Missing
/// Glyph Protection gave their earlier font: the characters a font command set its font on.
pub(crate) fn unprotected_runs(runs: &[TextRun], a: usize, b: usize, kept: &[(usize, usize)]) -> Vec<TextRun> {
let mut out = vec![];
let mut at = a;
for &(k0, k1) in kept {
if k1 <= at {
continue;
}
if k0 >= b {
break;
}
if k0 > at {
out.extend(edit::slice_runs(runs, at, k0));
}
at = k1;
}
if at < b {
out.extend(edit::slice_runs(runs, at, b));
}
out
}
fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.setRangeStyle";
// The type object given, else the one selected (#785).
let id = match p.get("id").filter(|v| !v.is_null()) {
Some(v) => v.as_u64().map(NodeId).ok_or_else(|| bad(C, format!("`id` must be an object id, not {v}")))?,
None => match super::typecmd::text_targets(s, &json!({}), C)?.as_slice() {
[one] => *one,
_ => return Err(bad(C, "missing `id`: give one, or select one type object")),
},
};
let story = switch_or(p, "story", false, C)?;
if story && ["start", "end"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
return Err(bad(C, "`story` styles the whole story: give no `start` or `end`"));
}
let frames = match super::threads::story_of(&s.doc()?.doc, id) {
all if story && !all.is_empty() => all,
_ => vec![id],
};
// Each frame's change (sizes are in its own text space) and range.
let mut parts = Vec::with_capacity(frames.len());
for f in frames {
let mut change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
super::paint::check_keep_model(p, C)?;
change.store_colors(s, p);
if change.is_empty() {
return Err(bad(C, "nothing to change"));
}
let t = text_ref(s, f)?;
change.localize(&t, C)?;
let (a, b) = if story { (0, edit::runs_len(&t.runs)) } else { range_of(p, edit::runs_len(&t.runs), C)? };
parts.push((f, change, a, b));
}
let sets_font = parts.first().is_some_and(|(_, change, _, _)| change.font.is_some() || change.style.is_some());
let protect = s.prefs.missing_glyph_protection && sets_font;
// The fonts the styled characters are set in, for the warning.
let mut set = std::collections::BTreeSet::new();
let n = s.edit("Character", |d, _| {
let mut n = 0;
for (f, change, a, b) in &parts {
let (a, b) = (*a, *b);
let t = text_mut(d, *f).ok_or(EngineError::NoNode(*f))?;
if a == b && !t.plain_text().is_empty() {
// An empty range inside text styles nothing (Illustrator keeps it for the next typing).
n = if *f == id { t.runs.len() } else { n };
continue;
}
let before = protect.then(|| t.runs.clone());
edit::style_range(&mut t.runs, a, b, |st| change.apply(st));
let kept = before.map(|before| protect_missing_glyphs(&before, &mut t.runs)).unwrap_or_default();
if sets_font {
// Empty text has one run, which the empty range styles. Characters that kept their font aren't the command's.
let styled = if a == b { t.runs.clone() } else { unprotected_runs(&t.runs, a, b, &kept) };
set.extend(styled.iter().map(|r| super::fonts::font_of(&r.style)));
}
refresh_bounds(t);
n = if *f == id { t.runs.len() } else { n };
}
Ok(n)
})?;
let mut out = json!({"id": id.0, "runs": n});
if let Some(w) = super::fonts::set_font_warning(set) {
out["warning"] = json!(w);
}
if let Some(kerning) = p.get("kerning") {
out["kerning"] = kerning.clone();
}
Ok(out)
}
/// The em a laid-out glyph's kerning and tracking are measured in, in text space: the size it
/// was shaped at (superscript, small caps and Shrink Text to Fit included).
fn glyph_em(db: &vectorcraft_text::FontDb, t: &TextObject, g: &vectorcraft_text::PositionedGlyph) -> Option<f64> {
let face = db.face_by_id(g.font_id)?;
let h_scale = edit::slice_runs(&t.runs, g.byte, g.byte + g.len).first()?.style.h_scale / 100.0;
let [a, b, ..] = g.xf.as_coeffs();
Some(a.hypot(b) * face.units_per_em() / h_scale).filter(|em| em.is_finite() && *em > 0.0)
}
/// The current pair correction in thousandths of an em, including an automatic correction.
pub fn effective_kerning(db: &vectorcraft_text::FontDb, t: &TextObject, lay: &vectorcraft_text::TextLayout, x: usize, y: usize) -> f64 {
if let Some(value) = edit::slice_runs(&t.runs, x, y).first().and_then(|r| r.style.kerning) {
return value;
}
// Measure both layouts without wrapping so the same pair stays on one line.
let mut trial = t.clone();
trial.kind = TextKind::Point;
let unwrapped = (!matches!(t.kind, TextKind::Point)).then(|| vectorcraft_text::layout(db, &trial));
let original = unwrapped.as_ref().unwrap_or(lay);
edit::style_range(&mut trial.runs, x, y, |st| st.kerning = Some(0.0));
let base = vectorcraft_text::layout(db, &trial);
let owner = original.glyphs.iter().find(|g| g.byte == x);
let rtl = owner.is_some_and(|g| g.rtl);
let drawn_gap = |layout: &vectorcraft_text::TextLayout| {
let first = layout.glyphs.iter().find(|g| g.byte == if rtl { y } else { x })?;
let following = layout.glyphs.iter().find(|g| g.byte == if rtl { x } else { y })?;
if first.line != following.line {
return None;
}
let axis = vectorcraft_geom::Vec2::new(first.angle.cos(), first.angle.sin());
let along = if rtl { -axis } else { axis };
let drawn = |g: &vectorcraft_text::PositionedGlyph| {
let [_, _, _, _, tx, ty] = g.xf.as_coeffs();
Point::new(tx, ty)
};
Some((drawn(following) - drawn(first)).dot(along))
};
let em = owner.and_then(|g| glyph_em(db, t, g)).unwrap_or(0.0);
if em > 0.0 { drawn_gap(original).zip(drawn_gap(&base)).map_or(0.0, |(a, b)| (a - b) * 1000.0 / em) } else { 0.0 }
}
/// The character at byte `start` of `p` in text object `t`, its bytes and its font's face. An error
/// when there is no character there, it is an inline graphic, or its font is not available.
fn glyph_target(t: &TextObject, p: &Value, c: &str) -> Result<(char, std::ops::Range<usize>, std::sync::Arc<vectorcraft_text::FontFace>)> {
check_numbers(p, &["start"], c)?;
let start = byte_param(p, "start").ok_or_else(|| bad(c, "missing `start` (the byte offset of a character)"))?;
let mut at = 0;
for run in &t.runs {
let end = at + run.text.len();
if (at..end).contains(&start) {
let ch = run.text.get(start - at..).and_then(|s| s.chars().next()).ok_or_else(|| bad(c, format!("no character at byte {start}")))?;
if run.inline.is_some() {
return Err(bad(c, "an inline graphic has no glyphs"));
}
let db = vectorcraft_text::FontDb::global();
let face = db.style_face(&run.style).filter(|_| db.has_family(&run.style.font_family));
let face = face.ok_or_else(|| bad(c, format!("the font {} is not available", run.style.font_family)))?;
return Ok((ch, start..start + ch.len_utf8(), face));
}
at = end;
}
Err(bad(c, format!("no character at byte {start}")))
}
/// The glyph of `face` that `lay` draws for the character `ch` at `bytes` of `plain`, the laid-out
/// text; `None` when the character is not laid out (overflowing text). A character that starts a
/// longer grapheme (a letter and its combining marks) is drawn in the grapheme's cluster. Of a
/// cluster of several glyphs (a mark, a vowel sign drawn before the letter), the character's glyph
/// is the first in cluster order that is its default glyph or one of its alternates and not the
/// default glyph of another character of the grapheme; else the first that is its default glyph or
/// one of its alternates; else the first glyph that is not the default glyph or an alternate of
/// another character of the grapheme (a ligature of the letter and a mark). `Some(None)` when the
/// cluster holds none of these.
fn own_glyph(
plain: &str,
lay: &vectorcraft_text::TextLayout,
ch: char,
bytes: &std::ops::Range<usize>,
face: &vectorcraft_text::FontFace,
) -> Option<Option<u32>> {
lay.glyphs.iter().find(|g| g.byte <= bytes.start && bytes.start < g.byte.saturating_add(g.len))?;
let grapheme = edit::next_cluster(plain, bytes.start).max(bytes.end) - bytes.start;
let cluster: Vec<u32> = lay
.glyphs
.iter()
.filter(|g| g.byte == bytes.start && [bytes.len(), grapheme].contains(&g.len) && g.font_id == face.id())
.map(|g| g.gid)
.collect();
Some(match cluster[..] {
[one] => Some(one),
_ => {
// The glyphs of each character are looked up once: a grapheme can repeat a mark.
let glyphs_of = |c: char| face.alternates(c).iter().map(|a| a.gid).chain([face.glyph_for(c)]).collect::<Vec<u32>>();
// In cluster order: a conjunct's half form comes before the default glyph of its
// second consonant, which can be the same character. A glyph drawn for another
// character (a vowel sign drawn first) is skipped even when the font lists it as an
// alternate of this one.
let own = glyphs_of(ch);
let mut others: Vec<char> = plain.get(bytes.end..bytes.start + grapheme).unwrap_or_default().chars().filter(|&c| c != ch).collect();
others.sort_unstable();
others.dedup();
let their_defaults: Vec<u32> = others.iter().map(|&c| face.glyph_for(c)).collect();
cluster
.iter()
.copied()
.find(|g| own.contains(g) && !their_defaults.contains(g))
.or_else(|| cluster.iter().copied().find(|g| own.contains(g)))
.or_else(|| {
let theirs: Vec<u32> = others.iter().flat_map(|&c| glyphs_of(c)).collect();
cluster.iter().copied().find(|g| !theirs.contains(g))
})
}
})
}
/// The glyph of `face` drawn for the character `ch` at `bytes` of `t` when the character is shaped
/// alone in the writing direction of `t` and the direction its paragraph sets it in, with the rest
/// of its grapheme in the grapheme's own styles: the glyph of a character that is not laid out
/// (overflowing text). Also the glyph drawn for the character in whatever face draws it
/// ([`drawn_glyph`]).
fn shaped_alone(t: &TextObject, ch: char, bytes: &std::ops::Range<usize>, face: &vectorcraft_text::FontFace) -> (Option<u32>, Option<u32>) {
use vectorcraft_doc::ParaDirection;
let plain = t.plain_text();
let end = edit::next_cluster(&plain, bytes.start).max(bytes.end);
let mut single = TextObject::point(Point::ZERO, "", CharStyle::default());
single.runs = edit::slice_runs(&t.runs, bytes.start, end);
single.vertical = t.vertical;
// The bidi level of the character in its paragraph (a parenthesis is mirrored at an odd one).
let before = plain.get(..bytes.start).unwrap_or_default();
let (paragraph, start) = (before.matches('\n').count(), before.rfind('\n').map_or(0, |i| i + 1));
let stop = plain.get(bytes.start..).and_then(|after| after.find('\n')).map_or(plain.len(), |i| bytes.start + i);
let rtl = vectorcraft_text::char_is_rtl(plain.get(start..stop).unwrap_or_default(), t.para_at(paragraph).direction, bytes.start - start);
single.para.direction = Some(if rtl { ParaDirection::RightToLeft } else { ParaDirection::LeftToRight });
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &single);
let (alone, at) = (single.plain_text(), 0..bytes.len());
(own_glyph(&alone, &lay, ch, &at, face).flatten(), drawn_glyph(&alone, &lay, ch, &at, face))
}
/// The glyph `lay` draws for the character `ch` at `bytes` of `plain` in whatever face draws it:
/// the glyph of `face` ([`own_glyph`]); else, when a fallback face draws the character, that face's
/// glyph for it by the same rule (its vowel sign drawn first is not reported); else the first glyph
/// drawn for the character. `None` when the character is not laid out.
fn drawn_glyph(
plain: &str,
lay: &vectorcraft_text::TextLayout,
ch: char,
bytes: &std::ops::Range<usize>,
face: &vectorcraft_text::FontFace,
) -> Option<u32> {
if let Some(own) = own_glyph(plain, lay, ch, bytes, face).flatten() {
return Some(own);
}
let first = lay.glyphs.iter().find(|g| g.byte <= bytes.start && bytes.start < g.byte.saturating_add(g.len))?;
let fallback = (first.font_id != face.id()).then(|| vectorcraft_text::FontDb::global().face_by_id(first.font_id)).flatten();
fallback.and_then(|f| own_glyph(plain, lay, ch, bytes, &f).flatten()).or(Some(first.gid))
}
fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.setGlyphAlternate";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let gid = match p.get("gid") {
None => return Err(bad(C, "missing `gid` (a glyph id, or null for the default glyph)")),
Some(Value::Null) => None,
Some(v) => Some(v.as_u64().and_then(|g| u32::try_from(g).ok()).ok_or_else(|| bad(C, format!("`gid` must be a glyph id, not {v}")))?),
};
let t = text_ref(s, id)?;
let (ch, bytes, face) = glyph_target(&t, p, C)?;
let default = face.glyph_for(ch);
let holder = t
.runs
.iter()
.scan(0, |at, r| {
let start = *at;
*at += r.text.len();
Some((start..*at, r))
})
.find(|(bytes_of_run, _)| bytes_of_run.contains(&bytes.start));
let styled =
holder.as_ref().is_some_and(|(_, r)| r.style.all_caps || (r.style.small_caps.is_some() && ch.is_lowercase()) || r.style.digits.map(ch) != ch);
// The glyph the character is drawn with when it holds no choice, drawn alone in its face: a
// feature of its style (`salt`, a stylistic set) can draw another glyph than the default one.
// The default glyph is then kept as a choice, so that it is drawn. A character that is not laid
// out (overflowing text) is shaped alone in its writing direction, with the rest of its grapheme
// in the grapheme's own styles.
let plain = t.plain_text();
let unchosen = holder.as_ref().filter(|_| !styled).and_then(|_| {
let mut probe = t.clone();
edit::style_range(&mut probe.runs, bytes.start, bytes.end, |st| st.glyph_alternate = None);
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe);
own_glyph(&plain, &lay, ch, &bytes, &face).unwrap_or_else(|| shaped_alone(&probe, ch, &bytes, &face).0)
});
let substituted = unchosen.is_some_and(|g| g != default);
let choice = match gid.filter(|g| *g != default || substituted) {
None => None,
Some(g) if g == default || face.alternates(ch).iter().any(|a| a.gid == g) => Some(face.choice(ch, g)),
Some(g) => return Err(bad(C, format!("glyph {g} is not an alternate of {ch} in {} {}", face.family, face.style))),
};
let drawn = choice.as_ref().map_or(unchosen.unwrap_or(default), |c| c.gid);
// The run holding the character. The request changes nothing when the choice is the one the
// character's run holds and that run holds the character alone; otherwise the edit splits the
// run, so that the following text does not take the character into a ligature.
let held = holder.as_ref().filter(|(bytes_of_run, _)| bytes_of_run.start == bytes.start).and_then(|(_, r)| r.style.glyph_alternate.clone());
let alone = holder.as_ref().is_some_and(|(bytes_of_run, _)| *bytes_of_run == bytes);
// A choice that the character's style keeps from being drawn (All Caps, Small Caps, another
// numeral system) is an error, not an edit that leaves the page as it was. The style decides
// for a character outside its frame too; one that is laid out is checked in the layout.
if let Some(c) = &choice {
let shown = || {
let mut probe = t.clone();
edit::style_range(&mut probe.runs, bytes.start, bytes.end, |st| st.glyph_alternate = Some(c.clone()));
own_glyph(&plain, &vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe), ch, &bytes, &face)
};
if styled || shown().is_some_and(|g| g != Some(c.gid)) {
return Err(bad(
C,
format!("glyph {} is not drawn for {ch} with the character's style (such as All Caps, Small Caps or a numeral system)", c.gid),
));
}
}
if held == choice && (choice.is_none() || alone) {
return Ok(json!({"id": id.0, "start": bytes.start, "end": bytes.end, "gid": drawn}));
}
s.edit("Glyphs", |d, _| {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
edit::style_range(&mut t.runs, bytes.start, bytes.end, |st| st.glyph_alternate = choice.clone());
refresh_bounds(t);
Ok(())
})?;
Ok(json!({"id": id.0, "start": bytes.start, "end": bytes.end, "gid": drawn}))
}
fn glyph_alternates(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.glyphAlternates";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let t = text_ref(s, id)?;
let (ch, bytes, face) = glyph_target(&t, p, C)?;
let default = face.glyph_for(ch);
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
// A character that is not laid out (overflowing text) reports the glyph it is drawn with when
// it is shaped alone, as `text.setGlyphAlternate` finds it; a fallback face's glyph when it is
// drawn in one.
let chosen = drawn_glyph(&t.plain_text(), &lay, ch, &bytes, &face).or_else(|| shaped_alone(&t, ch, &bytes, &face).1).unwrap_or(default);
let alternates: Vec<Value> = face.alternates(ch).iter().map(|a| json!({"gid": a.gid, "feature": String::from_utf8_lossy(&a.feature)})).collect();
Ok(json!({"char": ch.to_string(), "font": face.family, "style": face.style, "default": default, "chosen": chosen, "alternates": alternates}))
}
fn touch_type(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.touchType";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
check_numbers(p, &["start"], C)?;
let start = byte_param(p, "start").ok_or_else(|| bad(C, "missing `start` (the byte offset of a character)"))?;
if !["dx", "dy", "hScale", "vScale", "rotation"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
return Err(bad(C, "nothing to change: give dx, dy, hScale, vScale or rotation"));
}
let num = |k: &str| match p.get(k) {
None | Some(Value::Null) => Ok(0.0),
Some(v) => v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(C, format!("`{k}` must be a number"))),
};
let (dx, dy) = (num("dx")?, num("dy")?);
let look = json!({"hScale": p.get("hScale"), "vScale": p.get("vScale"), "rotation": p.get("rotation")});
let mut change = CharChange::parse(&look, C, &s.doc()?.doc.setup)?;
let t = text_ref(s, id)?;
change.localize(&t, C)?;
let points = t.style_scale().ok_or_else(|| bad(C, "text transform is collapsed or unrepresentable"))?.points;
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
let g = lay.glyphs.iter().find(|g| g.byte <= start && start < g.byte + g.len).ok_or_else(|| bad(C, format!("no character at byte {start}")))?;
let (a, b) = (g.byte, g.byte + g.len);
// The line's direction in reading order.
let d = Vec2::new(g.angle.cos(), g.angle.sin());
let along = if g.rtl { -d } else { d };
let linear = |m: Affine, v: Vec2| m * (Point::ZERO + v) - m * Point::ZERO;
// Document points per text-space point along `v`.
let doc_len = |v: Vec2| linear(t.xf, v).hypot() / v.hypot();
let limit = 1296.0 / points;
// A list marker's glyphs (length 0) stand for no character.
let prev = lay.glyphs.iter().find(|h| h.line == g.line && h.len > 0 && h.byte + h.len == a && h.rtl == g.rtl);
let pr = prev.map(|h| (h.byte, h.byte + h.len));
let db = vectorcraft_text::FontDb::global();
let em = glyph_em(db, &t, g).ok_or_else(|| bad(C, "the character's size can't be measured"))?;
let em_prev = match prev {
Some(h) => Some(glyph_em(db, &t, h).ok_or_else(|| bad(C, "the character's size can't be measured"))?),
None if dx != 0.0 => return Err(bad(C, "the first character of a line has no character before it to kern: it moves only up and down (dy)")),
None => None,
};
let kerning_in = |(x, y): (usize, usize)| effective_kerning(db, &t, &lay, x, y);
let original_before = pr.map(kerning_in);
let original_own = kerning_in((a, b));
// The move along the line, in text space, limited to what kerning from -1000 to 10000 (1/1000
// em) on the character before it and on the character itself can give.
let mut moved = dx / doc_len(along);
if let (Some(_range), Some(ep)) = (pr, em_prev) {
let (kp, kc) = (original_before.unwrap_or(0.0), original_own);
let hi = ((10000.0 - kp) * ep).min((kc + 1000.0) * em) / 1000.0;
let lo = ((-1000.0 - kp) * ep).max((kc - 10000.0) * em) / 1000.0;
if lo <= hi {
moved = moved.clamp(lo, hi);
}
}
// The character's scales, rotation and baseline shift, and kerning added to the character
// before it (`before`) and to the character itself (`own`), in 1/1000 em.
let touch = |t: &mut TextObject, before: Option<f64>, own: Option<f64>, raise: f64| {
for (range, by) in [(pr, before), (Some((a, b)), own)] {
if let (Some((x, y)), Some(by)) = (range, by) {
let original = if (x, y) == (a, b) { original_own } else { original_before.unwrap_or(0.0) };
edit::style_range(&mut t.runs, x, y, |st| st.kerning = Some((original + by).clamp(-1000.0, 10000.0)));
}
}
edit::style_range(&mut t.runs, a, b, |st| {
if raise != 0.0 {
st.baseline_shift = (st.baseline_shift + raise).clamp(-limit, limit);
}
change.apply(st);
});
};
// The baseline shift that raises the character `dy` document points. Baseline shift moves it
// up from its line, whatever its rotation, by a length that the object transform, Shrink Text
// to Fit and a path effect such as Skew scale: two layouts with the call's other changes and shifts one step apart, both within the range,
// measure that length. Neither shift is one that a run already has, so the character's run
// splits off alike in both.
let drawn_move = g.rtl && moved != 0.0;
let up = Vec2::new(d.y, -d.x);
let (mut raise, baseline_step) = if dy == 0.0 && !drawn_move {
(0.0, 0.0)
} else {
let shift = edit::slice_runs(&t.runs, a, b).first().map_or(0.0, |r| r.style.baseline_shift);
let from = shift.clamp(-limit, limit);
let free = |v: f64| v.abs() <= limit && t.runs.iter().all(|r| r.style.baseline_shift != v);
// A step of a quarter of the range at most leaves room for two steps up or down.
let unit = (limit / 4.0).min(1.0);
let step =
[1.0, -1.0, 0.75, -0.75].into_iter().map(|f| f * unit).find(|p| free(from + p) && free(from + 2.0 * p)).unwrap_or(-unit.copysign(from));
// The character's pen with its baseline shift set to `to`.
let pen_at = |to: f64| {
let mut trial = t.clone();
// Hold kerning fixed: Optical spacing can change along the line as the baseline
// rises, which would make its horizontal drift count as baseline movement.
touch(&mut trial, pr.map(|_| 0.0), Some(0.0), to - shift);
let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &trial);
l.glyphs.iter().find(|h| h.byte == a && h.len > 0).map(|h| h.xf * Point::ZERO)
};
let (per_step, across) = match (pen_at(from + step), pen_at(from + 2.0 * step)) {
(Some(p), Some(q)) => (linear(t.xf, q - p).hypot() / step.abs(), (q - p).dot(up) / step),
_ => (0.0, 0.0),
};
(dy / per_step, across)
};
if !raise.is_finite() {
return Err(bad(C, "the character's size can't be measured"));
}
// Along-line RTL movement must measure the drawn glyph: turning off automatic kerning can
// also remove font placement, and Character Rotation turns that placement across the line.
let pen = |h: &vectorcraft_text::PositionedGlyph| {
let at = if drawn_move {
h.xf * Point::ZERO
} else if h.rtl {
h.origin + Vec2::new(h.angle.cos(), h.angle.sin()) * h.width_start
} else {
h.origin
};
at.to_vec2().dot(along)
};
// Scaling or rotation in the same call may intentionally turn the glyph's font placement
// about its pen. Keep that change while compensating placement lost to manual kerning.
let intrinsic_delta = if drawn_move && ["hScale", "vScale", "rotation"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())) {
let mut trial = t.clone();
touch(&mut trial, None, None, 0.0);
let l = vectorcraft_text::layout(db, &trial);
let intrinsic = |h: &vectorcraft_text::PositionedGlyph| h.xf * Point::ZERO - h.xf * h.pivot;
l.glyphs.iter().find(|h| h.byte == a && h.len > 0).map_or(Vec2::ZERO, |h| intrinsic(h) - intrinsic(g))
} else {
Vec2::ZERO
};
// How much further the character's new scales advance it along the line: the character laid
// out alone, in point type of the same orientation, before and after the change.
let grown = {
let mut alone = TextObject { kind: TextKind::Point, runs: edit::slice_runs(&t.runs, a, b), paras: Vec::new(), ..t.clone() };
let advance = |o: &TextObject| {
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), o).glyphs.iter().filter(|h| h.len > 0).map(|h| h.advance).sum::<f64>()
};
let old = advance(&alone);
edit::style_range(&mut alone.runs, 0, b - a, |st| change.apply(st));
(advance(&alone) - old) * lay.fit_scale
};
// The character lands `moved` further on, and the character after it on its line keeps its
// distance from it, wider or narrower only by the character's new scales.
let goal = pen(g) + moved + intrinsic_delta.dot(along);
let next_goal = lay.glyphs.iter().find(|h| h.byte == b && h.len > 0 && h.line == g.line).map(|h| pen(h) + grown);
let prev_goal = prev.map(pen);
let mut k = if moved != 0.0 { (em_prev.map(|ep| moved / ep * 1000.0), Some(-moved / em * 1000.0)) } else { (None, None) };
if drawn_move {
if !baseline_step.is_finite() || baseline_step.abs() <= 1e-12 {
return Err(bad(C, "the character's size can't be measured"));
}
let mut trial = t.clone();
touch(&mut trial, k.0, k.1, raise);
let l = vectorcraft_text::layout(db, &trial);
let h = l.glyphs.iter().find(|h| h.byte == a && h.len > 0).ok_or_else(|| bad(C, "the character's size can't be measured"))?;
let across_goal = (g.xf * Point::ZERO).to_vec2().dot(up) + dy / doc_len(up) + intrinsic_delta.dot(up);
raise += (across_goal - (h.xf * Point::ZERO).to_vec2().dot(up)) / baseline_step;
if !raise.is_finite() {
return Err(bad(C, "the character's size can't be measured"));
}
}
// Keep both neighbors: alignment can pin either end of an RTL line, making one neighbor
// insensitive to kerning even though the other one moves. The two spacing values must place
// the selected character and keep every available neighbor in place.
let miss = |k: (Option<f64>, Option<f64>)| {
let mut trial = t.clone();
touch(&mut trial, k.0, k.1, raise);
let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &trial);
let h = l.glyphs.iter().find(|h| h.byte == a && h.len > 0)?;
let next = match (l.glyphs.iter().find(|n| n.byte == b && n.len > 0 && n.line == h.line), next_goal) {
(Some(n), Some(at)) => pen(n) - at,
_ => 0.0,
};
let previous = match (pr.and_then(|(byte, _)| l.glyphs.iter().find(|n| n.byte == byte && n.len > 0 && n.line == h.line)), prev_goal) {
(Some(n), Some(at)) => pen(n) - at,
_ => 0.0,
};
Some([pen(h) - goal, next, previous])
};
let placed = |m: [f64; 3]| m.iter().all(|v| v.abs() <= 1e-9);
// A manual kerning value turns off the font's kerning for its character, each style run is
// shaped apart, and centered, right-aligned and justified lines move as their width changes, so
// the first values can miss. Layouts then measure what each kerning value does to the two
// characters, and the values are solved for with a bounded set of layouts.
if miss(k).is_some_and(|m| !placed(m)) {
k = (pr.map(|_| k.0.unwrap_or(0.0)), Some(k.1.unwrap_or(0.0)));
if let Some(mut m) = miss(k) {
const STEP: f64 = 10.0;
let column = |dk: (f64, f64)| miss((k.0.map(|v| v + dk.0), k.1.map(|v| v + dk.1))).map(|n| std::array::from_fn(|i| (n[i] - m[i]) / STEP));
let by_before = if k.0.is_some() { column((STEP, 0.0)) } else { None };
let by_own = column((0.0, STEP));
// The kerning changes that cancel the misses `m`.
let solve = |m: [f64; 3]| {
let dot = |a: [f64; 3], b: [f64; 3]| a.into_iter().zip(b).map(|(a, b)| a * b).sum::<f64>();
let (p, q) = (by_before.unwrap_or([0.0; 3]), by_own.unwrap_or([0.0; 3]));
let (pp, pq, qq) = (dot(p, p), dot(p, q), dot(q, q));
let (pm, qm) = (dot(p, m), dot(q, m));
let det = pp * qq - pq * pq;
if det > 1e-12 * pp * qq {
((pq * qm - qq * pm) / det, (pq * pm - pp * qm) / det)
} else if pp >= qq && pp > 1e-24 {
(-pm / pp, 0.0)
} else if qq > 1e-24 {
(0.0, -qm / qq)
} else {
(0.0, 0.0)
}
};
for _ in 0..2 {
if placed(m) {
break;
}
let (dp, dq) = solve(m);
k = (k.0.map(|v| v + dp), k.1.map(|v| v + dq));
match miss(k) {
Some(n) => m = n,
None => break,
}
}
}
}
s.edit("Touch Type", |d, _| {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
touch(t, k.0, k.1, raise);
refresh_bounds(t);
Ok(())
})?;
Ok(json!({"id": id.0, "start": a, "end": b}))
}
fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.createInPath";
let pid = id_param(p, "path").ok_or_else(|| bad(C, "missing `path` (a path object id)"))?;
let on_path = match str_param(p, "mode").unwrap_or("area") {
"area" => false,
"onPath" | "path" => true,
o => return Err(bad(C, format!("mode must be area|onPath, got {o}"))),
};
let node = s.doc()?.doc.node(pid).cloned().ok_or(EngineError::NoNode(pid))?;
let NodeKind::Path { path, .. } = &node.kind else { return Err(bad(C, "`path` must be a path object")) };
if path.is_empty() {
return Err(bad(C, "the path is empty"));
}
if !on_path && !path.is_closed() && path.bounds().is_none_or(|b| b.width() < 1.0 || b.height() < 1.0) {
return Err(bad(C, "area type needs a path that encloses an area"));
}
super::paint::check_keep_model(p, C)?;
check_numbers(p, &["size"], C)?;
check_switches(p, &["vertical", "placeholder"], C)?;
super::color_param(p, "color", C)?;
let style = super::create::new_type_style(s, p);
let text = str_param(p, "text").unwrap_or("").to_string();
let start = match point_param(p, "at") {
Some(at) if on_path => vectorcraft_geom::ArcPath::new(path).fraction_at(at).unwrap_or(0.0),
_ => 0.0,
};
let kind = if on_path { TextKind::OnPath { path: path.clone(), start, end: None } } else { TextKind::Area { frame: path.clone() } };
let mut t = TextObject {
vertical: switch_or(p, "vertical", false, C)?,
kind,
xf: Affine::IDENTITY,
runs: vec![TextRun { text, style, inline: None }],
ruby: Vec::new(),
para: super::create::new_type_para(),
paras: Vec::new(),
first_para_continues: false,
area: Default::default(),
path_effect: Default::default(),
path_align: Default::default(),
path_spacing: 0.0,
wrap: Vec::new(),
optical_margin: false,
system_layout: false,
fractional_widths: true,
list_count: 0,
cached_bounds: None,
cached_baselines: Vec::new(),
};
super::create::new_type_alignment(s, p, C, &mut t)?;
if !on_path {
t.area.fit = super::create::new_area_fit(s, p, C)?;
}
t.system_layout = s.doc()?.doc.setup.system_layout;
if bool_or(p, "placeholder", false) {
super::typemenu::fill_with_placeholder(&mut t);
} else {
refresh_bounds(&mut t);
}
let id = s.edit(if on_path { "Type on a Path" } else { "Area Type" }, |d, sel| {
let (par, idx, _) = d.position(pid).ok_or(EngineError::NoNode(pid))?;
d.remove(pid)?;
let id = d.alloc_id();
let mut n = Node::new(id, NodeKind::Text(Box::new(t)));
n.appearance = Appearance::default().into();
n.opacity = node.opacity;
n.name = node.name.clone();
d.insert(par, idx, n)?;
sel.set([id]);
Ok(id)
})?;
let made = super::typecmd::with_bounds(s, Some(id), json!({"id": id.0}));
Ok(super::typecmd::with_overflow(s, &[id], made))
}
/// What `type.step` changes.
#[derive(Clone, Copy, PartialEq)]
enum Step {
Size,
Leading,
Tracking,
Kerning,
BaselineShift,
}
/// The Type tool's selected text while it edits (its typing session ends first: one undo step).
pub(crate) fn editing_range(s: &mut Session) -> Result<Option<(NodeId, usize, usize)>> {
let o = s.tool_options();
let Some(id) = o.get("editing").and_then(Value::as_u64).map(NodeId) else { return Ok(None) };
if super::edit::typing_in_progress(s) {
s.set_tool_option("commitTyping", &Value::Bool(true));
s.commit_interaction()?;
}
let at = |k: &str| byte_param(&o, k).unwrap_or(0);
Ok(Some((id, at("start"), at("end"))))
}
/// The frames of the story whose whole text the Type tool has selected after Select All in a
/// frame of threaded text, in thread order; empty when its selection is in one type object.
pub(crate) fn editing_story(s: &Session) -> Vec<NodeId> {
let o = s.tool_options();
let Some(id) = o.get("editing").and_then(Value::as_u64).map(NodeId) else { return vec![] };
if !o.get("story").and_then(Value::as_array).is_some_and(|ids| !ids.is_empty()) {
return vec![];
}
s.active().map(|st| super::threads::story_of(&st.doc, id)).unwrap_or_default()
}
/// The text ranges a type shortcut changes: the range of `id`, else all the text of the type
/// objects in `ids`, else the text the Type tool has selected (all the text of every frame of the
/// story after Select All in threaded text, [`editing_story`]), else all the text of every selected
/// type object. At a caret, `at_caret` gives the range from the plain text and the caret's byte
/// (None: nothing changes there). Each range comes with whether it came from a caret.
pub(crate) fn type_ranges(
s: &mut Session,
p: &Value,
c: &str,
at_caret: impl Fn(&str, usize) -> Option<(usize, usize)>,
) -> Result<Vec<(NodeId, usize, usize, bool)>> {
// (text, its range; None: all of it).
let targets: Vec<(NodeId, Option<(usize, usize)>)> = match id_param(p, "id") {
// An object that isn't type is refused as `ids` of it is.
Some(id) if s.doc()?.doc.node(id).is_some_and(|n| !matches!(n.kind, NodeKind::Text(_))) => {
return Err(bad(c, "no text objects selected"));
}
Some(id) => vec![(id, Some(range_of(p, edit::runs_len(&text_ref(s, id)?.runs), c)?))],
None => {
let editing = editing_range(s)?.filter(|_| p.get("ids").filter(|v| !v.is_null()).is_none());
let story = if editing.is_some() { editing_story(s) } else { vec![] };
match editing {
// Select All in threaded text: every frame's text.
Some(_) if !story.is_empty() => story.into_iter().map(|id| (id, None)).collect(),
Some((id, a, b)) => vec![(id, Some((a.min(b), a.max(b))))],
None => super::typecmd::text_targets(s, p, c)?.into_iter().map(|id| (id, None)).collect(),
}
}
};
let mut ranges = vec![];
for (id, range) in targets {
let text = text_ref(s, id)?.plain_text();
let (a, b) = range.map_or((0, text.len()), |(a, b)| (a.min(text.len()), b.min(text.len())));
let (a, b, caret) = if a < b {
(a, b, false)
} else {
match at_caret(&text, a) {
Some((a, b)) => (a, b, true),
None => continue,
}
};
if a < b {
ranges.push((id, a, b, caret));
}
}
Ok(ranges)
}
fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "type.step";
let step = match str_param(p, "attribute") {
Some("size") => Step::Size,
Some("leading") => Step::Leading,
Some("tracking") => Step::Tracking,
Some("kerning") => Step::Kerning,
Some("baselineShift") => Step::BaselineShift,
_ => return Err(bad(C, "attribute must be size|leading|tracking|kerning|baselineShift")),
};
let by = number_or(p, "by", 1.0, C)?;
if !by.is_finite() {
return Err(bad(C, "by must be a number"));
}
let prefs = &s.prefs;
let inc = by.clamp(-1000.0, 1000.0)
* match step {
Step::Size | Step::Leading => prefs.type_size_increment,
Step::Tracking | Step::Kerning => prefs.tracking_increment,
Step::BaselineShift => prefs.baseline_shift_increment,
};
// The ranges stepped. At a caret: kerning is the space after the character before it, leading
// the paragraph's; the rest wait for a selection.
let ranges = type_ranges(s, p, C, |text, a| match step {
Step::Kerning => Some((edit::prev_char(text, a), a)),
Step::Leading => {
let para = edit::paragraph_at(text, a);
Some((para.start, para.end))
}
_ => None,
})?;
let mut caret_kerning = Vec::with_capacity(ranges.len());
for &(id, a, b, caret) in &ranges {
let value = if matches!(step, Step::Kerning) && caret {
let t = text_ref(s, id)?;
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
Some(effective_kerning(vectorcraft_text::FontDb::global(), &t, &lay, a, b))
} else {
None
};
caret_kerning.push(value);
}
if !ranges.is_empty() {
s.edit("Character", |d, _| {
for (range_index, &(id, a, b, _)) in ranges.iter().enumerate() {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
let points = if matches!(step, Step::Size | Step::Leading | Step::BaselineShift) {
t.style_scale().ok_or_else(|| bad(C, "text transform is collapsed or unrepresentable"))?.points
} else {
1.0
};
let mut runs = edit::slice_runs(&t.runs, a, b);
// Auto leading steps from the leading its paragraph's Justification › Auto Leading
// sets, so each run covers one paragraph and knows which.
let mut paras = vec![];
if matches!(step, Step::Leading) {
let plain = t.plain_text();
let mut para = plain.get(..a).map_or(0, |s| s.bytes().filter(|&c| c == b'\n').count());
let mut split = Vec::with_capacity(runs.len());
for run in runs {
if run.inline.is_some() || !run.text.contains('\n') {
paras.push(para);
para += run.text.bytes().filter(|&c| c == b'\n').count();
split.push(run);
continue;
}
for piece in run.text.split_inclusive('\n') {
paras.push(para);
para += usize::from(piece.ends_with('\n'));
split.push(TextRun::new(piece, run.style.clone()));
}
}
runs = split;
}
for (i, run) in runs.iter_mut().enumerate() {
let st = &mut run.style;
match step {
Step::Size => st.size = super::typecmd::local_type_value(t, st.size * points + inc, (0.1, 1296.0), false, C)?,
Step::Leading => {
// With Auto leading the step starts from the leading the lines are set with:
// the paragraph's Auto Leading share of the em size the font height reference
// gives the characters. An inline graphic keeps its size.
let em = if run.inline.is_some() { st.size } else { vectorcraft_text::FontDb::global().em_size(st, t.vertical) };
let leading = t.para_at(paras.get(i).copied().unwrap_or(0)).effective_leading(&CharStyle { size: em, ..st.clone() });
st.leading = Some(super::typecmd::local_type_value(t, leading * points + inc, (0.1, 5000.0), false, C)?);
}
Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0),
Step::Kerning => {
let current = caret_kerning.get(range_index).copied().flatten().unwrap_or_else(|| st.kerning.unwrap_or(0.0));
st.kerning = Some((current + inc).clamp(-1000.0, 10000.0));
}
Step::BaselineShift => {
st.baseline_shift = super::typecmd::local_type_value(t, st.baseline_shift * points + inc, (-1296.0, 1296.0), false, C)?;
}
}
}
edit::replace_range_styled(&mut t.runs, a, b, &runs);
refresh_bounds(t);
}
Ok(())
})?;
}
Ok(json!({ "ids": ranges.iter().map(|(id, ..)| id.0).collect::<Vec<_>>() }))
}
/// Cmd/Ctrl+Alt+Q: tracking 0 and manual kerning cleared in the type objects in
/// `ids`, else in the Type tool's selected text, else in every selected type object. At a caret,
/// only the kerning of the character before it changes, as Alt+←/→ in horizontal type or Alt+↓/↑ in vertical type kerns there.
/// The automatic method remains selected.
fn reset_tracking(s: &mut Session, p: &Value) -> Result<Value> {
let ranges = type_ranges(s, p, "type.resetTracking", |text, a| Some((edit::prev_char(text, a), a)))?;
if !ranges.is_empty() {
s.edit("Reset Tracking and Kerning", |d, _| {
for &(id, a, b, caret) in &ranges {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
edit::style_range(&mut t.runs, a, b, |st| {
st.kerning = None;
if !caret {
st.tracking = 0.0;
}
});
refresh_bounds(t);
}
Ok(())
})?;
}
Ok(json!({ "ids": ranges.iter().map(|(id, ..)| id.0).collect::<Vec<_>>() }))
}
/// Cmd/Ctrl+Shift+X: horizontal scale 100% (the Character panel's value, which includes the
/// object's own transform) in the type objects in `ids`, else in the Type tool's selected text,
/// else in every selected type object. At a caret the command leaves the text as it is.
fn reset_horizontal_scale(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "type.resetHorizontalScale";
let ranges = type_ranges(s, p, C, |_, _| None)?;
if !ranges.is_empty() {
s.edit("Reset Horizontal Scale", |d, _| {
for &(id, a, b, _) in &ranges {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
let h_scale = super::typecmd::local_type_value(t, 100.0, (1.0, 10000.0), true, C)?;
edit::style_range(&mut t.runs, a, b, |st| st.h_scale = h_scale);
refresh_bounds(t);
}
Ok(())
})?;
}
Ok(json!({ "ids": ranges.iter().map(|(id, ..)| id.0).collect::<Vec<_>>() }))
}
/// Tracking (1/1000 em) that makes the first paragraph of `t` exactly `target` wide, if it fits
/// on one line at all.
fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
let plain = t.plain_text();
let end = plain.find('\n').unwrap_or(plain.len());
if end == 0 {
return None;
}
let head = edit::slice_runs(&t.runs, 0, end);
let measure = |tr: f64| {
let mut h = TextObject {
vertical: t.vertical,
kind: TextKind::Point,
xf: Affine::IDENTITY,
runs: head.clone(),
// Ruby over the counted part; the layout clamps it to `head`.
ruby: t.ruby.clone(),
para: Default::default(),
paras: Vec::new(),
first_para_continues: false,
area: Default::default(),
path_effect: Default::default(),
path_align: Default::default(),
path_spacing: 0.0,
wrap: Vec::new(),
optical_margin: false,
system_layout: t.system_layout,
fractional_widths: t.fractional_widths,
list_count: 0,
cached_bounds: None,
cached_baselines: Vec::new(),
};
for r in &mut h.runs {
r.style.tracking = tr;
}
let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &h);
l.lines.first().map_or(0.0, |li| li.x1 - li.x0)
};
// Width is linear in tracking: w(tr) = w0 + tr * slope.
let (w0, w1) = (measure(0.0), measure(100.0));
let slope = (w1 - w0) / 100.0;
if slope <= 1e-9 {
return None;
}
// A hair under the target so the line doesn't wrap.
Some(((target - w0) / slope - 0.05).clamp(-1000.0, 10000.0))
}
fn fit_headline(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "type.fitHeadline";
let ids: Vec<NodeId> = {
let st = s.doc()?;
targets(s, p)?
.into_iter()
.filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::Area { .. })))
.collect()
};
if ids.is_empty() {
return Err(bad(C, "select area type"));
}
let mut applied = vec![];
s.edit("Fit Headline", |d, _| {
for id in &ids {
let Some(t) = text_mut(d, *id) else { continue };
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let Some(first) = lay.lines.first() else { continue };
let target = first.avail.1 - first.avail.0;
let Some(tr) = headline_tracking(t, target) else { continue };
let end = t.plain_text().find('\n').unwrap_or(t.plain_text().len());
edit::style_range(&mut t.runs, 0, end, |st| st.tracking = tr);
refresh_bounds(t);
applied.push(tr);
}
Ok(())
})?;
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "tracking": applied}))
}
fn discard_empty(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.discardEmpty";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
if edit::runs_len(&text_ref(s, id)?.runs) > 0 {
return Ok(json!({"removed": false}));
}
// The newest step whose document lacks the text created it: when the document now is that one
// plus the text (and its id), roll back to it without a trace.
let st = s.doc_mut()?;
if st.interaction.is_none()
&& let Some(k) = st.history.undo.iter().rposition(|e| e.doc.node_uncached(id).is_none())
&& let Some(created) = st.history.undo.get(k)
{
let mut now = (*st.doc).clone();
let mut before = (*created.doc).clone();
before.alloc_id();
if now.remove(id).is_ok() && now == before {
st.doc = created.doc.clone();
st.history.undo.truncate(k);
st.selection.prune(&st.doc);
super::globaledit::follow_history(st);
st.revision += 1;
return Ok(json!({"removed": true}));
}
}
s.edit("Discard Empty Type", |d, _| d.remove(id).map(|_| ()).map_err(|_| EngineError::NoNode(id)))?;
Ok(json!({"removed": true}))
}