//! Source Text documents: text with character style runs and per-paragraph settings. //! //! A [`TextDoc`] keeps its *base* character style and paragraph settings in flat fields (the //! style of the first character and the settings of the first paragraph), which is all a //! single-style document needs and what older projects contain. Mixed formatting is stored in //! [`TextDoc::runs`] (character style runs covering the text, by character count) and //! [`TextDoc::paragraphs`] (one entry per `\n`-separated paragraph). Both lists are empty while the //! document is uniform, so single-style documents serialize exactly as before. //! //! Positions are **character** indices (Unicode scalar values), as in After Effects' expression //! and scripting APIs. use std::ops::Range; use serde::{Deserialize, Serialize}; use serde_json::Value as J; /// Horizontal paragraph alignment (AE's seven Paragraph panel buttons). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Justify { #[default] Left, Center, Right, JustifyLastLeft, JustifyLastCenter, JustifyLastRight, JustifyAll, } impl Justify { pub const ALL: [Justify; 7] = [Justify::Left, Justify::Center, Justify::Right, Justify::JustifyLastLeft, Justify::JustifyLastCenter, Justify::JustifyLastRight, Justify::JustifyAll]; /// The `layer.setText` / expression name. pub fn key(self) -> &'static str { match self { Justify::Left => "left", Justify::Center => "center", Justify::Right => "right", Justify::JustifyLastLeft => "justifyLeft", Justify::JustifyLastCenter => "justifyCenter", Justify::JustifyLastRight => "justifyRight", Justify::JustifyAll => "justifyAll", } } pub fn parse(s: &str) -> Option { Some(match s.to_ascii_lowercase().replace(['_', ' ', '-'], "").as_str() { "left" | "leftjustify" => Justify::Left, "center" | "centre" | "centerjustify" => Justify::Center, "right" | "rightjustify" => Justify::Right, "justify" | "justifyleft" | "justifylastleft" | "fulljustifylastlineleft" => Justify::JustifyLastLeft, "justifycenter" | "justifylastcenter" | "fulljustifylastlinecenter" => Justify::JustifyLastCenter, "justifyright" | "justifylastright" | "fulljustifylastlineright" => Justify::JustifyLastRight, "justifyall" | "fulljustifylastlinefull" | "fulljustify" => Justify::JustifyAll, _ => return None, }) } } /// Kerning between a character and the one before it. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] pub enum Kerning { /// The font's kerning pairs (`kern` / GPOS). #[default] Metrics, /// Spacing from the glyph outlines. Optical, /// A manual value in 1/1000 em before this character (replaces metrics for the pair). Manual(f64), } impl Kerning { pub fn parse(v: &J) -> Option { match v { J::Number(n) => n.as_f64().map(Kerning::Manual), J::String(s) => match s.to_ascii_lowercase().as_str() { "metrics" | "metric" | "auto" => Some(Kerning::Metrics), "optical" => Some(Kerning::Optical), "none" | "0" | "manual" => Some(Kerning::Manual(0.0)), s => s.parse::().ok().map(Kerning::Manual), }, _ => None, } } pub fn to_json(self) -> J { match self { Kerning::Metrics => J::from("metrics"), Kerning::Optical => J::from("optical"), Kerning::Manual(v) => J::from(v), } } } /// Superscript / subscript. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum BaselineOption { #[default] Normal, Superscript, Subscript, } impl BaselineOption { pub fn key(self) -> &'static str { match self { BaselineOption::Normal => "normal", BaselineOption::Superscript => "superscript", BaselineOption::Subscript => "subscript", } } pub fn parse(s: &str) -> Option { Some(match s.to_ascii_lowercase().as_str() { "normal" | "none" => BaselineOption::Normal, "superscript" | "super" => BaselineOption::Superscript, "subscript" | "sub" => BaselineOption::Subscript, _ => return None, }) } } /// Paragraph direction. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Direction { #[default] Ltr, Rtl, } /// Line-breaking composer. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Composer { /// Balances the breaks of the whole paragraph (AE's default for new text). #[default] EveryLine, /// Breaks each line as soon as it is full. SingleLine, } fn single_line() -> Composer { Composer::SingleLine } fn yes() -> bool { true } /// Figure style (OpenType `lnum` / `onum`). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum FigureStyle { /// The font's default figures. #[default] Default, Lining, OldStyle, } /// Figure spacing (OpenType `pnum` / `tnum`). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum FigureWidth { #[default] Default, Proportional, Tabular, } /// OpenType layout features of a run of characters (Character panel ▸ OpenType). Kerning, /// standard ligatures, small caps and superscript / subscript have their own [`CharStyle`] /// fields (they use the font's `smcp` / `sups` / `subs` glyphs when it has them and synthesize /// them otherwise). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(default)] pub struct OpenType { /// `dlig`. pub discretionary_ligatures: bool, /// `calt` (on by default, as in the font). pub contextual_alternates: bool, /// `salt`. pub stylistic_alternates: bool, /// Stylistic sets: bit `n - 1` turns on `ssNN` (n = 1…20). pub stylistic_sets: u32, /// `swsh`. pub swash: bool, /// `titl`. pub titling: bool, /// `ordn`. pub ordinals: bool, /// `frac`. pub fractions: bool, /// All Small Caps: capitals become small capitals too (`c2sc` with `smcp`). pub all_small_caps: bool, pub figure_style: FigureStyle, pub figure_width: FigureWidth, } impl Default for OpenType { fn default() -> Self { OpenType { discretionary_ligatures: false, contextual_alternates: true, stylistic_alternates: false, stylistic_sets: 0, swash: false, titling: false, ordinals: false, fractions: false, all_small_caps: false, figure_style: FigureStyle::Default, figure_width: FigureWidth::Default, } } } impl OpenType { pub fn is_default(&self) -> bool { *self == OpenType::default() } /// Whether stylistic set `n` (1…20) is on. pub fn stylistic_set(&self, n: u32) -> bool { (1..=20).contains(&n) && self.stylistic_sets & (1 << (n - 1)) != 0 } pub fn set_stylistic_set(&mut self, n: u32, on: bool) { if (1..=20).contains(&n) { if on { self.stylistic_sets |= 1 << (n - 1); } else { self.stylistic_sets &= !(1 << (n - 1)); } } } /// The OpenType feature settings these options ask for (tag, value), beyond the font's /// defaults: e.g. `[("dlig", 1), ("ss02", 1), ("calt", 0)]`. pub fn feature_settings(&self) -> Vec<([u8; 4], u32)> { let mut v = Vec::new(); for (tag, f) in [ (b"dlig", self.discretionary_ligatures), (b"salt", self.stylistic_alternates), (b"swsh", self.swash), (b"titl", self.titling), (b"ordn", self.ordinals), (b"frac", self.fractions), (b"c2sc", self.all_small_caps), (b"smcp", self.all_small_caps), (b"lnum", self.figure_style == FigureStyle::Lining), (b"onum", self.figure_style == FigureStyle::OldStyle), (b"pnum", self.figure_width == FigureWidth::Proportional), (b"tnum", self.figure_width == FigureWidth::Tabular), ] { if f { v.push((*tag, 1)); } } if !self.contextual_alternates { v.push((*b"calt", 0)); } for n in 1..=20u32 { if self.stylistic_set(n) { let d = format!("ss{n:02}"); let b = d.as_bytes(); v.push(([b[0], b[1], b[2], b[3]], 1)); } } v } } /// The formatting of a run of characters (Character panel). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct CharStyle { pub font: String, pub style: String, pub size: f64, pub fill: [f32; 4], pub stroke: [f32; 4], pub stroke_width: f64, pub apply_fill: bool, pub apply_stroke: bool, /// 1/1000 em after each character. pub tracking: f64, /// Baseline-to-baseline distance; None = auto (120% of the size). pub leading: Option, /// Pixels, positive = up. pub baseline_shift: f64, pub h_scale: f64, pub v_scale: f64, /// Tsume: percent (0–100) of the space around each character removed. pub tsume: f64, pub faux_bold: bool, pub faux_italic: bool, pub all_caps: bool, pub small_caps: bool, pub baseline: BaselineOption, pub kerning: Kerning, pub ligatures: bool, /// Vertical type: set these characters horizontally within the column (Tate-Chu-Yoko). pub tate_chu_yoko: bool, /// Vertical type: keep Roman (half-width) characters upright instead of turning them on /// their side (Character panel menu ▸ Standard Vertical Roman Alignment). pub vertical_roman_upright: bool, /// OpenType features (stylistic sets, figures, fractions…). #[serde(skip_serializing_if = "OpenType::is_default")] pub opentype: OpenType, /// Variable font axis values (tag, user units; axes not listed stay at their defaults): /// the Character panel's Variable Font Axes. #[serde(skip_serializing_if = "Vec::is_empty")] pub variations: Vec<(String, f32)>, } impl Default for CharStyle { fn default() -> Self { TextDoc::default().base_style() } } impl CharStyle { /// The line advance this style asks for (Auto Leading = 120% of the size). pub fn line_advance(&self) -> f64 { self.leading.unwrap_or(self.size * 1.2) } } /// A run of `len` characters sharing a style. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct StyleRun { pub len: usize, pub style: CharStyle, } /// Paragraph panel settings of one paragraph. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct ParaStyle { pub justify: Justify, /// Start (left in LTR) and end margins, and the first line's extra indent, in pixels. pub indent_left: f64, pub indent_right: f64, pub indent_first: f64, pub space_before: f64, pub space_after: f64, pub direction: Direction, pub composer: Composer, /// Roman hanging punctuation: punctuation at the edges of paragraph text hangs outside the box. pub hanging_punctuation: bool, } impl Default for ParaStyle { fn default() -> Self { TextDoc::default().base_para() } } /// The value of a text layer's Source Text (hold-interpolated). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct TextDoc { pub text: String, pub font: String, pub style: String, pub size: f64, pub fill: [f32; 4], pub stroke: [f32; 4], pub stroke_width: f64, pub apply_fill: bool, pub apply_stroke: bool, pub stroke_over_fill: bool, pub tracking: f64, /// None = auto (120% of size). pub leading: Option, pub justify: Justify, pub baseline_shift: f64, pub h_scale: f64, pub v_scale: f64, pub faux_bold: bool, pub faux_italic: bool, pub all_caps: bool, pub small_caps: bool, /// Paragraph text box (width, height) with top-left at `box_pos`; None = point text. pub box_size: Option<[f64; 2]>, pub box_pos: [f64; 2], pub tsume: f64, pub baseline: BaselineOption, pub kerning: Kerning, #[serde(default = "yes")] pub ligatures: bool, /// Base Tate-Chu-Yoko and Standard Vertical Roman Alignment (see [`CharStyle`]). pub tate_chu_yoko: bool, pub vertical_roman_upright: bool, /// Base OpenType features (see [`CharStyle::opentype`]). #[serde(skip_serializing_if = "OpenType::is_default")] pub opentype: OpenType, /// Base variable font axis values (see [`CharStyle::variations`]). #[serde(skip_serializing_if = "Vec::is_empty")] pub variations: Vec<(String, f32)>, pub indent_left: f64, pub indent_right: f64, pub indent_first: f64, pub space_before: f64, pub space_after: f64, pub direction: Direction, /// Documents saved before composers existed broke lines greedily. #[serde(default = "single_line")] pub composer: Composer, pub hanging_punctuation: bool, /// Vertical type (columns right to left, characters upright). pub vertical: bool, /// Character style runs covering the text (empty = the whole text uses the base style). #[serde(skip_serializing_if = "Vec::is_empty")] pub runs: Vec, /// One entry per paragraph (empty = every paragraph uses the base settings). #[serde(skip_serializing_if = "Vec::is_empty")] pub paragraphs: Vec, } impl Default for TextDoc { fn default() -> Self { TextDoc { text: String::new(), font: "Inter".into(), style: "Regular".into(), size: 72.0, fill: [1.0, 1.0, 1.0, 1.0], stroke: [0.0, 0.0, 0.0, 1.0], stroke_width: 0.0, apply_fill: true, apply_stroke: false, stroke_over_fill: true, tracking: 0.0, leading: None, justify: Justify::Left, baseline_shift: 0.0, h_scale: 100.0, v_scale: 100.0, faux_bold: false, faux_italic: false, all_caps: false, small_caps: false, box_size: None, box_pos: [0.0, 0.0], tsume: 0.0, baseline: BaselineOption::Normal, kerning: Kerning::Metrics, ligatures: true, tate_chu_yoko: false, vertical_roman_upright: false, opentype: OpenType::default(), variations: vec![], indent_left: 0.0, indent_right: 0.0, indent_first: 0.0, space_before: 0.0, space_after: 0.0, direction: Direction::Ltr, composer: Composer::EveryLine, hanging_punctuation: false, vertical: false, runs: Vec::new(), paragraphs: Vec::new(), } } } /// Split `runs` so a run starts at character `at`; returns that run's index. fn split_at(runs: &mut Vec, at: usize) -> usize { let mut pos = 0; for i in 0..runs.len() { if pos == at { return i; } let len = runs[i].len; if at < pos + len { let mut tail = runs[i].clone(); tail.len = pos + len - at; runs[i].len = at - pos; runs.insert(i + 1, tail); return i + 1; } pos += len; } runs.len() } impl TextDoc { /// A document with `text` in the default style. pub fn plain(text: &str) -> TextDoc { TextDoc { text: text.into(), ..Default::default() } } pub fn char_len(&self) -> usize { self.text.chars().count() } /// Byte offset of character `ci` (clamped to the end). pub fn byte_of(&self, ci: usize) -> usize { self.text.char_indices().nth(ci).map_or(self.text.len(), |(b, _)| b) } /// Character index of byte offset `b`. pub fn char_of_byte(&self, b: usize) -> usize { self.text.char_indices().take_while(|(i, _)| *i < b).count() } /// The text of characters `r`. pub fn substring(&self, r: Range) -> String { self.text.chars().skip(r.start).take(r.end.saturating_sub(r.start)).collect() } /// The base (first character's) style. pub fn base_style(&self) -> CharStyle { CharStyle { font: self.font.clone(), style: self.style.clone(), size: self.size, fill: self.fill, stroke: self.stroke, stroke_width: self.stroke_width, apply_fill: self.apply_fill, apply_stroke: self.apply_stroke, tracking: self.tracking, leading: self.leading, baseline_shift: self.baseline_shift, h_scale: self.h_scale, v_scale: self.v_scale, tsume: self.tsume, faux_bold: self.faux_bold, faux_italic: self.faux_italic, all_caps: self.all_caps, small_caps: self.small_caps, baseline: self.baseline, kerning: self.kerning, ligatures: self.ligatures, tate_chu_yoko: self.tate_chu_yoko, vertical_roman_upright: self.vertical_roman_upright, opentype: self.opentype, variations: self.variations.clone(), } } fn write_base(&mut self, s: &CharStyle) { self.font = s.font.clone(); self.style = s.style.clone(); self.size = s.size; self.fill = s.fill; self.stroke = s.stroke; self.stroke_width = s.stroke_width; self.apply_fill = s.apply_fill; self.apply_stroke = s.apply_stroke; self.tracking = s.tracking; self.leading = s.leading; self.baseline_shift = s.baseline_shift; self.h_scale = s.h_scale; self.v_scale = s.v_scale; self.tsume = s.tsume; self.faux_bold = s.faux_bold; self.faux_italic = s.faux_italic; self.all_caps = s.all_caps; self.small_caps = s.small_caps; self.baseline = s.baseline; self.kerning = s.kerning; self.ligatures = s.ligatures; self.tate_chu_yoko = s.tate_chu_yoko; self.vertical_roman_upright = s.vertical_roman_upright; self.opentype = s.opentype; self.variations = s.variations.clone(); } /// The base (first paragraph's) settings. pub fn base_para(&self) -> ParaStyle { ParaStyle { justify: self.justify, indent_left: self.indent_left, indent_right: self.indent_right, indent_first: self.indent_first, space_before: self.space_before, space_after: self.space_after, direction: self.direction, composer: self.composer, hanging_punctuation: self.hanging_punctuation, } } fn write_base_para(&mut self, p: &ParaStyle) { self.justify = p.justify; self.indent_left = p.indent_left; self.indent_right = p.indent_right; self.indent_first = p.indent_first; self.space_before = p.space_before; self.space_after = p.space_after; self.direction = p.direction; self.composer = p.composer; self.hanging_punctuation = p.hanging_punctuation; } /// Whether every character shares one style. pub fn is_uniform(&self) -> bool { self.runs.is_empty() } /// Style runs covering the whole text (at least one run, of length 0 for empty text). pub fn runs(&self) -> Vec { let n = self.char_len(); if self.runs.is_empty() { return vec![StyleRun { len: n, style: self.base_style() }]; } let mut out = Vec::with_capacity(self.runs.len()); let mut pos = 0; for r in &self.runs { if pos >= n { break; } let len = r.len.min(n - pos); if len > 0 { out.push(StyleRun { len, style: r.style.clone() }); } pos += len; } match out.last_mut() { Some(last) if pos < n => last.len += n - pos, None => out.push(StyleRun { len: n, style: self.runs[0].style.clone() }), _ => {} } out } /// Replace the style runs: zero-length runs are dropped, equal neighbours merged, the base /// style becomes the first run's, and a single run collapses to the uniform form. pub fn set_runs(&mut self, runs: Vec) { let first = runs.first().map(|r| r.style.clone()); let mut out: Vec = Vec::with_capacity(runs.len()); for r in runs.into_iter().filter(|r| r.len > 0) { match out.last_mut() { Some(last) if last.style == r.style => last.len += r.len, _ => out.push(r), } } if let Some(s) = out.first().map(|r| r.style.clone()).or(first) { self.write_base(&s); } if out.len() <= 1 { out.clear(); } self.runs = out; } /// The style of character `ci` (the last character's past the end). pub fn style_at(&self, ci: usize) -> CharStyle { if self.runs.is_empty() { return self.base_style(); } let mut pos = 0; let runs = self.runs(); for r in &runs { if ci < pos + r.len { return r.style.clone(); } pos += r.len; } runs.last().map(|r| r.style.clone()).unwrap_or_else(|| self.base_style()) } /// The style text typed at caret `at` takes: the character before it, or the first one. pub fn insertion_style(&self, at: usize) -> CharStyle { self.style_at(at.saturating_sub(1)) } /// Change the style of characters `r` (clamped). pub fn apply_style(&mut self, r: Range, mut f: impl FnMut(&mut CharStyle)) { let n = self.char_len(); let (a, b) = (r.start.min(n), r.end.min(n)); if n == 0 { // Empty text: the base style is what the next typed character gets. let mut s = self.base_style(); f(&mut s); self.write_base(&s); return; } if a >= b { return; } let mut runs = self.runs(); let i = split_at(&mut runs, a); let j = split_at(&mut runs, b); for run in &mut runs[i..j] { f(&mut run.style); } self.set_runs(runs); } /// Apply a style to every character. pub fn apply_style_all(&mut self, f: impl FnMut(&mut CharStyle)) { let n = self.char_len(); self.apply_style(0..n.max(1), f); } /// Number of paragraphs (`\n`-separated). pub fn para_count(&self) -> usize { self.text.matches('\n').count() + 1 } /// Character ranges of the paragraphs (without their newline). pub fn para_ranges(&self) -> Vec> { let mut out = Vec::new(); let mut start = 0; let mut i = 0; for c in self.text.chars() { if c == '\n' { out.push(start..i); start = i + 1; } i += 1; } out.push(start..i); out } /// Paragraph index of character / caret position `ci`. pub fn para_of(&self, ci: usize) -> usize { self.text.chars().take(ci).filter(|c| *c == '\n').count() } /// Paragraph settings, one per paragraph. pub fn paras(&self) -> Vec { let n = self.para_count(); let base = self.base_para(); if self.paragraphs.is_empty() { return vec![base; n]; } let mut v: Vec = self.paragraphs.iter().take(n).cloned().collect(); let last = v.last().cloned().unwrap_or(base); v.resize(n, last); v } /// Settings of paragraph `i`. pub fn para(&self, i: usize) -> ParaStyle { if self.paragraphs.is_empty() { return self.base_para(); } let v = self.paras(); v[i.min(v.len() - 1)].clone() } /// Replace the paragraph settings (collapsed when they're all equal). pub fn set_paras(&mut self, mut v: Vec) { if let Some(first) = v.first().cloned() { self.write_base_para(&first); } if v.iter().all(|p| Some(p) == v.first()) { v.clear(); } self.paragraphs = v; } /// Change the paragraphs touched by characters `r` (an empty range: the caret's paragraph). pub fn apply_para(&mut self, r: Range, mut f: impl FnMut(&mut ParaStyle)) { let pa = self.para_of(r.start); let pb = if r.end > r.start { self.para_of(r.end - 1) } else { pa }; let mut v = self.paras(); for p in v.iter_mut().take(pb + 1).skip(pa) { f(p); } self.set_paras(v); } /// Replace characters `r` with `s`. The new text takes `style`, or the style of the first /// replaced character, or (inserting) the style of the character before the caret. pub fn replace_range(&mut self, r: Range, s: &str, style: Option<&CharStyle>) { let n = self.char_len(); let (a, b) = (r.start.min(n), r.end.min(n).max(r.start.min(n))); let st = match style { Some(s) => s.clone(), None if b > a => self.style_at(a), None => self.insertion_style(a), }; let mut runs = self.runs(); let i = split_at(&mut runs, a); let j = split_at(&mut runs, b); let ins = s.chars().count(); runs.splice(i..j, std::iter::once(StyleRun { len: ins, style: st })); // Paragraphs: the joined paragraph keeps the first one's settings; new paragraphs copy it. let mut paras = self.paras(); let pa = self.para_of(a); let pb = self.para_of(b); let added = s.matches('\n').count(); let keep = paras[pa].clone(); paras.splice(pa..=pb.min(paras.len() - 1), std::iter::repeat_n(keep, added + 1)); let (ba, bb) = (self.byte_of(a), self.byte_of(b)); self.text.replace_range(ba..bb, s); self.set_runs(runs); self.set_paras(paras); } /// Replace the whole text, keeping the first character's style and the paragraph settings. pub fn set_text(&mut self, s: &str) { if self.runs.is_empty() && self.paragraphs.is_empty() { self.text = s.to_string(); return; } let n = self.char_len(); let st = self.style_at(0); self.replace_range(0..n, s, Some(&st)); } /// Characters `r` as their own document (formatting kept). pub fn slice(&self, r: Range) -> TextDoc { let n = self.char_len(); let (a, b) = (r.start.min(n), r.end.min(n).max(r.start.min(n))); let mut runs = self.runs(); let i = split_at(&mut runs, a); let j = split_at(&mut runs, b); let sub: Vec = runs[i..j].to_vec(); let paras = self.paras(); let (pa, pb) = (self.para_of(a), self.para_of(b)); let mut d = self.clone(); d.text = self.substring(a..b); d.runs.clear(); d.paragraphs.clear(); if sub.is_empty() { d.write_base(&self.style_at(a)); } d.set_runs(sub); d.set_paras(paras[pa..=pb.min(paras.len() - 1)].to_vec()); d } /// Replace characters `r` with another document's text and character formatting. pub fn insert_doc(&mut self, r: Range, frag: &TextDoc) { let a = r.start.min(self.char_len()); self.replace_range(r, &frag.text, None); let mut pos = a; for run in frag.runs() { let st = run.style.clone(); self.apply_style(pos..pos + run.len, |s| *s = st.clone()); pos += run.len; } } /// Re-establish the invariants after direct field edits (runs clamped, lists collapsed). pub fn normalize(&mut self) { let runs = self.runs(); if !self.runs.is_empty() { self.set_runs(runs); } if !self.paragraphs.is_empty() { let p = self.paras(); self.set_paras(p); } } /// Set a document-level, character or paragraph attribute by its `layer.setText` key over /// characters `r` (None = the whole text). Returns false for unknown keys. pub fn set_attr(&mut self, key: &str, v: &J, r: Option>) -> Result { match key { "strokeOverFill" => { self.stroke_over_fill = v.as_bool().ok_or("strokeOverFill: expected a boolean")?; return Ok(true); } "vertical" => { self.vertical = v.as_bool().ok_or("vertical: expected a boolean")?; return Ok(true); } "box" => { match v { J::Null => self.box_size = None, J::Array(a) if a.len() >= 4 => { let g = |i: usize| a[i].as_f64().unwrap_or(0.0); self.box_pos = [g(0), g(1)]; self.box_size = Some([g(2).max(1.0), g(3).max(1.0)]); } _ => return Err("box: expected [x, y, width, height] or null".into()), } return Ok(true); } _ => {} } let n = self.char_len(); let r = r.unwrap_or(0..n); let mut probe = ParaStyle::default(); if apply_para_attr(&mut probe, key, v)? { let mut err = None; self.apply_para(r, |p| { if let Err(e) = apply_para_attr(p, key, v) { err = Some(e); } }); return err.map_or(Ok(true), Err); } let mut probe = CharStyle::default(); if apply_char_attr(&mut probe, key, v)? { if r.start >= r.end && n > 0 { return Ok(true); } self.apply_style(r.start..r.end.max(r.start + usize::from(n == 0)), |s| { let _ = apply_char_attr(s, key, v); }); return Ok(true); } Ok(false) } } fn color_json(v: &J) -> Option<[f32; 4]> { match v { J::String(s) => { let s = s.trim_start_matches('#'); let h = |i: usize| u8::from_str_radix(s.get(i..i + 2)?, 16).ok().map(|x| x as f32 / 255.0); Some([h(0)?, h(2)?, h(4)?, if s.len() >= 8 { h(6)? } else { 1.0 }]) } J::Array(a) if a.len() >= 3 => { let g = |i: usize, d: f64| a.get(i).and_then(J::as_f64).unwrap_or(d) as f32; Some([g(0, 0.0), g(1, 0.0), g(2, 0.0), g(3, 1.0)]) } _ => None, } } fn num(key: &str, v: &J) -> Result { v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| format!("{key}: expected a number")) } fn norm(v: &J) -> Option { v.as_str().map(|s| s.to_ascii_lowercase().replace(['-', ' ', '_'], "")) } fn flag(key: &str, v: &J) -> Result { v.as_bool().or_else(|| v.as_f64().map(|x| x != 0.0)).ok_or_else(|| format!("{key}: expected a boolean")) } /// Character attribute keys of `layer.setText` (also used by the expression style API). pub const CHAR_ATTRS: &[&str] = &[ "font", "style", "size", "fill", "stroke", "strokeWidth", "applyFill", "applyStroke", "tracking", "leading", "baselineShift", "hScale", "vScale", "tsume", "fauxBold", "fauxItalic", "allCaps", "smallCaps", "baseline", "superscript", "subscript", "kerning", "ligatures", "tateChuYoko", "verticalRomanUpright", "discretionaryLigatures", "contextualAlternates", "stylisticAlternates", "stylisticSets", "swash", "titling", "ordinals", "fractions", "allSmallCaps", "figureStyle", "figureWidth", "figures", "variations", ]; /// Paragraph attribute keys of `layer.setText`. pub const PARA_ATTRS: &[&str] = &["justify", "indentLeft", "indentRight", "indentFirst", "spaceBefore", "spaceAfter", "direction", "composer", "hangingPunctuation"]; /// Set one character attribute; Ok(false) for keys that aren't character attributes. pub fn apply_char_attr(s: &mut CharStyle, key: &str, v: &J) -> Result { match key { "font" => s.font = v.as_str().ok_or("font: expected a string")?.to_string(), "style" => s.style = v.as_str().ok_or("style: expected a string")?.to_string(), "size" => s.size = num(key, v)?.clamp(0.1, 10_000.0), "fill" => s.fill = color_json(v).ok_or("fill: expected #rrggbb or [r, g, b]")?, "stroke" => { s.stroke = color_json(v).ok_or("stroke: expected #rrggbb or [r, g, b]")?; s.apply_stroke = true; } "strokeWidth" => { s.stroke_width = num(key, v)?.max(0.0); s.apply_stroke = s.stroke_width > 0.0; } "applyFill" => s.apply_fill = flag(key, v)?, "applyStroke" => s.apply_stroke = flag(key, v)?, "tracking" => s.tracking = num(key, v)?, "leading" => { s.leading = match v { J::Number(n) => n.as_f64().map(|x| x.max(0.0)), J::String(_) | J::Null => None, J::Bool(true) => None, _ => return Err("leading: expected a number or \"auto\"".into()), } } "baselineShift" => s.baseline_shift = num(key, v)?, "hScale" => s.h_scale = num(key, v)?.clamp(1.0, 1000.0), "vScale" => s.v_scale = num(key, v)?.clamp(1.0, 1000.0), "tsume" => s.tsume = num(key, v)?.clamp(0.0, 100.0), "fauxBold" => s.faux_bold = flag(key, v)?, "fauxItalic" => s.faux_italic = flag(key, v)?, "allCaps" => { s.all_caps = flag(key, v)?; if s.all_caps { s.small_caps = false; s.opentype.all_small_caps = false; } } "smallCaps" => { s.small_caps = flag(key, v)?; if s.small_caps { s.all_caps = false; } } "baseline" => s.baseline = v.as_str().and_then(BaselineOption::parse).ok_or("baseline: expected normal|superscript|subscript")?, "superscript" => { s.baseline = if flag(key, v)? { BaselineOption::Superscript } else { BaselineOption::Normal }; } "subscript" => { s.baseline = if flag(key, v)? { BaselineOption::Subscript } else { BaselineOption::Normal }; } "kerning" => s.kerning = Kerning::parse(v).ok_or("kerning: expected metrics|optical|number")?, "ligatures" => s.ligatures = flag(key, v)?, "tateChuYoko" => s.tate_chu_yoko = flag(key, v)?, "verticalRomanUpright" => s.vertical_roman_upright = flag(key, v)?, "discretionaryLigatures" => s.opentype.discretionary_ligatures = flag(key, v)?, "contextualAlternates" => s.opentype.contextual_alternates = flag(key, v)?, "stylisticAlternates" => s.opentype.stylistic_alternates = flag(key, v)?, "swash" => s.opentype.swash = flag(key, v)?, "titling" => s.opentype.titling = flag(key, v)?, "ordinals" => s.opentype.ordinals = flag(key, v)?, "fractions" => s.opentype.fractions = flag(key, v)?, "allSmallCaps" => { s.opentype.all_small_caps = flag(key, v)?; if s.opentype.all_small_caps { s.all_caps = false; } } "stylisticSets" => { s.opentype.stylistic_sets = match v { J::Array(a) => { let mut m = 0u32; for n in a { let n = n.as_u64().filter(|n| (1..=20).contains(n)).ok_or("stylisticSets: expected set numbers 1–20")?; m |= 1 << (n - 1); } m } J::Number(_) => (num(key, v)? as u64 & 0xF_FFFF) as u32, _ => return Err("stylisticSets: expected [1, 2, …] or a bit mask".into()), } } "figureStyle" => { s.opentype.figure_style = match norm(v).as_deref() { Some("default") => FigureStyle::Default, Some("lining") => FigureStyle::Lining, Some("oldstyle") => FigureStyle::OldStyle, _ => return Err("figureStyle: expected default|lining|oldStyle".into()), } } "figureWidth" => { s.opentype.figure_width = match norm(v).as_deref() { Some("default") => FigureWidth::Default, Some("proportional") => FigureWidth::Proportional, Some("tabular") => FigureWidth::Tabular, _ => return Err("figureWidth: expected default|proportional|tabular".into()), } } "figures" => { let (st, w) = match norm(v).as_deref() { Some("default") => (FigureStyle::Default, FigureWidth::Default), Some("tabularlining") => (FigureStyle::Lining, FigureWidth::Tabular), Some("proportionallining") => (FigureStyle::Lining, FigureWidth::Proportional), Some("tabularoldstyle") => (FigureStyle::OldStyle, FigureWidth::Tabular), Some("proportionaloldstyle") => (FigureStyle::OldStyle, FigureWidth::Proportional), _ => return Err("figures: expected default|tabularLining|proportionalLining|tabularOldstyle|proportionalOldstyle".into()), }; s.opentype.figure_style = st; s.opentype.figure_width = w; } // Variable font axes: `{wght: 650, wdth: 90}` (null removes an axis), or `{}` / null to // reset every axis to its default. "variations" => match v { J::Null => s.variations.clear(), J::Object(m) => { if m.is_empty() { s.variations.clear(); } for (tag, val) in m { if tag.is_empty() || tag.len() > 4 { return Err(format!("variations: `{tag}` is not an axis tag")); } s.variations.retain(|(t, _)| t != tag); match val { J::Null => {} _ => s.variations.push((tag.clone(), val.as_f64().ok_or("variations: expected {tag: number}")? as f32)), } } s.variations.sort_by(|a, b| a.0.cmp(&b.0)); } _ => return Err("variations: expected {tag: number, …}".into()), }, k if k.len() == 4 && k.starts_with("ss") && k[2..].parse::().is_ok_and(|n| (1..=20).contains(&n)) => { let n = k[2..].parse::().unwrap_or(0); s.opentype.set_stylistic_set(n, flag(key, v)?); } _ => return Ok(false), } Ok(true) } /// Set one paragraph attribute; Ok(false) for keys that aren't paragraph attributes. pub fn apply_para_attr(p: &mut ParaStyle, key: &str, v: &J) -> Result { match key { "justify" => { p.justify = v.as_str().and_then(Justify::parse).ok_or("justify: expected left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll")? } "indentLeft" => p.indent_left = num(key, v)?, "indentRight" => p.indent_right = num(key, v)?, "indentFirst" => p.indent_first = num(key, v)?, "spaceBefore" => p.space_before = num(key, v)?, "spaceAfter" => p.space_after = num(key, v)?, "direction" => { p.direction = match v.as_str().map(str::to_ascii_lowercase).as_deref() { Some("ltr" | "lefttoright" | "left-to-right") => Direction::Ltr, Some("rtl" | "righttoleft" | "right-to-left") => Direction::Rtl, _ => return Err("direction: expected ltr|rtl".into()), } } "composer" => { p.composer = match v.as_str().map(|s| s.to_ascii_lowercase().replace(['-', ' ', '_'], "")).as_deref() { Some("everyline" | "every") => Composer::EveryLine, Some("singleline" | "single") => Composer::SingleLine, _ => return Err("composer: expected everyLine|singleLine".into()), } } "hangingPunctuation" => p.hanging_punctuation = flag(key, v)?, _ => return Ok(false), } Ok(true) } /// A character style as `layer.setText` keys. pub fn char_style_json(s: &CharStyle) -> J { let c = |c: [f32; 4]| J::from(vec![c[0] as f64, c[1] as f64, c[2] as f64, c[3] as f64]); serde_json::json!({ "font": s.font, "style": s.style, "size": s.size, "fill": c(s.fill), "stroke": c(s.stroke), "strokeWidth": s.stroke_width, "applyFill": s.apply_fill, "applyStroke": s.apply_stroke, "tracking": s.tracking, "leading": s.leading.map_or(J::from("auto"), J::from), "baselineShift": s.baseline_shift, "hScale": s.h_scale, "vScale": s.v_scale, "tsume": s.tsume, "fauxBold": s.faux_bold, "fauxItalic": s.faux_italic, "allCaps": s.all_caps, "smallCaps": s.small_caps, "baseline": s.baseline.key(), "kerning": s.kerning.to_json(), "ligatures": s.ligatures, "tateChuYoko": s.tate_chu_yoko, "verticalRomanUpright": s.vertical_roman_upright, "discretionaryLigatures": s.opentype.discretionary_ligatures, "contextualAlternates": s.opentype.contextual_alternates, "stylisticAlternates": s.opentype.stylistic_alternates, "stylisticSets": (1..=20u32).filter(|n| s.opentype.stylistic_set(*n)).collect::>(), "swash": s.opentype.swash, "titling": s.opentype.titling, "ordinals": s.opentype.ordinals, "fractions": s.opentype.fractions, "allSmallCaps": s.opentype.all_small_caps, "variations": s.variations.iter().map(|(t, v)| (t.clone(), J::from(*v as f64))).collect::>(), "figureStyle": match s.opentype.figure_style { FigureStyle::Default => "default", FigureStyle::Lining => "lining", FigureStyle::OldStyle => "oldStyle" }, "figureWidth": match s.opentype.figure_width { FigureWidth::Default => "default", FigureWidth::Proportional => "proportional", FigureWidth::Tabular => "tabular" }, }) } /// Paragraph settings as `layer.setText` keys. pub fn para_style_json(p: &ParaStyle) -> J { serde_json::json!({ "justify": p.justify.key(), "indentLeft": p.indent_left, "indentRight": p.indent_right, "indentFirst": p.indent_first, "spaceBefore": p.space_before, "spaceAfter": p.space_after, "direction": if p.direction == Direction::Rtl { "rtl" } else { "ltr" }, "composer": if p.composer == Composer::EveryLine { "everyLine" } else { "singleLine" }, "hangingPunctuation": p.hanging_punctuation, }) } /// Word boundaries for caret movement: the start of the word left of `ci`. pub fn word_left(text: &str, ci: usize) -> usize { let chars: Vec = text.chars().collect(); let mut i = ci.min(chars.len()); while i > 0 && !chars[i - 1].is_alphanumeric() { i -= 1; } while i > 0 && chars[i - 1].is_alphanumeric() { i -= 1; } i } /// The end of the word right of `ci`. pub fn word_right(text: &str, ci: usize) -> usize { let chars: Vec = text.chars().collect(); let mut i = ci.min(chars.len()); while i < chars.len() && !chars[i].is_alphanumeric() { i += 1; } while i < chars.len() && chars[i].is_alphanumeric() { i += 1; } i } /// The word (or run of spaces / punctuation) containing character `ci`, for double-click. pub fn word_at(text: &str, ci: usize) -> Range { let chars: Vec = text.chars().collect(); if chars.is_empty() { return 0..0; } let i = ci.min(chars.len() - 1); let class = |c: char| { if c.is_alphanumeric() { 0 } else if c == '\n' { 2 } else if c.is_whitespace() { 1 } else { 3 } }; let k = class(chars[i]); let (mut a, mut b) = (i, i + 1); while a > 0 && class(chars[a - 1]) == k && k != 2 { a -= 1; } while b < chars.len() && class(chars[b]) == k && k != 2 { b += 1; } a..b } #[cfg(test)] mod tests { use super::*; fn sizes(d: &TextDoc) -> Vec<(usize, f64)> { d.runs().iter().map(|r| (r.len, r.style.size)).collect() } #[test] fn style_runs_split_and_merge() { let mut d = TextDoc::plain("Hello world"); assert!(d.is_uniform()); d.apply_style(6..11, |s| s.size = 40.0); assert_eq!(sizes(&d), vec![(6, 72.0), (5, 40.0)]); d.apply_style(2..8, |s| s.fill = [1.0, 0.0, 0.0, 1.0]); assert_eq!(d.runs().len(), 4); assert_eq!(d.style_at(7).fill, [1.0, 0.0, 0.0, 1.0]); assert_eq!(d.style_at(7).size, 40.0); // Undo the colour: neighbours merge again. d.apply_style(0..11, |s| s.fill = [1.0; 4]); assert_eq!(sizes(&d), vec![(6, 72.0), (5, 40.0)]); d.apply_style(0..11, |s| s.size = 10.0); assert!(d.is_uniform()); assert_eq!(d.size, 10.0); } #[test] fn edits_keep_runs_aligned() { let mut d = TextDoc::plain("abcdef"); d.apply_style(2..4, |s| s.size = 20.0); // Typing after "cd" continues its style. d.replace_range(4..4, "XY", None); assert_eq!(d.text, "abcdXYef"); assert_eq!(sizes(&d), vec![(2, 72.0), (4, 20.0), (2, 72.0)]); // Typing at the very start takes the first character's style. d.replace_range(0..0, "_", None); assert_eq!(sizes(&d), vec![(3, 72.0), (4, 20.0), (2, 72.0)]); // Deleting the whole styled span merges the outer runs. d.replace_range(3..7, "", None); assert_eq!(d.text, "_abef"); assert!(d.is_uniform()); // Replacing a selection uses the first selected character's style. d.apply_style(1..2, |s| s.size = 5.0); d.replace_range(1..3, "Q", None); assert_eq!(d.text, "_Qef"); assert_eq!(sizes(&d), vec![(1, 72.0), (1, 5.0), (2, 72.0)]); // Deleting everything keeps the style of what was there. d.replace_range(0..4, "", None); assert_eq!(d.text, ""); assert!(d.is_uniform()); assert_eq!(d.size, 72.0); } #[test] fn paragraphs_follow_edits() { let mut d = TextDoc::plain("one\ntwo\nthree"); assert_eq!(d.para_count(), 3); d.apply_para(4..5, |p| p.indent_left = 30.0); assert_eq!(d.paras().iter().map(|p| p.indent_left).collect::>(), vec![0.0, 30.0, 0.0]); // Enter inside "two" splits it: both halves keep the indent. d.replace_range(5..5, "\n", None); assert_eq!(d.text, "one\nt\nwo\nthree"); assert_eq!(d.paras().iter().map(|p| p.indent_left).collect::>(), vec![0.0, 30.0, 30.0, 0.0]); // Joining the first two paragraphs keeps the first's settings. d.replace_range(3..4, "", None); assert_eq!(d.paras().iter().map(|p| p.indent_left).collect::>(), vec![0.0, 30.0, 0.0]); assert_eq!(d.para_ranges(), vec![0..4, 5..7, 8..13]); // Space before only on paragraph 3 via a range ending in it. d.apply_para(9..10, |p| p.space_before = 12.0); assert_eq!(d.para(2).space_before, 12.0); assert_eq!(d.para(1).space_before, 0.0); } #[test] fn single_style_serde_is_unchanged_and_old_docs_load() { let d = TextDoc::plain("Hi"); let j = serde_json::to_value(&d).unwrap(); assert!(j.get("runs").is_none() && j.get("paragraphs").is_none()); // A document saved before style runs existed (no new fields). let old = r#"{"text":"Old","font":"Inter","style":"Bold","size":50.0,"fill":[1,0,0,1],"stroke":[0,0,0,1], "stroke_width":0.0,"apply_fill":true,"apply_stroke":false,"stroke_over_fill":true,"tracking":10.0, "leading":null,"justify":"Center","baseline_shift":0.0,"h_scale":100.0,"v_scale":100.0,"faux_bold":false, "faux_italic":false,"all_caps":false,"small_caps":false,"box_size":[300.0,200.0],"box_pos":[0.0,0.0]}"#; let d: TextDoc = serde_json::from_str(old).unwrap(); assert!(d.is_uniform()); assert_eq!(d.style_at(2).size, 50.0); assert_eq!(d.composer, Composer::SingleLine, "old box text keeps greedy breaking"); assert!(d.ligatures); assert_eq!(d.kerning, Kerning::Metrics); // Round trip with runs. let mut m = d.clone(); m.apply_style(0..1, |s| s.kerning = Kerning::Manual(-40.0)); let back: TextDoc = serde_json::from_value(serde_json::to_value(&m).unwrap()).unwrap(); assert_eq!(back, m); assert_eq!(back.style_at(0).kerning, Kerning::Manual(-40.0)); } #[test] fn slice_and_insert_doc_carry_formatting() { let mut d = TextDoc::plain("red blue"); d.apply_style(0..3, |s| s.fill = [1.0, 0.0, 0.0, 1.0]); let frag = d.slice(2..6); assert_eq!(frag.text, "d bl"); assert_eq!(frag.runs().len(), 2); let mut e = TextDoc::plain("xx"); e.insert_doc(1..1, &frag); assert_eq!(e.text, "xd blx"); assert_eq!(e.style_at(1).fill, [1.0, 0.0, 0.0, 1.0]); assert_eq!(e.style_at(2).fill, [1.0; 4]); } #[test] fn attrs_by_key() { let mut d = TextDoc::plain("abc\ndef"); assert!(d.set_attr("size", &J::from(20), Some(0..2)).unwrap()); assert!(d.set_attr("kerning", &J::from("optical"), None).unwrap()); assert!(d.set_attr("justify", &J::from("justifyAll"), Some(5..5)).unwrap()); assert!(d.set_attr("superscript", &J::from(true), Some(2..3)).unwrap()); assert!(!d.set_attr("nope", &J::from(1), None).unwrap()); assert!(d.set_attr("size", &J::from("big"), None).is_err()); assert_eq!(d.style_at(1).size, 20.0); assert_eq!(d.style_at(2).baseline, BaselineOption::Superscript); assert_eq!(d.style_at(6).kerning, Kerning::Optical); assert_eq!(d.para(1).justify, Justify::JustifyAll); assert_eq!(d.para(0).justify, Justify::Left); let j = char_style_json(&d.style_at(0)); let mut s = CharStyle::default(); for (k, v) in j.as_object().unwrap() { assert!(apply_char_attr(&mut s, k, v).unwrap(), "{k}"); } assert_eq!(s, d.style_at(0)); } #[test] fn word_boundaries() { let t = "hello, big world"; assert_eq!(word_right(t, 0), 5); assert_eq!(word_right(t, 5), 10); assert_eq!(word_left(t, 16), 11); assert_eq!(word_left(t, 11), 7); assert_eq!(word_at(t, 8), 7..10); assert_eq!(word_at(t, 6), 6..7); } #[test] fn opentype_attributes_per_range_and_serde() { let mut d = TextDoc::plain("Office 1/2 1st"); // Untouched documents serialize without an OpenType block. assert!(!serde_json::to_string(&d).unwrap().contains("opentype")); d.set_attr("ss02", &J::Bool(true), Some(0..6)).unwrap(); d.set_attr("fractions", &J::Bool(true), Some(7..10)).unwrap(); d.set_attr("figures", &J::from("tabularOldstyle"), None).unwrap(); assert!(d.style_at(0).opentype.stylistic_set(2)); assert!(!d.style_at(8).opentype.stylistic_set(2)); assert!(d.style_at(8).opentype.fractions); assert_eq!(d.style_at(12).opentype.figure_width, FigureWidth::Tabular); assert!(d.set_attr("stylisticSets", &serde_json::json!([1, 20]), Some(0..1)).unwrap()); assert_eq!(d.style_at(0).opentype.stylistic_sets, 1 | (1 << 19)); assert!(d.set_attr("stylisticSets", &serde_json::json!([21]), None).is_err()); let f = d.style_at(8).opentype.feature_settings(); assert!(f.contains(&(*b"frac", 1)) && f.contains(&(*b"onum", 1)) && f.contains(&(*b"tnum", 1))); let mut s = d.style_at(0); s.opentype.contextual_alternates = false; assert!(s.opentype.feature_settings().contains(&(*b"calt", 0))); // Round trip, and the JSON form names the attributes as layer.setText does. let back: TextDoc = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap(); assert_eq!(back, d); let j = char_style_json(&d.style_at(0)); assert_eq!(j["stylisticSets"], serde_json::json!([1, 20])); assert_eq!(j["figureStyle"], "oldStyle"); // All Small Caps and All Caps exclude each other. let mut s = CharStyle::default(); apply_char_attr(&mut s, "allCaps", &J::Bool(true)).unwrap(); apply_char_attr(&mut s, "allSmallCaps", &J::Bool(true)).unwrap(); assert!(!s.all_caps && s.opentype.all_small_caps); } }