From 695ca0752874c94138bd5e362136bf28850a3c42 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 06:43:02 -0500 Subject: [PATCH 1/4] M3: text styles carry paragraph spacing and indent, the list style and the OpenType features (#1829) ParagraphStyle beside TextStyle in StyleKind::Text: style.create takes it from the layer or a value, style.apply sets it (the list on the paragraphs touched for a range), style.update moves it, editing it unlinks, .fig files keep it with the style; tests in styles_behaviour_tests and fig_export_assets_tests. Co-Authored-By: Claude Fable 5.1 --- crates/doc/src/colors.rs | 2 +- crates/doc/src/lib.rs | 2 +- crates/doc/src/styles.rs | 22 ++++- crates/doc/src/text.rs | 68 ++++++++++++++ crates/engine/src/assets/style_vars.rs | 4 +- crates/engine/src/cmd/file/load.rs | 6 +- crates/engine/src/cmd/styles.rs | 94 ++++++++++++++++--- crates/engine/src/content/synthetic_ds.rs | 4 +- crates/engine/src/fig_export_assets_tests.rs | 21 +++++ crates/engine/src/fonts.rs | 2 +- crates/engine/src/fonts/tests.rs | 5 +- crates/engine/src/sketch_import_tests.rs | 2 +- crates/engine/src/styles_behaviour_tests.rs | 65 ++++++++++++- crates/engine/src/text_style_picker_tests.rs | 4 +- crates/format/src/fig/assets_tests.rs | 4 +- crates/format/src/fig/export/assets.rs | 7 +- crates/format/src/fig/text_skip_ink_tests.rs | 6 +- crates/format/src/fig/text_style_tests.rs | 2 +- crates/format/src/penpot.rs | 12 +-- crates/format/src/rest.rs | 10 +- crates/format/src/rest/tests.rs | 2 +- crates/format/src/sketch/shared_tests.rs | 2 +- crates/ui-egui/src/widgets/style_picker.rs | 41 +++++++- .../ui-egui/src/widgets/style_picker/tests.rs | 8 +- docs/components-parity.md | 5 +- docs/mcp.md | 12 ++- 26 files changed, 346 insertions(+), 66 deletions(-) diff --git a/crates/doc/src/colors.rs b/crates/doc/src/colors.rs index f4681601b..bd4dfdbab 100644 --- a/crates/doc/src/colors.rs +++ b/crates/doc/src/colors.rs @@ -79,7 +79,7 @@ impl Style { pub fn for_each_color_mut(&mut self, f: &mut ColorVisitor) { match &mut self.kind { StyleKind::Paint { paints } => paints.iter_mut().for_each(|p| p.for_each_color_mut(f)), - StyleKind::Text { style } => style.for_each_color_mut(f), + StyleKind::Text { style, .. } => style.for_each_color_mut(f), StyleKind::Effect { effects } => effects.iter_mut().for_each(|e| e.for_each_color_mut(f)), StyleKind::Grid { grids } => grids.iter_mut().for_each(|g| f(&mut g.color)), } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 89eb97269..eecb64af2 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1206,7 +1206,7 @@ impl Document { .styles .iter() .filter_map(|s| match &s.kind { - StyleKind::Text { style } => Some((s.id.clone(), style.clone())), + StyleKind::Text { style, .. } => Some((s.id.clone(), style.clone())), _ => None, }) .collect(); diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index f3893077e..b10806e33 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -1,7 +1,7 @@ //! Shared styles and variables. use crate::props::{Effect, LayoutGrid}; -use crate::text::TextStyle; +use crate::text::{ParagraphStyle, TextStyle}; use figcraft_color::{Color, Paint}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; @@ -14,10 +14,22 @@ pub type ModeId = String; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum StyleKind { - Paint { paints: Vec }, - Text { style: TextStyle }, - Effect { effects: Vec }, - Grid { grids: Vec }, + Paint { + paints: Vec, + }, + Text { + style: TextStyle, + /// What the style sets at the paragraph and layer level (#1829); boxed to keep the enum + /// small. + #[serde(default, skip_serializing_if = "crate::node::is_default")] + paragraph: Box, + }, + Effect { + effects: Vec, + }, + Grid { + grids: Vec, + }, } impl StyleKind { diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 0c41d3c9a..76771c80c 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -291,6 +291,23 @@ pub struct TextStyle { pub style_id: Option, } +/// What a text style sets at the paragraph and layer level, beside its character style +/// ([`TextStyle`]): paragraph spacing and indent, the list style and the OpenType features +/// ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType +/// features", Create and apply text styles). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct ParagraphStyle { + #[serde(skip_serializing_if = "crate::node::is_default")] + pub paragraph_spacing: f64, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub paragraph_indent: f64, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub list: ListStyle, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub features: std::collections::BTreeMap, +} + /// The font new text starts in. A build can name another (`FIGCRAFT_DEFAULT_FONT="Noto Sans Thai"`, /// for a distribution whose users mostly write a script Inter lacks); empty means Inter. pub const DEFAULT_FONT: &str = match option_env!("FIGCRAFT_DEFAULT_FONT") { @@ -843,6 +860,57 @@ impl TextData { (a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first) } + /// What a text style made from this text sets at the paragraph and layer level: the paragraph + /// spacing and indent, the list style (the one every paragraph shares; the base one when they + /// differ) and the OpenType features. + pub fn paragraph_style(&self) -> ParagraphStyle { + let list = match self.paragraphs.first() { + Some(first) if self.paragraphs.iter().all(|p| p.list == first.list) => first.list, + _ => self.list, + }; + ParagraphStyle { paragraph_spacing: self.paragraph_spacing, paragraph_indent: self.paragraph_indent, list, features: self.features.clone() } + } + + /// Take what text style `ps` sets at the paragraph and layer level: the spacing, indent and + /// features on the layer, the list style on every paragraph. + pub fn take_paragraph_style(&mut self, ps: &ParagraphStyle) { + let len = self.char_count(); + self.take_paragraph_style_in(0, len, ps); + } + + /// As [`take_paragraph_style`](Self::take_paragraph_style), with the list style going to the + /// paragraphs chars `start..end` touch only; the spacing, indent and features are the + /// layer's, as in Figma. + pub fn take_paragraph_style_in(&mut self, start: usize, end: usize, ps: &ParagraphStyle) { + self.paragraph_spacing = ps.paragraph_spacing; + self.paragraph_indent = ps.paragraph_indent; + self.features.clone_from(&ps.features); + self.set_para_lists(start, end, |pl| pl.list = ps.list); + } + + /// Follow text style `id`'s paragraph-level definition wherever chars link to it. + pub fn update_paragraph_style(&mut self, id: &str, ps: &ParagraphStyle) { + let linked: Vec<(usize, usize)> = + self.spans().into_iter().filter(|(_, _, s)| s.style_id.as_deref() == Some(id)).map(|(a, b, _)| (a, b)).collect(); + for (a, b) in linked { + self.take_paragraph_style_in(a, b, ps); + } + } + + /// Whether chars `start..end` show what text style `ps` sets at the paragraph and layer + /// level: the layer's spacing, indent and features, and their paragraphs' list style. + pub fn shows_paragraph_style_in(&self, start: usize, end: usize, ps: &ParagraphStyle) -> bool { + self.paragraph_spacing == ps.paragraph_spacing + && self.paragraph_indent == ps.paragraph_indent + && self.features == ps.features + && self.common_para_list(start, end).is_some_and(|pl| pl.list == ps.list) + } + + /// Whether the whole text shows what text style `ps` sets at the paragraph and layer level. + pub fn shows_paragraph_style(&self, ps: &ParagraphStyle) -> bool { + self.shows_paragraph_style_in(0, self.char_count(), ps) + } + /// Link chars `start..end` (all of them when the range is empty or the whole text) to text /// style `id`, defined as `def`. pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) { diff --git a/crates/engine/src/assets/style_vars.rs b/crates/engine/src/assets/style_vars.rs index 7fb30fd06..12db4eaa8 100644 --- a/crates/engine/src/assets/style_vars.rs +++ b/crates/engine/src/assets/style_vars.rs @@ -113,7 +113,7 @@ fn resolved(vars: &Variables, st: &Style, modes: &Modes) -> StyleKind { (StyleKind::Grid { grids }, Some((BoundList::Grids, i, field)), VariableValue::Number(x)) => { crate::cmd::lists::set_grid_field(grids, i, field, *x); } - (StyleKind::Text { style }, None, _) => set_text_field(style, key, &val), + (StyleKind::Text { style, .. }, None, _) => set_text_field(style, key, &val), _ => {} } } @@ -206,7 +206,7 @@ pub fn refresh(doc: &mut Document, before: Option<&Document>, only: Option<&BTre && was.is_none_or(|w| w.kind == n.kind) { for st in doc.styles.iter().filter(|s| !s.bound.is_empty()) { - if let StyleKind::Text { style } = kind_for(doc, st, id).as_ref() { + if let StyleKind::Text { style, .. } = kind_for(doc, st, id).as_ref() { t.update_text_style(&st.id, style); } } diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs index 5033a057e..3e917d5dd 100644 --- a/crates/engine/src/cmd/file/load.rs +++ b/crates/engine/src/cmd/file/load.rs @@ -250,7 +250,7 @@ fn match_font_families(doc: &mut Document) { } // Shared text styles too, so they stay like the layers linked to them. for st in &mut doc.styles { - if let StyleKind::Text { style } = &mut st.kind { + if let StyleKind::Text { style, .. } = &mut st.kind { fix(style); } } @@ -294,7 +294,7 @@ fn platform_tracking(doc: &mut Document) { } } for st in &mut doc.styles { - if let StyleKind::Text { style } = &mut st.kind { + if let StyleKind::Text { style, .. } = &mut st.kind { track(style); } } @@ -365,7 +365,7 @@ fn sketch_line_heights(doc: &mut Document) { } } for st in &mut doc.styles { - if let StyleKind::Text { style } = &mut st.kind { + if let StyleKind::Text { style, .. } = &mut st.kind { set(style); } } diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 99d4521e4..313062a68 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; use figcraft_color::{Color, Paint, PaintKind}; +use figcraft_doc::ParagraphStyle; use figcraft_doc::{ Collection, CollectionId, Document, Effect, EffectKind, Mode, ModeId, Node, NodeId, NodeKind, Style, StyleId, StyleKind, TextData, TextStyle, Variable, VariableId, VariableType, VariableValue, Variables, trim_num, @@ -261,6 +262,41 @@ fn text_style_of(n: &Node, at: Option) -> Option { Some(at.map_or(&t.style, |a| t.style_at(a)).as_text_style()) } +/// What a text style definition from JSON sets at the paragraph level, in the `node.set` +/// spellings (`paragraphSpacing`, `paragraphIndent`, `listStyle`: none|bulleted|numbered, +/// `features` or `openTypeFeatures`: `{tag: bool}`); keys left out are the defaults. +fn paragraph_style_json(cmd: &str, v: &Value) -> Result { + let Value::Object(m) = v else { return Ok(ParagraphStyle::default()) }; + let mut ps = ParagraphStyle::default(); + let length = + |k: &str, x: &Value| f64_of(x).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 10_000.0)).ok_or_else(|| bad(cmd, format!("{k}: a number"))); + for (k, x) in m { + match k.as_str() { + "paragraphSpacing" | "paragraph_spacing" => ps.paragraph_spacing = length(k, x)?, + "paragraphIndent" | "paragraph_indent" => ps.paragraph_indent = length(k, x)?, + "listStyle" | "list" => ps.list = super::props::enum_of(x).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?, + "features" | "openTypeFeatures" | "fontFeatures" | "textFeatures" => { + ps.features = super::props::features_of(cmd, x)?.into_iter().filter_map(|(tag, on)| Some((tag, on?))).collect(); + } + _ => {} + } + } + Ok(ps) +} + +/// What a layer's text sets at the paragraph level, the list style being that of the paragraphs +/// chars `range` touch when given (the layer's when they differ). +fn paragraph_style_of(n: &Node, range: Option<(usize, usize)>) -> Option { + let t = n.kind.text()?; + let mut ps = t.paragraph_style(); + if let Some((a, b)) = range + && let Some(pl) = t.common_para_list(a, b) + { + ps.list = pl.list; + } + Some(ps) +} + fn detach(s: &mut Session, p: &Value) -> Result { let cmd = "style.detach"; let which = req_string(cmd, p, "which")?; @@ -295,7 +331,7 @@ fn kind_from_node(t: &str, n: &Node) -> Option { Some(match t { "paint" | "color" | "fill" => StyleKind::Paint { paints: n.fills.clone() }, "stroke" => StyleKind::Paint { paints: n.strokes.clone() }, - "text" => StyleKind::Text { style: text_style_of(n, None)? }, + "text" => StyleKind::Text { style: text_style_of(n, None)?, paragraph: Box::new(paragraph_style_of(n, None)?) }, "effect" => StyleKind::Effect { effects: n.effects.clone() }, "grid" => StyleKind::Grid { grids: n.grids.clone() }, _ => return None, @@ -317,13 +353,17 @@ fn create(s: &mut Session, p: &Value) -> Result { let kind = if let Some(v) = get(p, "value") { match t.as_str() { "paint" | "color" | "fill" => StyleKind::Paint { paints: paints_of(v).ok_or_else(|| bad(cmd, "value: paints"))? }, - "text" => StyleKind::Text { style: text_style_json(cmd, v)? }, + "text" => StyleKind::Text { style: text_style_json(cmd, v)?, paragraph: Box::new(paragraph_style_json(cmd, v)?) }, "effect" => StyleKind::Effect { effects: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? }, "grid" => StyleKind::Grid { grids: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? }, _ => return Err(bad(cmd, "type: paint|text|effect|grid")), } - } else if let Some((tid, a, _)) = range { - StyleKind::Text { style: doc.node(tid).and_then(|n| text_style_of(n, Some(a))).ok_or_else(|| bad(cmd, "not a text layer"))? } + } else if let Some((tid, a, b)) = range { + let n = doc.node(tid).ok_or_else(|| bad(cmd, "not a text layer"))?; + StyleKind::Text { + style: text_style_of(n, Some(a)).ok_or_else(|| bad(cmd, "not a text layer"))?, + paragraph: Box::new(paragraph_style_of(n, Some((a, b))).ok_or_else(|| bad(cmd, "not a text layer"))?), + } } else { let n = from.and_then(|f| doc.node(f)).ok_or_else(|| bad(cmd, "pass `value` or select a layer to take it from"))?; kind_from_node(&t, n).ok_or_else(|| bad(cmd, "that layer has no such property"))? @@ -343,7 +383,7 @@ fn create(s: &mut Session, p: &Value) -> Result { }; doc.styles.push(Style { id: sid.clone(), name, description: string(p, "description").unwrap_or_default(), kind: kind.clone(), bound }); // The source layer (or the selected chars) becomes linked to the new style. - if let (Some((tid, a, b)), StyleKind::Text { style }) = (range, &kind) { + if let (Some((tid, a, b)), StyleKind::Text { style, .. }) = (range, &kind) { if let NodeKind::Text(td) = &mut doc.get_mut(tid)?.kind { td.link_text_style(a, b, style, &sid); } @@ -387,11 +427,13 @@ pub fn apply_style_ref(doc: &mut Document, id: NodeId, field: &str, sid: Option< n.grids = grids.clone(); n.styles.grid = Some(id.clone()); } - ("textStyle", Some(Style { kind: StyleKind::Text { style }, id, .. })) => { - // Every char takes the style's typography (their fills and links stay). + ("textStyle", Some(Style { kind: StyleKind::Text { style, paragraph }, id, .. })) => { + // Every char takes the style's typography (their fills and links stay), and the layer + // what the style sets at the paragraph level (#1829). if let NodeKind::Text(t) = &mut n.kind { let len = t.char_count(); t.link_text_style(0, len, style, id); + t.take_paragraph_style(paragraph); } n.styles.text = Some(id.clone()); } @@ -416,7 +458,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { let st = req_string(cmd, p, "style")?; let style = s.doc()?.style(&st).cloned().ok_or_else(|| bad(cmd, format!("no style `{st}`")))?; // Text styles go on chars: a range, the selected chars of the edited text, or whole layers. - if let StyleKind::Text { style: def } = &style.kind { + if let StyleKind::Text { style: def, paragraph } = &style.kind { // A text style is no property of other layers: they're left alone. let doc = s.doc()?; let targets: Vec = @@ -430,6 +472,8 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { Some((a, b)) => { if let NodeKind::Text(t) = &mut doc.get_mut(i)?.kind { t.link_text_style(a, b, def, &style.id); + // The list goes to the paragraphs touched; the rest is the layer's. + t.take_paragraph_style_in(a, b, paragraph); } } None => apply_style_ref(doc, i, "textStyle", Some(&style.id))?, @@ -506,7 +550,7 @@ fn update(s: &mut Session, p: &Value) -> Result { let cur = doc.styles.get(idx).map(|x| x.kind.type_name()).unwrap_or("paint"); Some(match cur { "paint" => StyleKind::Paint { paints: paints_of(v).ok_or_else(|| bad(cmd, "value: paints"))? }, - "text" => StyleKind::Text { style: text_style_json(cmd, v)? }, + "text" => StyleKind::Text { style: text_style_json(cmd, v)?, paragraph: Box::new(paragraph_style_json(cmd, v)?) }, "effect" => StyleKind::Effect { effects: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? }, _ => StyleKind::Grid { grids: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? }, }) @@ -651,22 +695,23 @@ fn rename_folder(s: &mut Session, p: &Value) -> Result { /// Re-apply style `sid` to every node (and text chars) linked to it. pub fn propagate_style(doc: &mut Document, sid: &str) { let text_def = match doc.style(sid).map(|s| &s.kind) { - Some(StyleKind::Text { style }) => Some(style.clone()), + Some(StyleKind::Text { style, paragraph }) => Some((style.clone(), paragraph.clone())), _ => None, }; let ids: Vec = doc.ids().collect(); for i in ids { let Some(n) = doc.node(i) else { continue }; - if let Some(def) = &text_def { + if let Some((def, paragraph)) = &text_def { if n.kind.text().is_some_and(|t| t.links_text_style(sid)) { let def = match doc.style(sid).map(|st| crate::style_vars::kind_for(doc, st, i).into_owned()) { - Some(StyleKind::Text { style }) => style, + Some(StyleKind::Text { style, .. }) => style, _ => def.clone(), }; if let Ok(n) = doc.get_mut(i) && let NodeKind::Text(t) = &mut n.kind { t.update_text_style(sid, &def); + t.update_paragraph_style(sid, paragraph); } } continue; @@ -695,7 +740,16 @@ pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) { .styles .iter() .filter_map(|s| match &s.kind { - StyleKind::Text { style } => Some((s.id.clone(), style.clone())), + StyleKind::Text { style, .. } => Some((s.id.clone(), style.clone())), + _ => None, + }) + .collect(); + // What each text style sets at the paragraph level (#1829); no variable binds those. + let paras: BTreeMap<&str, &ParagraphStyle> = doc + .styles + .iter() + .filter_map(|s| match &s.kind { + StyleKind::Text { paragraph, .. } => Some((s.id.as_str(), paragraph.as_ref())), _ => None, }) .collect(); @@ -707,7 +761,7 @@ pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) { // Styles with bound fields read as this layer sees them. let mut defs = std::borrow::Cow::Borrowed(&defs); for st in &varying { - if let StyleKind::Text { style } = crate::style_vars::kind_for(doc, st, id).into_owned() { + if let StyleKind::Text { style, .. } = crate::style_vars::kind_for(doc, st, id).into_owned() { defs.to_mut().insert(st.id.clone(), style); } } @@ -717,11 +771,23 @@ pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) { && t2.text_style_link() == Some(None) && let Some(def) = defs.get(sid) && t2.spans().iter().all(|(_, _, s)| s.shows_text_style(def)) + && paras.get(sid.as_str()).is_some_and(|ps| t2.shows_paragraph_style(ps)) { let len = t2.char_count(); t2.restyle(0, len, |s| s.style_id = Some(sid.clone())); } t2.check_text_styles(|sid| defs.get(sid)); + // Chars whose paragraphs left their style's list, and every char of a layer whose + // paragraph spacing, indent or features were edited, unlink too (#1829). + let gone: Vec<(usize, usize)> = t2 + .spans() + .iter() + .filter(|(a, b, s)| s.style_id.as_deref().is_some_and(|sid| paras.get(sid).is_none_or(|ps| !t2.shows_paragraph_style_in(*a, *b, ps)))) + .map(|(a, b, _)| (*a, *b)) + .collect(); + for (a, b) in gone { + t2.unlink_text_style(a, b); + } let link = t2.text_style_link().flatten().map(str::to_string); if t2 != *t || link != n.styles.text { updates.push((id, link, t2)); diff --git a/crates/engine/src/content/synthetic_ds.rs b/crates/engine/src/content/synthetic_ds.rs index 74558567f..be5268355 100644 --- a/crates/engine/src/content/synthetic_ds.rs +++ b/crates/engine/src/content/synthetic_ds.rs @@ -154,7 +154,7 @@ fn auto_layout(doc: &mut Document, id: NodeId, mode: LayoutMode, gap: f64, pad: fn styled_text(doc: &mut Document, parent: NodeId, chars: &str, name: &str, style: &str) -> DocResult { let id = doc.create(parent, text(chars), name, 0.0, 0.0, 60.0, 16.0)?; let def = doc.styles.iter().find(|s| s.id == style).and_then(|s| match &s.kind { - StyleKind::Text { style } => Some(style.clone()), + StyleKind::Text { style, .. } => Some(style.clone()), _ => None, }); set_node(doc, id, |n| { @@ -242,7 +242,7 @@ fn foundations(doc: &mut Document) -> (CollectionId, ModeId, ModeId) { id: format!("ds:text:{name}"), name: name.into(), description: String::new(), - kind: StyleKind::Text { style }, + kind: StyleKind::Text { style, paragraph: Default::default() }, bound: Default::default(), }); } diff --git a/crates/engine/src/fig_export_assets_tests.rs b/crates/engine/src/fig_export_assets_tests.rs index f5b9f7758..ef88bc680 100644 --- a/crates/engine/src/fig_export_assets_tests.rs +++ b/crates/engine/src/fig_export_assets_tests.rs @@ -122,6 +122,27 @@ fn variables_styles_and_instance_sizes_come_back() { assert!(after.iter().any(|l| l.contains("\"In the dark\"") && l.contains("FFFFFF")), "{after:#?}"); } +/// #1829: a text style's paragraph spacing and indent and its list style go through a `.fig` +/// file with it (the style's sample layer carries them, as a Figma file's does). +#[test] +fn a_text_styles_paragraph_values_come_back_from_a_fig_file() { + let mut s = Session::new(); + let t = id(&run(&mut s, "text.create", json!({"text": "One\nTwo", "x": 0, "y": 0}))); + run(&mut s, "node.set", json!({"ids": [t], "paragraphSpacing": 12, "paragraphIndent": 8, "listStyle": "bulleted"})); + run(&mut s, "style.create", json!({"type": "text", "name": "Audit", "from": t})); + let path = std::env::temp_dir().join(format!("figcraft-fig-text-style-{}.fig", std::process::id())); + run(&mut s, "file.export", json!({"format": "fig", "path": path.to_string_lossy()})); + run(&mut s, "file.open", json!({"path": path.to_string_lossy()})); + let _ = std::fs::remove_file(&path); + let doc = s.doc().unwrap(); + let st = doc.styles.iter().find(|x| x.name == "Audit").unwrap(); + let figcraft_doc::StyleKind::Text { paragraph, .. } = &st.kind else { panic!("{:?}", st.kind) }; + assert_eq!((paragraph.paragraph_spacing, paragraph.paragraph_indent, paragraph.list), (12.0, 8.0, figcraft_doc::ListStyle::Bulleted)); + // The layer the style was made from is linked to it again, showing the same values. + let layer = doc.nodes().find(|n| n.kind.text().is_some()).unwrap(); + assert_eq!(layer.styles.text.as_deref(), Some(st.id.as_str())); +} + #[test] fn every_example_keeps_its_variables_and_styles() { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples"); diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index 484e7b88e..e0538db67 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -54,7 +54,7 @@ pub fn missing(doc: &Document) -> Vec { } } for st in &doc.styles { - if let StyleKind::Text { style } = &st.kind { + if let StyleKind::Text { style, .. } = &st.kind { add(&style.font_family, false); } if let Some(var) = st.bound.get("fontFamily") { diff --git a/crates/engine/src/fonts/tests.rs b/crates/engine/src/fonts/tests.rs index 5df9a8540..fc9713535 100644 --- a/crates/engine/src/fonts/tests.rs +++ b/crates/engine/src/fonts/tests.rs @@ -37,7 +37,10 @@ fn lists_families_of_layers_ranges_styles_and_variables_once_each() { id: "S:1".into(), name: "Heading".into(), description: String::new(), - kind: StyleKind::Text { style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() } }, + kind: StyleKind::Text { + style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() }, + paragraph: Default::default(), + }, bound: Default::default(), }); s.execute("variable.createCollection", &json!({"name": "Type", "modes": ["Light", "Dark"]})).unwrap(); diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index e05dade6c..b5ddab8b0 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -520,7 +520,7 @@ fn sketch_shared_text_styles_match_their_layers() { .styles .iter() .find_map(|st| match &st.kind { - figcraft_doc::StyleKind::Text { style } if st.name == "Body" => Some(style.clone()), + figcraft_doc::StyleKind::Text { style, .. } if st.name == "Body" => Some(style.clone()), _ => None, }) .unwrap(); diff --git a/crates/engine/src/styles_behaviour_tests.rs b/crates/engine/src/styles_behaviour_tests.rs index de9da5a5d..f1ebd96de 100644 --- a/crates/engine/src/styles_behaviour_tests.rs +++ b/crates/engine/src/styles_behaviour_tests.rs @@ -5,10 +5,11 @@ //! line; "docs silent" marks behaviour the documentation doesn't describe (pinned as it is). use serde_json::{Value, json}; +use std::collections::BTreeMap; use crate::Session; use crate::params::id_of; -use figcraft_doc::{Node, NodeId}; +use figcraft_doc::{ListStyle, Node, NodeId, ParagraphStyle, StyleKind, TextData}; fn id(v: Value) -> NodeId { id_of(&v["id"]).unwrap() @@ -355,6 +356,68 @@ fn text_styles_go_on_text_layers_and_chars() { assert_eq!(tx.styles.text, None, "mixed: the layer isn't linked as a whole"); } +#[test] +fn text_styles_carry_paragraph_spacing_indent_lists_and_features() { + // "Supported in text styles": "Paragraph spacing and indentation", "Lists" and "OpenType + // features", beside the character properties (Create and apply text styles). A style made + // from a text takes them, applying it sets them, editing one on a styled text unlinks it. + let mut s = Session::new(); + let td = |s: &Session, n: NodeId| -> TextData { node(s, n).kind.text().unwrap().clone() }; + let t = text(&mut s, 0.0); + let props = json!({"characters": "One\nTwo", "paragraphSpacing": 12, "paragraphIndent": 8, "listStyle": "bulleted", "features": {"tnum": true, "liga": false}}); + run(&mut s, "node.set", json!({"id": t, "props": props})); + let st = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Audit", "from": t}))); + let listed = run(&mut s, "style.list", json!({"type": "text"})); + let audit = listed.as_array().unwrap().iter().find(|x| x["id"] == json!(st)).unwrap(); + assert_eq!( + audit["paragraph"], + json!({"paragraph_spacing": 12.0, "paragraph_indent": 8.0, "list": "bulleted", "features": {"liga": false, "tnum": true}}) + ); + // Applying it sets them on a plain text, every paragraph in the list; the layer links. + let t2 = text(&mut s, 300.0); + run(&mut s, "node.set", json!({"id": t2, "props": {"characters": "A\nB\nC"}})); + run(&mut s, "style.apply", json!({"ids": [t2], "style": st})); + let d = td(&s, t2); + assert_eq!((d.paragraph_spacing, d.paragraph_indent), (12.0, 8.0)); + assert!((0..3).all(|p| d.para_list(p).list == ListStyle::Bulleted), "{:?}", d.paragraphs); + assert_eq!(d.features, BTreeMap::from([("liga".to_string(), false), ("tnum".to_string(), true)])); + assert_eq!(node(&s, t2).styles.text.as_deref(), Some(st.as_str())); + // Redefining the style moves its layers' paragraph values with the rest. + let value = json!({"fontSize": 22, "paragraphSpacing": 20, "listStyle": "numbered", "openTypeFeatures": {"tnum": true}}); + run(&mut s, "style.update", json!({"style": st, "value": value})); + let d = td(&s, t2); + assert_eq!((d.paragraph_spacing, d.paragraph_indent, d.para_list(1).list), (20.0, 0.0, ListStyle::Numbered)); + assert_eq!(d.features, BTreeMap::from([("tnum".to_string(), true)])); + assert_eq!(font_size(&s, t2), 22.0); + assert_eq!(node(&s, t2).styles.text.as_deref(), Some(st.as_str()), "still linked after the update"); + // Editing a paragraph value the style sets unlinks the text, as editing its font does. + run(&mut s, "node.set", json!({"id": t2, "props": {"paragraphSpacing": 5}})); + assert_eq!(node(&s, t2).styles.text, None, "paragraph spacing edited: unlinked"); + assert!(td(&s, t2).spans().iter().all(|(_, _, x)| x.style_id.is_none())); + // On a range, the list goes to the paragraphs touched; the spacing is the layer's. Taking + // the paragraph out of the style's list unlinks its chars. + let t3 = text(&mut s, 600.0); + run(&mut s, "node.set", json!({"id": t3, "props": {"characters": "A\nB\nC"}})); + run(&mut s, "style.apply", json!({"ids": [t3], "style": st, "start": 0, "end": 1})); + let d = td(&s, t3); + assert_eq!((d.para_list(0).list, d.para_list(1).list, d.paragraph_spacing), (ListStyle::Numbered, ListStyle::None, 20.0)); + assert_eq!(d.style_at(0).style_id.as_deref(), Some(st.as_str())); + run(&mut s, "node.set", json!({"id": t3, "props": {"start": 0, "end": 1, "listStyle": "none"}})); + assert_eq!(td(&s, t3).style_at(0).style_id, None, "its paragraph left the style's list"); + // A definition given as a value takes the node.set spellings; junk is refused. + let value = json!({"fontSize": 11, "paragraphSpacing": 4, "listStyle": "bulleted", "features": {"liga": false}}); + let st2 = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Note", "value": value}))); + let want = ParagraphStyle { + paragraph_spacing: 4.0, + list: ListStyle::Bulleted, + features: BTreeMap::from([("liga".to_string(), false)]), + ..Default::default() + }; + assert!(matches!(&s.doc().unwrap().style(&st2).unwrap().kind, StyleKind::Text { paragraph, .. } if **paragraph == want)); + assert!(s.execute("style.create", &json!({"type": "text", "name": "Bad", "value": {"listStyle": "dotted"}})).is_err()); + assert!(s.execute("style.create", &json!({"type": "text", "name": "Bad", "value": {"paragraphSpacing": "wide"}})).is_err()); +} + #[test] fn styles_are_organised_by_slash_names_order_and_duplicates() { // Documented: "/" in a name groups styles in folders; moving a style to another folder renames diff --git a/crates/engine/src/text_style_picker_tests.rs b/crates/engine/src/text_style_picker_tests.rs index 6ee8cc2c3..733845a52 100644 --- a/crates/engine/src/text_style_picker_tests.rs +++ b/crates/engine/src/text_style_picker_tests.rs @@ -86,7 +86,7 @@ fn editing_text_applies_to_the_selected_chars() { s.execute("text.style", &json!({"id": t, "start": 6, "end": 11, "fontSize": 20})).unwrap(); let c = s.execute("style.create", &json!({"type": "text", "name": "Type/Caption"})).unwrap()["id"].as_str().unwrap().to_string(); let style = s.doc().unwrap().style(&c).cloned().unwrap(); - let StyleKind::Text { style: def } = style.kind else { panic!("not a text style") }; + let StyleKind::Text { style: def, .. } = style.kind else { panic!("not a text style") }; assert_eq!(def.font_size, 20.0); assert_eq!(def.style_id, None, "definitions link to nothing"); assert_eq!(links(&s, t)[8].as_deref(), Some(c.as_str())); @@ -117,7 +117,7 @@ fn edits_unlink_and_style_edits_follow() { assert_eq!(layer_link(&s, a).as_deref(), Some(h.as_str())); // Redefining the style moves linked chars only. let mut def = match &s.doc().unwrap().style(&h).unwrap().kind { - StyleKind::Text { style } => style.clone(), + StyleKind::Text { style, .. } => style.clone(), _ => panic!("not a text style"), }; def.font_size = 40.0; diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs index ce4192008..36c70a687 100644 --- a/crates/format/src/fig/assets_tests.rs +++ b/crates/format/src/fig/assets_tests.rs @@ -225,7 +225,7 @@ fn same_after_round_trip(doc: &Document, what: &str) -> Vec { normalize(&mut n, &|i| i); *paints = n.fills; } - StyleKind::Text { style } => { + StyleKind::Text { style, .. } => { if let NodeKind::Text(td) = &mut n.kind { td.style = style.clone(); } @@ -444,7 +444,7 @@ fn styled() -> Document { bound: Default::default(), }; doc.styles = vec![ - style("s-text", "Heading", StyleKind::Text { style: heading.clone() }), + style("s-text", "Heading", StyleKind::Text { style: heading.clone(), paragraph: Default::default() }), style("s-brand", "Brand/Primary", StyleKind::Paint { paints: vec![solid(0.25, 0.5, 1.0)] }), style("s-unused", "Brand/Unused", StyleKind::Paint { paints: vec![solid(1.0, 0.0, 0.0), solid(0.0, 1.0, 0.0)] }), style("s-shadow", "Elevation/1", StyleKind::Effect { effects: vec![shadow.clone()] }), diff --git a/crates/format/src/fig/export/assets.rs b/crates/format/src/fig/export/assets.rs index 3874b073c..f1df1874d 100644 --- a/crates/format/src/fig/export/assets.rs +++ b/crates/format/src/fig/export/assets.rs @@ -159,7 +159,7 @@ impl<'d> Exporter<'d> { Bound::default() } else { let mut sample = Node::new(NodeId(0), NodeKind::Rectangle, "", 0.0, 0.0); - if let StyleKind::Text { style } = &st.kind { + if let StyleKind::Text { style, .. } = &st.kind { sample.kind = NodeKind::Text(TextData { style: style.as_text_style(), ..TextData::new("Ag") }); } sample.bound.clone_from(&st.bound); @@ -179,8 +179,9 @@ impl<'d> Exporter<'d> { let list = grids.iter().enumerate().map(|(i, g)| layout_grid(g, i, &none)).collect(); f.extend([("fillPaints", arr(Vec::new())), ("layoutGrids", arr(list))]); } - StyleKind::Text { style } => { - let td = TextData { style: style.as_text_style(), ..TextData::new("Ag") }; + StyleKind::Text { style, paragraph } => { + let mut td = TextData { style: style.as_text_style(), ..TextData::new("Ag") }; + td.take_paragraph_style(paragraph); let fills = [Paint::solid(Color::BLACK)]; f.push(("fillPaints", self.paints(style.fills.as_deref().unwrap_or(&fills), true, &none))); self.text(&td, &fills, &mut f); diff --git a/crates/format/src/fig/text_skip_ink_tests.rs b/crates/format/src/fig/text_skip_ink_tests.rs index 485663847..b5285e1b6 100644 --- a/crates/format/src/fig/text_skip_ink_tests.rs +++ b/crates/format/src/fig/text_skip_ink_tests.rs @@ -113,7 +113,7 @@ fn local_skip_ink_override_preserves_shared_style_and_omitted_value_inherits() { let node = doc.nodes().find(|n| n.name == "Label").unwrap(); assert_eq!(node.kind.text().unwrap().style.look().skip_ink, expected); let linked = doc.style(node.styles.text.as_deref().unwrap()).unwrap(); - let StyleKind::Text { style } = &linked.kind else { panic!("linked definition must remain text") }; + let StyleKind::Text { style, .. } = &linked.kind else { panic!("linked definition must remain text") }; assert!(style.look().skip_ink, "local value must not alter definition"); } } @@ -223,7 +223,7 @@ fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only id: "body".into(), name: "Body".into(), description: String::new(), - kind: StyleKind::Text { style: shared }, + kind: StyleKind::Text { style: shared, paragraph: Default::default() }, bound: Default::default(), }); let id = linked_doc @@ -237,7 +237,7 @@ fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only let node = back.nodes().find(|n| n.name == "Label").unwrap(); assert!(!node.kind.text().unwrap().style.look().skip_ink); let linked = back.style(node.styles.text.as_deref().unwrap()).unwrap(); - let StyleKind::Text { style } = &linked.kind else { panic!("shared style link must remain text") }; + let StyleKind::Text { style, .. } = &linked.kind else { panic!("shared style link must remain text") }; assert!(style.look().skip_ink, "local false must not alter the shared definition"); } diff --git a/crates/format/src/fig/text_style_tests.rs b/crates/format/src/fig/text_style_tests.rs index 0f08459e6..dadd052c1 100644 --- a/crates/format/src/fig/text_style_tests.rs +++ b/crates/format/src/fig/text_style_tests.rs @@ -85,7 +85,7 @@ fn seen(doc: &Document) -> Seen { assert_eq!((text.style.font_family.as_str(), text.style.font_size), ("Inter", 20.0)); let linked = doc.style(label.styles.text.as_deref().unwrap()).unwrap(); assert_eq!(linked.name, "Shared"); - let StyleKind::Text { style } = &linked.kind else { panic!("the linked definition must stay a text style") }; + let StyleKind::Text { style, .. } = &linked.kind else { panic!("the linked definition must stay a text style") }; Seen { case: text.style.case, decoration: text.style.decoration, diff --git a/crates/format/src/penpot.rs b/crates/format/src/penpot.rs index 6cd8fe53c..a0e50a067 100644 --- a/crates/format/src/penpot.rs +++ b/crates/format/src/penpot.rs @@ -1531,7 +1531,7 @@ fn import_typography_styles( id: format!("penpot:text:{base}:{id}"), name: name.chars().take(256).collect(), description: "Imported from Penpot typography library".into(), - kind: StyleKind::Text { style }, + kind: StyleKind::Text { style, paragraph: Default::default() }, bound: BTreeMap::new(), }); } @@ -1596,7 +1596,7 @@ fn composite_token_style(token: &Value) -> Option { } } let parsed = text_style(&Value::Object(fields), &style); - Some(StyleKind::Text { style: parsed }) + Some(StyleKind::Text { style: parsed, paragraph: Default::default() }) } "boxShadow" | "shadow" => { let parts: Vec<&Value> = if let Some(items) = value.as_array() { items.iter().take(32).collect() } else { vec![value] }; @@ -5927,7 +5927,7 @@ mod tests { let (doc, warnings) = import(&archive_with_design_system(tokens, None, None, None)).unwrap(); assert_eq!(doc.styles.len(), 2); assert!(doc.styles.iter().any(|s| s.name == "Penpot tokens/Core/title" - && matches!(&s.kind, StyleKind::Text { style } + && matches!(&s.kind, StyleKind::Text { style, .. } if style.font_family == "Spicy Rice" && style.font_size == 24.0 && style.line_height == LineHeight::Pixels(32.0)))); assert!(doc.styles.iter().any(|s| s.name == "Penpot tokens/Core/elevation" && matches!(&s.kind, StyleKind::Effect { effects } @@ -5983,10 +5983,8 @@ mod tests { let (doc, warnings) = import(&archive_with_design_system(serde_json::json!({"sets":{}}), Some(typ), Some(color), None)).unwrap(); assert!(warnings.is_empty(), "{warnings:?}"); assert_eq!(doc.styles.len(), 2); - assert!( - doc.styles.iter().any(|s| s.name == "Brand/Heading" - && matches!(&s.kind, StyleKind::Text { style } if style.font_family == "Inter" && style.font_size == 24.0)) - ); + assert!(doc.styles.iter().any(|s| s.name == "Brand/Heading" + && matches!(&s.kind, StyleKind::Text { style, .. } if style.font_family == "Inter" && style.font_size == 24.0))); assert!(doc.styles.iter().any(|s| s.name == "Brand/Primary" && matches!(s.kind, StyleKind::Paint { .. }))); assert_eq!(doc.document_colors.len(), 1); } diff --git a/crates/format/src/rest.rs b/crates/format/src/rest.rs index 4604b73a6..eb9a10112 100644 --- a/crates/format/src/rest.rs +++ b/crates/format/src/rest.rs @@ -913,7 +913,11 @@ impl<'a> Ctx<'a> { Some("FILL") => StyleKind::Paint { paints: props::paints(self, node, "fills").unwrap_or_default() }, Some("EFFECT") => StyleKind::Effect { effects: props::effects(self, node) }, Some("GRID") => StyleKind::Grid { grids: props::layout_grids(node) }, - Some("TEXT") => StyleKind::Text { style: text::text_data(self, node).style.as_text_style() }, + Some("TEXT") => { + // The sample layer carries the paragraph-level values too (#1829). + let td = text::text_data(self, node); + StyleKind::Text { style: td.style.as_text_style(), paragraph: Box::new(td.paragraph_style()) } + } _ => continue, }; let name = text(d, "name").filter(|s| !s.is_empty()).map_or_else(|| format!("Style {id}"), |s| clip_str(s).to_string()); @@ -955,7 +959,9 @@ impl<'a> Ctx<'a> { "effect" | "effects" => (StyleKind::Effect { effects: n.effects.clone() }, &mut n.styles.effect), "grid" | "grids" => (StyleKind::Grid { grids: n.grids.clone() }, &mut n.styles.grid), "text" => match &n.kind { - NodeKind::Text(t) => (StyleKind::Text { style: t.style.as_text_style() }, &mut n.styles.text), + NodeKind::Text(t) => { + (StyleKind::Text { style: t.style.as_text_style(), paragraph: Box::new(t.paragraph_style()) }, &mut n.styles.text) + } _ => continue, }, _ => continue, diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index 050dcbaa6..43c9db6f2 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -530,7 +530,7 @@ fn shared_styles_link_with_metadata_names() { let fill = doc.style("S:1").unwrap(); assert_eq!((fill.name.as_str(), fill.description.as_str()), ("Brand/Primary", "Main")); assert!(matches!(&fill.kind, StyleKind::Paint { paints } if paints.len() == 1)); - assert!(matches!(&doc.style("S:2").unwrap().kind, StyleKind::Text { style } if style.font_size == 32.0)); + assert!(matches!(&doc.style("S:2").unwrap().kind, StyleKind::Text { style, .. } if style.font_size == 32.0)); assert!(matches!(&doc.style("Soft").unwrap().kind, StyleKind::Effect { effects } if effects.len() == 1)); let r = named(&doc, "R"); assert_eq!((r.styles.fill.as_deref(), r.styles.effect.as_deref()), (Some("S:1"), Some("S:4"))); diff --git a/crates/format/src/sketch/shared_tests.rs b/crates/format/src/sketch/shared_tests.rs index 240fdb7ea..4636c652e 100644 --- a/crates/format/src/sketch/shared_tests.rs +++ b/crates/format/src/sketch/shared_tests.rs @@ -91,7 +91,7 @@ fn text_style_links() { let sid = n.styles.text.clone().expect("text style linked"); let s = style(&doc, &sid); assert_eq!(s.name, "Heading/H1"); - let StyleKind::Text { style: ts } = &s.kind else { panic!("not a text style") }; + let StyleKind::Text { style: ts, .. } = &s.kind else { panic!("not a text style") }; assert_eq!((ts.font_family.as_str(), ts.font_style.as_str(), ts.font_size), ("Helvetica", "Bold", 32.0)); assert!(n.fills.iter().any(|p| matches!(p.kind, PaintKind::Solid { color } if color.r == 1.0 && color.g == 0.0))); } diff --git a/crates/ui-egui/src/widgets/style_picker.rs b/crates/ui-egui/src/widgets/style_picker.rs index eb2a2b44b..f616ddec7 100644 --- a/crates/ui-egui/src/widgets/style_picker.rs +++ b/crates/ui-egui/src/widgets/style_picker.rs @@ -10,7 +10,9 @@ use egui::{Align2, Color32, CornerRadius, Id, Rect, Response, Sense, Stroke, Ui, pos2, vec2}; use figcraft_color::{Paint, PaintKind}; -use figcraft_doc::{Document, GridKind, Node, NodeId, PublishedLibrary, Style, StyleKind, TextStyle, VariableType, trim_num}; +use figcraft_doc::{ + Document, GridKind, ListStyle, Node, NodeId, ParagraphStyle, PublishedLibrary, Style, StyleKind, TextStyle, VariableType, trim_num, +}; use figcraft_engine::library; use serde_json::{Value, json}; @@ -354,7 +356,7 @@ fn by_folder<'a>(styles: impl Iterator) -> Vec<(&'a str, Vec<& /// A text style's definition, if `s` is one. fn text_def(s: &Style) -> Option<&TextStyle> { match &s.kind { - StyleKind::Text { style } => Some(style), + StyleKind::Text { style, .. } => Some(style), _ => None, } } @@ -974,6 +976,39 @@ fn text_properties(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, if let Some(sr) = style_resp { font_style_menu(id, def, &sr, out); } + if let StyleKind::Text { paragraph, .. } = &style.kind { + paragraph_rows(ui, paragraph); + } +} + +/// What a text style sets at the paragraph level (#1829), when it sets anything: the spacing, +/// indent, list style and OpenType features (as `+tnum -liga`). A layer's Type settings edit +/// them; Update from selection takes them into the style. +fn paragraph_rows(ui: &mut Ui, ps: &ParagraphStyle) { + let mut rows: Vec = Vec::new(); + if ps.paragraph_spacing != 0.0 { + let label = tr("Paragraph spacing"); + rows.push(format!("{label}: {}", trim_num(ps.paragraph_spacing))); + } + if ps.paragraph_indent != 0.0 { + let label = tr("Paragraph indent"); + rows.push(format!("{label}: {}", trim_num(ps.paragraph_indent))); + } + match ps.list { + ListStyle::None => {} + ListStyle::Bulleted => rows.push(tr("Bulleted list").into()), + ListStyle::Numbered => rows.push(tr("Numbered list").into()), + } + if !ps.features.is_empty() { + rows.push(ps.features.iter().map(|(tag, on)| format!("{}{tag}", if *on { '+' } else { '-' })).collect::>().join(" ")); + } + if rows.is_empty() { + return; + } + ui.add_space(4.0); + for row in rows { + caption(ui, &row); + } } /// Editing one style: name, description, its values (paints open the colour picker; text styles @@ -1038,7 +1073,7 @@ fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) { binding_cell(ui, doc, &style, &format!("layoutGrids/{i}/size"), figcraft_doc::VariableType::Number, out); } } - StyleKind::Text { style: def } => text_properties(ui, doc, &style, def, out), + StyleKind::Text { style: def, .. } => text_properties(ui, doc, &style, def, out), } }); ui.add_space(8.0); diff --git a/crates/ui-egui/src/widgets/style_picker/tests.rs b/crates/ui-egui/src/widgets/style_picker/tests.rs index fa996374c..0f3392e48 100644 --- a/crates/ui-egui/src/widgets/style_picker/tests.rs +++ b/crates/ui-egui/src/widgets/style_picker/tests.rs @@ -29,7 +29,7 @@ fn names_split_into_folders() { for s in [StyleSlot::Fill, StyleSlot::Stroke, StyleSlot::Effect, StyleSlot::Grid, StyleSlot::Text] { assert_eq!(StyleSlot::parse(s.key()), Some(s)); } - assert!(StyleSlot::Text.holds(&StyleKind::Text { style: Default::default() }) && StyleSlot::Text.has_grid()); + assert!(StyleSlot::Text.holds(&StyleKind::Text { style: Default::default(), paragraph: Default::default() }) && StyleSlot::Text.has_grid()); } fn frame(ctx: &egui::Context, app: &mut FigApp, t: &mut f64) { @@ -255,7 +255,7 @@ fn commit_text_field(def: &TextStyle, field: &str, typed: &str) -> Option = texts.iter().filter(|(_, p)| p.x > left && p.x < left + W).cloned().collect(); @@ -434,7 +434,7 @@ fn text_style_editor_binds_variables_from_its_fields() { assert_eq!(bound_to(&u, field).as_deref(), Some(name)); } let doc = u.app.session.doc().unwrap(); - let Some(StyleKind::Text { style: def }) = doc.style("Body").map(|s| &s.kind) else { panic!("a text style") }; + let Some(StyleKind::Text { style: def, .. }) = doc.style("Body").map(|s| &s.kind) else { panic!("a text style") }; assert_eq!((def.font_size, def.font_style.as_str()), (18.0, "Bold"), "the style takes the variables' values"); assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); } diff --git a/docs/components-parity.md b/docs/components-parity.md index 0e3248248..7fce63d14 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 @@ -59,7 +59,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Feature | Status | Notes | |---|---|---| -| Paint, text, effect, grid styles; style picker; text styles on character ranges | done | behaviour suite: `styles_behaviour_tests.rs` | +| Paint, text, effect, grid styles; style picker; text styles on character ranges | done | behaviour suite: `styles_behaviour_tests.rs`; a text style carries paragraph spacing and indent, the list style and the OpenType features beside the character properties (`paragraph` next to `style` in `style.list`): applied with it, moved by `style.update`, unlinking when edited, kept by `.fig` files (#1829) | | Editing a style updates its layers; detach and delete keep values; editing a styled property unlinks that style | done | an edited fill/stroke/effect/grid list or text chars unlink; applying a style replaces variables bound to what it sets; text styles on text layers only | | Organising styles: folders by `/` names, order, duplicate, rename folder, Local styles list | done | `style.move` (into a folder renames), `style.duplicate` (right below), `style.renameFolder`; the page's Local styles list by type and folder (drag to reorder, right-click) | | Variables inside styles (paint, effect, grid and text styles; each user resolves them in its own modes) | done | `style.bind`, `style_vars_tests`; the editor binds paint colours, shadow colours, blur radii, grid sizes and a text style's font, font style, size, line height and letter spacing, the rest through the command | @@ -78,6 +78,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | M3, #1829: text styles carry paragraph spacing and indent, the list style and the OpenType features ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType features", Create and apply text styles) beside the character properties: `style.create` takes them from the layer (or the selected paragraphs' list) or from a `value` (`paragraphSpacing`, `paragraphIndent`, `listStyle`, `features`), `style.apply` sets them (on a range, the list goes to the paragraphs touched; the rest is the layer's, as in Figma), `style.update` moves them on every linked layer, editing one on a styled text unlinks it as editing its font does, the style editor lists them, and a `.fig` file keeps them with the style (its sample layer). Grade unchanged. | | 2026-11-01 | patch | M3: Delete on a layer inside an instance hides it (an override) instead of being refused ("You can't delete a layer or object from an instance. If you try, Figma will only toggle the layer's visibility instead of removing it", Guide to auto layout); `edit.delete` reports those ids under `hidden`, the structure and the main component stay as they were, one undo step; deleting the instance itself still removes it. Found by a documentation audit of the auto layout page. Test: `components_behaviour_tests::deleting_a_layer_inside_an_instance_hides_it_instead` (the refusal row for it is gone from `what_instances_cannot_override_is_refused`). Numbers unchanged | | 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. | | 2026-10-31 | minor | M6: as the help center describes code syntax, Dev Mode's SwiftUI and Compose code now writes a bound variable's iOS or Android code syntax in place of its value, as CSS already wrote the Web one: fills, strokes and their weight, sizes, corner radius, gaps, padding, opacity, the font family and size and the characters. A variable with no code syntax for the platform keeps the value, since a name made up here wouldn't exist in the code it lands in. ArkUI and Flutter have no code syntax platform and are unchanged. Test: `variables_behaviour_tests::swiftui_and_compose_name_variables_by_their_code_syntax`. Numbers unchanged. Grade unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index b5e49e749..8344f4972 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -312,10 +312,16 @@ Or in a project's `.mcp.json`: `style.detach {which: "text", start?, end?}` unlinks them (values stay). A layer's `styles.text` is the style all its chars share (none when they differ); `get_node` lists the linked chars as `textStyleRanges` (`[start, end, style id]`). + A text style also carries paragraph spacing and indent, the list style and the OpenType features + (`paragraph` beside `style` in `style.list`): applying it sets them (on a range, the list goes to + the paragraphs touched; the rest is the layer's), `style.update` moves them too, and a `.fig` + file keeps them with the style. Changing what a text style sets (font, size, line height, letter spacing, decoration, case, axis - values) on linked chars unlinks them; `style.update` moves every linked char. `style.create - {type: "text", name}` takes the selected chars while editing; a `value` may use the `node.set` - spellings (`{"fontSize": 32, "lineHeight": "120%", "letterSpacing": "2%"}`). + values; at the paragraph level the spacing, indent, list and features) on linked chars unlinks + them; `style.update` moves every linked char. `style.create {type: "text", name}` takes the + selected chars while editing; a `value` may use the `node.set` spellings (`{"fontSize": 32, + "lineHeight": "120%", "letterSpacing": "2%", "paragraphSpacing": 12, "listStyle": "bulleted", + "features": {"liga": false}}`). - **Styles stay true to their layers**: editing a styled fill, stroke, effect or layout grid list (`node.set`, `fill.set`, `fill.add`, a bound variable…) unlinks that style (`styles.fill` etc. go `null`), so a later `style.update` doesn't overwrite the edit; `style.apply` replaces variables From 0d1801db937678756d09b2f2e72990e47b7fcb3e Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Thu, 5 Nov 2026 17:21:18 -0500 Subject: [PATCH 2/4] text styles: applying one to the whole layer reaches a trailing empty paragraph take_paragraph_style went through para_span, which leaves out the empty paragraph after a final newline, so its list style stayed as it was and the layer did not fold back to one list. The whole-layer path now addresses every paragraph by index; para_span's selection semantics are unchanged. Co-Authored-By: Claude Fable 5.1 --- crates/doc/src/text.rs | 2246 ++++++++++++++++++++-------------------- 1 file changed, 1137 insertions(+), 1109 deletions(-) diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index d674adb6d..f4d087589 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -1,1109 +1,1137 @@ -//! Text node data: characters plus a base style and styled runs. - -use figcraft_color::{Color, Paint}; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -#[serde(tag = "unit", content = "value", rename_all = "camelCase")] -#[derive(Default)] -pub enum LineHeight { - #[default] - Auto, - Pixels(f64), - Percent(f64), -} - -impl LineHeight { - /// Resolved line height in px for `font_size` (`auto` uses the font's natural ratio). - pub fn resolve(self, font_size: f64, natural_ratio: f64) -> f64 { - match self { - LineHeight::Auto => font_size * natural_ratio, - LineHeight::Pixels(v) => v.max(0.0), - LineHeight::Percent(p) => font_size * p.max(0.0) / 100.0, - } - } - pub fn label(self) -> String { - match self { - LineHeight::Auto => "Auto".into(), - LineHeight::Pixels(v) => trim_num(v), - LineHeight::Percent(p) => format!("{}%", trim_num(p)), - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -#[serde(tag = "unit", content = "value", rename_all = "camelCase")] -pub enum LetterSpacing { - Pixels(f64), - Percent(f64), -} - -impl Default for LetterSpacing { - fn default() -> Self { - LetterSpacing::Percent(0.0) - } -} - -impl LetterSpacing { - pub fn resolve(self, font_size: f64) -> f64 { - match self { - LetterSpacing::Pixels(v) => v, - LetterSpacing::Percent(p) => font_size * p / 100.0, - } - } - pub fn label(self) -> String { - match self { - LetterSpacing::Pixels(v) => trim_num(v), - LetterSpacing::Percent(p) => format!("{}%", trim_num(p)), - } - } -} - -pub fn trim_num(v: f64) -> String { - if v.abs() >= 9.0e18 { - return format!("{v}"); - } - let r = (v * 100.0).round() / 100.0; - if r.fract().abs() < 1e-9 { format!("{}", r as i64) } else { format!("{r}") } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum TextDecoration { - #[default] - None, - Underline, - Strikethrough, -} - -/// How an underline or strikethrough is drawn. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum DecorationStyle { - #[default] - Solid, - Dotted, - Wavy, -} - -/// A decoration length: `Auto` (the font's own value), pixels, or a percentage of the font size. -pub type DecorationLength = LineHeight; - -/// How a decoration line looks; the default is the font's own solid line in the text's colour. -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct DecorationLook { - pub style: DecorationStyle, - pub thickness: DecorationLength, - /// Underlines: from the baseline down to the line's top edge (negative: above the baseline). - pub offset: DecorationLength, - /// Underlines break where descenders cross them. - pub skip_ink: bool, - /// The line's own colour (None: the text's fills). - pub color: Option, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum TextCase { - #[default] - Original, - Upper, - Lower, - Title, - SmallCaps, -} - -impl TextCase { - pub fn apply(self, s: &str) -> String { - self.apply_after(s, None) - } - - /// Apply the case to `s`, which follows `prev` in the text (so title case only capitalises - /// the first letter of each word even when a word spans several styled runs). An apostrophe - /// as `prev` is taken to continue a word. - pub fn apply_after(self, s: &str, prev: Option) -> String { - self.apply_impl(s, prev, prev.is_some_and(is_word_char)) - } - - /// Like [`apply_after`](Self::apply_after), also given the char `before` `prev`: an apostrophe - /// only continues a word when a letter or digit precedes it. - pub fn apply_between(self, s: &str, before: Option, prev: Option) -> String { - let continues = prev.is_some_and(|c| is_word_char(c) && (c.is_alphanumeric() || before.is_some_and(char::is_alphanumeric))); - self.apply_impl(s, prev, continues) - } - - /// `in_word` says whether `prev` continues a word (rather than a new word starting here). - fn apply_impl(self, s: &str, prev: Option, in_word: bool) -> String { - match self { - TextCase::Original | TextCase::SmallCaps => s.to_string(), - TextCase::Upper => s.to_uppercase(), - TextCase::Lower => s.to_lowercase(), - TextCase::Title => { - let mut out = String::with_capacity(s.len()); - let mut start = !in_word; - let mut last = prev; - for ch in s.chars() { - if start && ch.is_alphanumeric() { - out.extend(ch.to_uppercase()); - start = false; - } else { - out.push(ch); - // An apostrophe only continues a word right after a letter or digit. - let opening_apostrophe = - matches!(ch, '\'' | '\u{2019}') && !last.is_some_and(|c| c.is_alphanumeric() || is_combining_mark(c)); - // A combining mark attaches to the char before it and never changes word state. - if !is_combining_mark(ch) { - start = !is_word_char(ch) || opening_apostrophe; - } - } - last = Some(ch); - } - out - } - } - } - - /// Whether the char at a position preceded by `prev` starts a word (for title case). - pub fn starts_word(prev: Option) -> bool { - prev.is_none_or(|c| !is_word_char(c)) - } -} - -/// Letters, digits, combining marks and apostrophes continue a word (so "don't" stays "Don't" -/// and decomposed accents stay inside their word). -fn is_word_char(c: char) -> bool { - c.is_alphanumeric() || is_combining_mark(c) || matches!(c, '\'' | '\u{2019}') -} - -/// Combining marks such as the U+0301 accent in decomposed text: non-alphanumeric chars that -/// extend the grapheme of the char before them. -fn is_combining_mark(c: char) -> bool { - use unicode_segmentation::UnicodeSegmentation; - if c.is_ascii() || c.is_alphanumeric() { - return false; - } - let mut buf = [0u8; 8]; - buf[0] = b'a'; - let end = 1 + c.encode_utf8(&mut buf[1..]).len(); - std::str::from_utf8(&buf[..end]).is_ok_and(|s| s.graphemes(true).count() == 1) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum TextAlignH { - #[default] - Left, - Center, - Right, - Justified, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum TextAlignV { - #[default] - Top, - Middle, - Bottom, -} - -/// How a text box resizes as its content changes. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum TextAutoResize { - /// Auto width: one line per paragraph, box grows both ways. - #[default] - WidthAndHeight, - /// Fixed width, height grows. - Height, - /// Fixed size. - None, - /// Fixed size, truncate with an ellipsis. - Truncate, -} - -/// Vertical trim: what bounds a text box vertically. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum LeadingTrim { - /// The full line height of the first and last lines. - #[default] - None, - /// From the first line's cap height to the last line's baseline. - CapHeight, -} - -/// Where lines break when text wraps to more than one line ("Wrap style controls where lines -/// break when text wraps to more than one line", Explore text properties). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum WrapStyle { - /// "Figma fits as many words as possible on each line before wrapping to the next." - #[default] - Auto, - /// "Figma distributes text as evenly as possible across every line, so each line is close to - /// the same length." - Balance, - /// "Figma keeps each line full, but adjusts the last four lines to avoid an orphan -- a single - /// word stranded alone on the last line." - Pretty, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum ListStyle { - #[default] - None, - Bulleted, - Numbered, -} - -/// Deepest list nesting (levels 0..=MAX_LIST_INDENT). -pub const MAX_LIST_INDENT: u8 = 8; - -/// One paragraph's list settings. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct ParaList { - pub list: ListStyle, - /// Nesting level (0 = outermost). A plain paragraph is 0, except one that left its list by - /// Backspace or Delete at its start ([`TextData::unlist_keeping_levels`]): it keeps the level - /// it had and starts where that level's item text does ("delete the counter, but keep the - /// same level of indentation"). - pub indent: u8, - /// A numbered list that starts counting at this paragraph counts from `start_offset + 1` - /// (items that go on counting ignore it; other paragraphs are always 0). - #[serde(skip_serializing_if = "crate::node::is_default")] - pub start_offset: u32, -} - -/// Character style. In runs, every field is an override; `None` means "inherit the base". -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct TextStyle { - pub font_family: String, - /// The style name, e.g. "Regular", "Bold", "Semi Bold Italic". - pub font_style: String, - pub font_size: f64, - pub line_height: LineHeight, - pub letter_spacing: LetterSpacing, - pub decoration: TextDecoration, - /// How the decoration looks (None: the default look; boxed to keep styles small). - #[serde(skip_serializing_if = "Option::is_none")] - pub decoration_look: Option>, - pub case: TextCase, - /// Run fills (None = node fills). - pub fills: Option>, - /// Hyperlink. - pub link: Option, - /// Variable font axis values in user space, keyed by 4-char OpenType axis tag (e.g. `wght`: - /// 550, `wdth`: 85). Empty = what the style name implies; axes the face lacks are ignored. - #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")] - pub variations: std::collections::BTreeMap, - /// The text style (shared style id) these chars are linked to. A layer's chars can link to - /// different styles; editing what a style sets unlinks the chars edited. - #[serde(skip_serializing_if = "Option::is_none")] - pub style_id: Option, -} - -/// What a text style sets at the paragraph and layer level, beside its character style -/// ([`TextStyle`]): paragraph spacing and indent, the list style and the OpenType features -/// ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType -/// features", Create and apply text styles). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct ParagraphStyle { - #[serde(skip_serializing_if = "crate::node::is_default")] - pub paragraph_spacing: f64, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub paragraph_indent: f64, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub list: ListStyle, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub features: std::collections::BTreeMap, -} - -/// The font new text starts in. A build can name another (`FIGCRAFT_DEFAULT_FONT="Noto Sans Thai"`, -/// for a distribution whose users mostly write a script Inter lacks); empty means Inter. -pub const DEFAULT_FONT: &str = match option_env!("FIGCRAFT_DEFAULT_FONT") { - Some(family) if !family.is_empty() => family, - _ => "Inter", -}; - -impl Default for TextStyle { - fn default() -> Self { - Self { - font_family: DEFAULT_FONT.into(), - font_style: "Regular".into(), - font_size: 12.0, - line_height: LineHeight::Auto, - letter_spacing: LetterSpacing::default(), - decoration: TextDecoration::None, - decoration_look: None, - case: TextCase::Original, - fills: None, - link: None, - variations: std::collections::BTreeMap::new(), - style_id: None, - } - } -} - -/// Axes a style name implies (weight, italic, slant): picking a named style clears their custom values. -pub const STYLE_AXES: [&str; 3] = ["wght", "ital", "slnt"]; - -impl TextStyle { - /// Numeric weight: a custom `wght` axis value, else the one implied by the style name. - pub fn weight(&self) -> u16 { - match self.variations.get("wght") { - Some(w) if w.is_finite() => w.round().clamp(1.0, 1000.0) as u16, - _ => style_weight(&self.font_style), - } - } - pub fn italic(&self) -> bool { - self.font_style.to_ascii_lowercase().contains("italic") - || self.variations.get("ital").is_some_and(|v| *v >= 0.5) - || self.variations.get("slnt").is_some_and(|v| *v != 0.0) - } - - /// Take what text style `def` sets (font, size, line height, letter spacing, decoration, case - /// and axis values) and link to it as `id`; fills and hyperlinks stay. - pub fn take_text_style(&mut self, def: &TextStyle, id: &str) { - *self = TextStyle { fills: self.fills.take(), link: self.link.take(), style_id: Some(id.to_string()), ..def.clone() }; - } - - /// Whether these chars show what text style `def` sets. - pub fn shows_text_style(&self, def: &TextStyle) -> bool { - self.font_family == def.font_family - && self.font_style == def.font_style - && self.font_size == def.font_size - && self.line_height == def.line_height - && self.letter_spacing == def.letter_spacing - && self.decoration == def.decoration - && self.decoration_look == def.decoration_look - && self.case == def.case - && self.variations == def.variations - } - - /// This style as a text style's definition (linked to nothing). - pub fn as_text_style(&self) -> TextStyle { - TextStyle { style_id: None, link: None, ..self.clone() } - } - - /// How the decoration looks. - pub fn look(&self) -> DecorationLook { - self.decoration_look.as_deref().copied().unwrap_or_default() - } - - /// Change how the decoration looks (the default look is stored as None). - pub fn set_look(&mut self, f: impl FnOnce(&mut DecorationLook)) { - let mut look = self.look(); - f(&mut look); - self.decoration_look = (look != DecorationLook::default()).then(|| Box::new(look)); - } - - /// Switch to the named style, dropping custom values of the axes the name implies. - pub fn set_font_style(&mut self, name: impl Into) { - self.font_style = name.into(); - for tag in STYLE_AXES { - self.variations.remove(tag); - } - } -} - -/// Weight from a style name ("Semi Bold Italic" → 600). -pub fn style_weight(style: &str) -> u16 { - let s = style.to_ascii_lowercase().replace([' ', '-'], ""); - // A weight number ("600", "600 Italic"), as some apps name styles. - let digits: String = s.chars().take_while(char::is_ascii_digit).collect(); - if let Ok(n) = digits.parse::() - && (1..=1000).contains(&n) - { - return n; - } - let table: [(&str, u16); 10] = [ - ("thin", 100), - ("extralight", 200), - ("ultralight", 200), - ("light", 300), - ("medium", 500), - ("semibold", 600), - ("demibold", 600), - ("extrabold", 800), - ("ultrabold", 800), - ("black", 900), - ]; - for (k, w) in table { - if s.contains(k) { - return w; - } - } - if s.contains("bold") { - return 700; - } - if s.contains("heavy") { - return 900; - } - 400 -} - -/// A styled range of characters (char indices, end exclusive). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct StyleRun { - pub start: usize, - pub end: usize, - pub style: TextStyle, -} - -/// Text node content. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct TextData { - pub characters: String, - pub style: TextStyle, - /// Ranges whose style differs from `style`; full styles (already merged), non-overlapping, sorted. - #[serde(skip_serializing_if = "crate::node::is_default")] - pub runs: Vec, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub auto_resize: TextAutoResize, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub align_h: TextAlignH, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub align_v: TextAlignV, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub paragraph_spacing: f64, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub paragraph_indent: f64, - /// The list style of paragraphs `paragraphs` doesn't cover (all of them when it's empty). - #[serde(skip_serializing_if = "crate::node::is_default")] - pub list: ListStyle, - /// Per-paragraph list settings, by paragraph index; empty when every paragraph is `list` at - /// level 0. Kept in step with the characters by [`TextData::replace_range`]. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub paragraphs: Vec, - /// Space between two items of a list (px), in place of the paragraph spacing. - #[serde(skip_serializing_if = "crate::node::is_default")] - pub list_spacing: f64, - /// Opening and closing punctuation at a line's aligned edge sits outside the box. - #[serde(skip_serializing_if = "crate::node::is_default")] - pub hanging_punctuation: bool, - /// List bullets and numbers sit outside the box; item text lines up with plain paragraphs. - #[serde(skip_serializing_if = "crate::node::is_default")] - pub hanging_list: bool, - /// Vertical trim (`capHeight`: the box runs from the first line's cap height to the last - /// line's baseline). - #[serde(skip_serializing_if = "crate::node::is_default")] - pub leading_trim: LeadingTrim, - /// Max lines when truncating, or before an auto-height text ends in an ellipsis (0 = as many - /// as fit). - #[serde(skip_serializing_if = "crate::node::is_default")] - pub max_lines: u32, - /// Where lines break when the text wraps: greedily (Auto), as evenly as the words allow - /// (Balance), or full with no one word alone on the last line (Pretty). - #[serde(skip_serializing_if = "crate::node::is_default")] - pub wrap_style: WrapStyle, - /// OpenType feature toggles (e.g. "liga": false). - #[serde(skip_serializing_if = "crate::node::is_default")] - pub features: std::collections::BTreeMap, - /// The path the text follows (text on a path); `None`: lines in the box. Boxed to keep text - /// data small. - #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option>, - /// Where the file this text came from wrapped its lines (an imported `.fig`'s saved layout). - /// Layout follows them only while the box width and everything that moves a line break are - /// as they were saved, so an edit that could change the wrap drops them. - #[serde(skip_serializing_if = "Option::is_none")] - pub saved_breaks: Option>, -} - -/// Line breaks a file saved with a text: the chars that start a wrapped line (not those after a -/// hard break), for the box width and the content ([`TextData::layout_key`]) they were made for. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct SavedBreaks { - pub starts: Vec, - pub width: f64, - pub for_text: u64, -} - -/// Text on a path: the baseline follows `d`. The text starts at `start` (aligned there by the -/// horizontal alignment: left starts, centre centres, right ends at it); glyphs past an open -/// path's ends are hidden, a closed path wraps them around. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct TextPath { - /// SVG path data in the layer's own space. - pub d: String, - /// Where the text starts along the path, 0..1 by length. - pub start: f64, - /// On the other side of the path, running the other way. - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub flip: bool, -} - -impl TextPath { - /// A text path following `path` (layer space). - pub fn new(path: &figcraft_geom::BezPath) -> Self { - Self { d: path.to_svg(), start: 0.0, flip: false } - } - - /// The path (empty when `d` doesn't parse). - pub fn path(&self) -> figcraft_geom::BezPath { - figcraft_geom::BezPath::from_svg(&self.d).unwrap_or_default() - } - - /// The start offset clamped into 0..1 (NaN: 0). - pub fn start(&self) -> f64 { - if self.start.is_finite() { self.start.clamp(0.0, 1.0) } else { 0.0 } - } - - /// Scale the path by `sx`, `sy` (the layer was resized). - pub fn scale(&mut self, sx: f64, sy: f64) { - if sx.is_finite() && sy.is_finite() { - let mut p = self.path(); - p.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy)); - self.d = p.to_svg(); - } - } -} - -/// Whether `tag` is a valid OpenType feature tag (four printable ASCII chars). -pub fn valid_feature_tag(tag: &str) -> bool { - tag.len() == 4 && tag.bytes().all(|b| (0x20..0x7f).contains(&b)) -} - -/// Whether `tag` is a valid OpenType variation axis tag (same shape as a feature tag). -pub fn valid_axis_tag(tag: &str) -> bool { - valid_feature_tag(tag) -} - -impl TextData { - pub fn new(characters: impl Into) -> Self { - Self { characters: characters.into(), ..Default::default() } - } - - /// A stable hash of what decides where lines break: everything but paints, links, style links, - /// decorations, vertical settings and the saved breaks themselves (FNV-1a over the JSON, so it - /// stays the same across builds and can be saved). - pub fn layout_key(&self) -> u64 { - let clear = |s: &mut TextStyle| { - (s.fills, s.link, s.style_id, s.decoration, s.decoration_look) = (None, None, None, TextDecoration::None, None); - }; - let mut t = TextData { - align_v: TextAlignV::default(), - paragraph_spacing: 0.0, - list_spacing: 0.0, - leading_trim: LeadingTrim::default(), - max_lines: 0, - saved_breaks: None, - ..self.clone() - }; - clear(&mut t.style); - t.runs.iter_mut().for_each(|r| clear(&mut r.style)); - t.normalize(); - serde_json::to_vec(&t).unwrap_or_default().iter().fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3)) - } - - /// Keep `starts` (chars that start a wrapped line) as this text's saved line breaks in a box - /// `width` wide. - pub fn save_breaks(&mut self, starts: Vec, width: f64) { - self.saved_breaks = Some(Box::new(SavedBreaks { starts, width, for_text: self.layout_key() })); - } - - /// The saved line starts (empty: no wrapped line) while they still hold: the same box width and - /// layout content, and starts increasing inside the text. - pub fn saved_breaks_for(&self, width: f64) -> Option<&[usize]> { - let b = self.saved_breaks.as_deref()?; - let inside = b.starts.first().is_none_or(|s| *s > 0) && b.starts.last().is_none_or(|s| *s < self.char_count()); - let same_box = (b.width - width).abs() <= 1e-6 * width.abs().max(1.0); - (inside && same_box && b.starts.is_sorted_by(|a, b| a < b) && b.for_text == self.layout_key()).then_some(b.starts.as_slice()) - } - - /// How far text on a path paints past its path's box (glyphs stand on the path, lines after - /// the first run beside it): about one and a half of the largest size per line; 0 off a path. - pub fn path_reach(&self) -> f64 { - if self.path.is_none() { - return 0.0; - } - let lines = self.characters.lines().count().clamp(1, 10_000) as f64; - self.max_size() * 1.5 * lines - } - - /// How far vertically trimmed text paints past its box (descenders below the last baseline, - /// accents above the first cap height): half the largest size; 0 untrimmed. - pub fn trim_reach(&self) -> f64 { - if self.leading_trim == LeadingTrim::None { 0.0 } else { self.max_size() * 0.5 } - } - - /// The largest font size used (capped like layout caps it). - fn max_size(&self) -> f64 { - self.spans().iter().map(|(_, _, s)| s.font_size).filter(|v| v.is_finite()).fold(0.0, f64::max).min(5000.0) - } - - pub fn char_count(&self) -> usize { - self.characters.chars().count() - } - - /// Whether text that runs out of room ends in an ellipsis: a truncating box, or auto height - /// or auto width with a line limit (the box hugs up to `max_lines` lines: "Max lines is - /// available only if truncate text is enabled, and: Text resizing is set to auto height or - /// auto width"). - pub fn truncates(&self) -> bool { - match self.auto_resize { - TextAutoResize::Truncate => true, - TextAutoResize::Height | TextAutoResize::WidthAndHeight => self.max_lines > 0, - TextAutoResize::None => false, - } - } - - /// The resize mode once the box is given an explicit `width` and/or `height`: an explicit - /// height fixes the box, an explicit width turns auto width into auto height, and truncating - /// text keeps truncating. - pub fn fix_size(&mut self, width: bool, height: bool) { - self.auto_resize = match self.auto_resize { - _ if height && self.truncates() => TextAutoResize::Truncate, - _ if height => TextAutoResize::None, - TextAutoResize::WidthAndHeight if width => TextAutoResize::Height, - mode => mode, - }; - } - - /// The style at char index `i`. - pub fn style_at(&self, i: usize) -> &TextStyle { - self.runs.iter().find(|r| r.start <= i && i < r.end).map(|r| &r.style).unwrap_or(&self.style) - } - - /// Spans of uniform style covering the whole text: (start, end, style). - pub fn spans(&self) -> Vec<(usize, usize, &TextStyle)> { - let n = self.char_count(); - let mut out = Vec::new(); - let mut pos = 0; - let mut runs: Vec<&StyleRun> = self.runs.iter().filter(|r| r.start < r.end && r.start < n).collect(); - runs.sort_by_key(|r| r.start); - for r in runs { - let start = r.start.max(pos); - let end = r.end.min(n); - if start >= end { - continue; - } - if start > pos { - out.push((pos, start, &self.style)); - } - out.push((start, end, &r.style)); - pos = end; - } - if pos < n || out.is_empty() { - out.push((pos, n, &self.style)); - } - out - } - - /// Apply `f` to the style of chars `start..end` (all text when the range is the whole text or - /// empty text), splitting runs as needed and merging equal neighbours. - pub fn restyle(&mut self, start: usize, end: usize, f: impl Fn(&mut TextStyle)) { - let n = self.char_count(); - let (start, end) = (start.min(n), end.min(n)); - if n == 0 || (start == 0 && end >= n) { - f(&mut self.style); - for r in &mut self.runs { - f(&mut r.style); - } - self.normalize(); - return; - } - if start >= end { - return; - } - // Expand to per-char styles, modify, re-compress. - let mut per: Vec = (0..n).map(|i| self.style_at(i).clone()).collect(); - for s in per.iter_mut().take(end).skip(start) { - f(s); - } - self.runs.clear(); - let mut i = 0; - while i < n { - let Some(s) = per.get(i) else { break }; - let mut j = i + 1; - while per.get(j).is_some_and(|t| t == s) { - j += 1; - } - if *s != self.style { - self.runs.push(StyleRun { start: i, end: j, style: s.clone() }); - } - i = j; - } - } - - /// Drop empty/out-of-range runs and runs equal to the base style, and merge touching equal runs. - pub fn normalize(&mut self) { - let n = self.char_count(); - let base = self.style.clone(); - self.runs.retain(|r| r.start < r.end && r.start < n && r.style != base); - for r in &mut self.runs { - r.end = r.end.min(n); - } - self.runs.sort_by_key(|r| r.start); - let mut merged: Vec = Vec::with_capacity(self.runs.len()); - for mut r in std::mem::take(&mut self.runs) { - // Runs never overlap: a later run starts where the previous one ends at the earliest. - if let Some(prev) = merged.last() { - r.start = r.start.max(prev.end); - if r.start >= r.end { - continue; - } - } - match merged.last_mut() { - Some(prev) if prev.end == r.start && prev.style == r.style => prev.end = r.end, - _ => merged.push(r), - } - } - self.runs = merged; - } - - /// Replace chars `start..end` with `text`, shifting runs (inserted text takes the style at `start`). - pub fn replace_range(&mut self, start: usize, end: usize, text: &str) { - let n = self.char_count(); - let (start, end) = (start.min(n), end.min(n).max(start.min(n))); - let byte = |i: usize| self.characters.char_indices().nth(i).map(|(b, _)| b).unwrap_or(self.characters.len()); - let (bs, be) = (byte(start), byte(end)); - if !self.paragraphs.is_empty() { - // Paragraphs merged by removed newlines take the first one's list; new ones copy it. - let p = self.para_of(start); - let removed = self.characters.get(bs..be).unwrap_or("").matches('\n').count(); - let added = text.matches('\n').count(); - self.fill_paragraphs(); - let at = self.para_list(p); - let tail = (p + 1).min(self.paragraphs.len()); - self.paragraphs.drain(tail..(p + 1 + removed).min(self.paragraphs.len())); - self.paragraphs.splice(tail..tail, std::iter::repeat_n(at, added)); - } - let mut s = String::with_capacity(self.characters.len() + text.len()); - s.push_str(self.characters.get(..bs).unwrap_or("")); - s.push_str(text); - s.push_str(self.characters.get(be..).unwrap_or("")); - self.characters = s; - let inserted = text.chars().count(); - let removed = end - start; - for r in &mut self.runs { - let shift = |p: usize| -> usize { - if p <= start { - p - } else if p >= end { - // Runs of a file that reach past the text (nothing clamps them on load) - // would otherwise overflow here; `normalize` below drops what is left. - p.saturating_sub(removed).saturating_add(inserted) - } else { - start + inserted - } - }; - // A run touching the insertion point at its end grows with the typed text. - let new_start = shift(r.start); - let new_end = if r.end == start && r.start < start { start + inserted } else { shift(r.end) }; - r.start = new_start; - r.end = new_end; - } - self.normalize(); - } - - pub fn paragraph_count(&self) -> usize { - self.characters.matches('\n').count() + 1 - } - - /// The paragraph char index `i` is in. - pub fn para_of(&self, i: usize) -> usize { - self.characters.chars().take(i).filter(|c| *c == '\n').count() - } - - /// Paragraph `p`'s list settings. - pub fn para_list(&self, p: usize) -> ParaList { - self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, ..ParaList::default() }) - } - - /// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries). - fn fill_paragraphs(&mut self) { - let n = self.paragraph_count(); - let fill = ParaList { list: self.list, ..ParaList::default() }; - self.paragraphs.resize(n, fill); - } - - /// The paragraphs chars `start..end` touch (a caret: its paragraph; a selection ending just - /// after a newline leaves the next paragraph out). - pub fn para_span(&self, start: usize, end: usize) -> (usize, usize) { - let (s, e) = (start.min(end), start.max(end)); - let (a, b) = (self.para_of(s), self.para_of(e)); - let ends_at_start = e > s && b > a && self.characters.chars().nth(e - 1) == Some('\n'); - (a, if ends_at_start { b - 1 } else { b }) - } - - /// Apply `f` to the list settings of the paragraphs chars `start..end` touch (plain - /// paragraphs stay at level 0, only numbered ones keep a start, levels stop at - /// [`MAX_LIST_INDENT`]); one style for all with no nesting or start folds back into `list`. - pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) { - self.set_para_lists_with(start, end, false, f); - } - - /// Take the list items among the paragraphs chars `start..end` touch out of their list at the - /// level they had, as Backspace or Delete at an item's start does: "delete the counter, but - /// keep the same level of indentation" (Create bulleted and numbered lists). - pub fn unlist_keeping_levels(&mut self, start: usize, end: usize) { - self.set_para_lists_with(start, end, true, |pl| pl.list = ListStyle::None); - } - - fn set_para_lists_with(&mut self, start: usize, end: usize, keep_levels: bool, f: impl Fn(&mut ParaList)) { - let (a, b) = self.para_span(start, end); - self.fill_paragraphs(); - for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) { - f(pl); - if pl.list == ListStyle::None && !keep_levels { - pl.indent = 0; - } - if pl.list != ListStyle::Numbered { - pl.start_offset = 0; - } - pl.indent = pl.indent.min(MAX_LIST_INDENT); - } - if let Some(first) = self.paragraphs.first().copied() - && first == (ParaList { list: first.list, ..ParaList::default() }) - && self.paragraphs.iter().all(|p| *p == first) - { - self.list = first.list; - self.paragraphs.clear(); - } - } - - /// The list style and level shared by the paragraphs chars `start..end` touch (the first - /// one's settings), or None when they differ. Start numbers don't count. - pub fn common_para_list(&self, start: usize, end: usize) -> Option { - let (a, b) = self.para_span(start, end); - let first = self.para_list(a); - (a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first) - } - - /// What a text style made from this text sets at the paragraph and layer level: the paragraph - /// spacing and indent, the list style (the one every paragraph shares; the base one when they - /// differ) and the OpenType features. - pub fn paragraph_style(&self) -> ParagraphStyle { - let list = match self.paragraphs.first() { - Some(first) if self.paragraphs.iter().all(|p| p.list == first.list) => first.list, - _ => self.list, - }; - ParagraphStyle { paragraph_spacing: self.paragraph_spacing, paragraph_indent: self.paragraph_indent, list, features: self.features.clone() } - } - - /// Take what text style `ps` sets at the paragraph and layer level: the spacing, indent and - /// features on the layer, the list style on every paragraph. - pub fn take_paragraph_style(&mut self, ps: &ParagraphStyle) { - let len = self.char_count(); - self.take_paragraph_style_in(0, len, ps); - } - - /// As [`take_paragraph_style`](Self::take_paragraph_style), with the list style going to the - /// paragraphs chars `start..end` touch only; the spacing, indent and features are the - /// layer's, as in Figma. - pub fn take_paragraph_style_in(&mut self, start: usize, end: usize, ps: &ParagraphStyle) { - self.paragraph_spacing = ps.paragraph_spacing; - self.paragraph_indent = ps.paragraph_indent; - self.features.clone_from(&ps.features); - self.set_para_lists(start, end, |pl| pl.list = ps.list); - } - - /// Follow text style `id`'s paragraph-level definition wherever chars link to it. - pub fn update_paragraph_style(&mut self, id: &str, ps: &ParagraphStyle) { - let linked: Vec<(usize, usize)> = - self.spans().into_iter().filter(|(_, _, s)| s.style_id.as_deref() == Some(id)).map(|(a, b, _)| (a, b)).collect(); - for (a, b) in linked { - self.take_paragraph_style_in(a, b, ps); - } - } - - /// Whether chars `start..end` show what text style `ps` sets at the paragraph and layer - /// level: the layer's spacing, indent and features, and their paragraphs' list style. - pub fn shows_paragraph_style_in(&self, start: usize, end: usize, ps: &ParagraphStyle) -> bool { - self.paragraph_spacing == ps.paragraph_spacing - && self.paragraph_indent == ps.paragraph_indent - && self.features == ps.features - && self.common_para_list(start, end).is_some_and(|pl| pl.list == ps.list) - } - - /// Whether the whole text shows what text style `ps` sets at the paragraph and layer level. - pub fn shows_paragraph_style(&self, ps: &ParagraphStyle) -> bool { - self.shows_paragraph_style_in(0, self.char_count(), ps) - } - - /// Link chars `start..end` (all of them when the range is empty or the whole text) to text - /// style `id`, defined as `def`. - pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) { - let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) }; - self.restyle(start, end, |s| s.take_text_style(def, id)); - } - - /// Unlink chars `start..end` (all when empty or whole) from their text styles; values stay. - pub fn unlink_text_style(&mut self, start: usize, end: usize) { - let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) }; - self.restyle(start, end, |s| s.style_id = None); - } - - /// The text style every char is linked to: `Some(None)` when none is, None when they differ. - pub fn text_style_link(&self) -> Option> { - let mut spans = self.spans().into_iter().map(|(_, _, s)| s.style_id.as_deref()); - let first = spans.next()?; - spans.all(|s| s == first).then_some(first) - } - - /// Apply `f` to every distinct style (the base and each run) and merge runs that became equal. - pub(crate) fn each_style(&mut self, mut f: impl FnMut(&mut TextStyle)) { - f(&mut self.style); - for r in &mut self.runs { - f(&mut r.style); - } - self.normalize(); - } - - /// Whether any char links to text style `id`. - pub fn links_text_style(&self, id: &str) -> bool { - std::iter::once(&self.style).chain(self.runs.iter().map(|r| &r.style)).any(|s| s.style_id.as_deref() == Some(id)) - } - - /// Follow text style `id`'s new definition wherever chars link to it. - pub fn update_text_style(&mut self, id: &str, def: &TextStyle) { - self.each_style(|s| { - if s.style_id.as_deref() == Some(id) { - s.take_text_style(def, id); - } - }); - } - - /// Point text style links elsewhere: `f(id)` gives the new id, None unlinks (values stay). - pub fn map_text_styles(&mut self, f: impl Fn(&str) -> Option) { - self.each_style(|s| s.style_id = s.style_id.as_deref().and_then(&f)); - } - - /// Unlink chars whose style is gone (`def` gives None) or that no longer show it (edited since). - pub fn check_text_styles<'a>(&mut self, def: impl Fn(&str) -> Option<&'a TextStyle>) { - self.each_style(|s| { - if s.style_id.as_deref().is_some_and(|id| !def(id).is_some_and(|d| s.shows_text_style(d))) { - s.style_id = None; - } - }); - } - - /// Whether every char shares one style (the panel shows "Mixed" otherwise). - pub fn is_uniform(&self) -> bool { - self.runs.is_empty() - } -} - -#[cfg(test)] -mod case_tests { - use super::*; - - #[test] - fn trim_num_prints_huge_whole_numbers() { - assert_eq!(trim_num(1e19), "10000000000000000000"); - assert_eq!(trim_num(-1e19), "-10000000000000000000"); - assert_eq!(trim_num(12.0), "12"); - let v = 12345678901234567890.0_f64; - assert_eq!(trim_num(v).parse::().unwrap(), v); - } - - #[test] - fn title_case_respects_context() { - assert_eq!(TextCase::Title.apply("hello wORLD don't 3d x-ray"), "Hello WORLD Don't 3d X-Ray"); - // Continuing a word from a previous run: no capital. - assert_eq!(TextCase::Title.apply_after("lo world", Some('l')), "lo World"); - assert_eq!(TextCase::Title.apply_after("lo", Some(' ')), "Lo"); - assert_eq!(TextCase::Upper.apply_after("ß", Some('a')), "SS"); - assert!(valid_feature_tag("liga") && valid_feature_tag("ss01")); - assert!(!valid_feature_tag("lig") && !valid_feature_tag("ligatures") && !valid_feature_tag("li\u{e9}")); - } - - #[test] - fn new_text_starts_in_the_builds_default_font() { - // Inter, unless the build names another with `FIGCRAFT_DEFAULT_FONT` (empty means Inter). - let named = option_env!("FIGCRAFT_DEFAULT_FONT").filter(|f| !f.is_empty()); - assert_eq!(DEFAULT_FONT, named.unwrap_or("Inter")); - let st = TextStyle::default(); - assert_eq!(st.font_family, DEFAULT_FONT); - // The family is always written, so a saved file keeps it in a build with another default. - let saved = serde_json::to_value(&st).unwrap(); - assert_eq!(saved["font_family"], DEFAULT_FONT); - } - - #[test] - fn variations_are_backward_compatible_and_imply_weight() { - // Files written before variable fonts load with no custom axes, and stay byte-identical. - let old = r#"{"font_family":"Inter","font_style":"Bold","font_size":12.0}"#; - let st: TextStyle = serde_json::from_str(old).unwrap(); - assert!(st.variations.is_empty()); - assert!(!serde_json::to_string(&st).unwrap().contains("variations")); - let mut st = TextStyle { font_style: "Regular".into(), ..Default::default() }; - st.variations.insert("wght".into(), 640.0); - st.variations.insert("wdth".into(), 80.0); - assert_eq!(st.weight(), 640); - assert!(!st.italic()); - st.variations.insert("slnt".into(), -8.0); - assert!(st.italic()); - let round: TextStyle = serde_json::from_str(&serde_json::to_string(&st).unwrap()).unwrap(); - assert_eq!(round, st); - // Picking a named style drops the axes its name implies, keeps the others. - st.set_font_style("Bold"); - assert_eq!(st.weight(), 700); - assert_eq!(st.variations.keys().collect::>(), ["wdth"]); - assert!(valid_axis_tag("wght") && !valid_axis_tag("weight")); - } - - #[test] - fn list_starts_load_from_old_files_and_follow_edits() { - use ListStyle::{Bulleted as B, None as P, Numbered as N}; - // Files written before start numbers load with none, and write the same bytes. - let old = r#"{"list":"numbered","indent":1}"#; - let pl: ParaList = serde_json::from_str(old).unwrap(); - assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 }); - assert_eq!(serde_json::to_string(&pl).unwrap(), old); - // One numbered paragraph with a start keeps it (it doesn't fold into `list`). - let mut t = TextData { characters: "Next".into(), ..Default::default() }; - t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 }); - assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 }); - // Enter copies it to the new item; different starts don't make the list mixed. - t.replace_range(4, 4, "\nMore"); - assert_eq!(t.para_list(1).start_offset, 1); - t.set_para_lists(5, 5, |p| p.start_offset = 0); - assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N)); - // Bullets have no number, so no start. - t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 }); - assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 }); - // A plain paragraph has none. - t.set_para_lists(0, 0, |p| p.list = P); - assert_eq!(t.para_list(0), ParaList::default()); - } -} - -#[cfg(test)] -mod title_case_tests { - use super::TextCase; - - #[test] - fn title_case_after_opening_apostrophe() { - assert_eq!(TextCase::Title.apply("'hello world'"), "'Hello World'"); - assert_eq!(TextCase::Title.apply("say 'hi'"), "Say 'Hi'"); - assert_eq!(TextCase::Title.apply("don't"), "Don't"); - assert_eq!(TextCase::Title.apply("\u{2019}hello"), "\u{2019}Hello"); - } - - #[test] - fn title_case_between_runs_sees_char_before_apostrophe() { - assert_eq!(TextCase::Title.apply_after("t", Some('\'')), "t"); - assert_eq!(TextCase::Title.apply_between("hello", None, Some('\'')), "Hello"); - assert_eq!(TextCase::Title.apply_between("t", Some('n'), Some('\'')), "t"); - assert_eq!(TextCase::Title.apply_between("t", Some(' '), Some('\u{2019}')), "T"); - } - - #[test] - fn title_case_combining_marks_do_not_break_words() { - assert_eq!(TextCase::Title.apply("re\u{301}sume\u{301}"), "Re\u{301}sume\u{301}"); - assert_eq!(TextCase::Title.apply("re\u{301}sume\u{301} bon"), "Re\u{301}sume\u{301} Bon"); - assert_eq!(TextCase::Title.apply("a \u{301}b"), "A \u{301}B"); - assert_eq!(TextCase::Title.apply("cafe\u{301}'s"), "Cafe\u{301}'s"); - } -} +//! Text node data: characters plus a base style and styled runs. + +use figcraft_color::{Color, Paint}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "unit", content = "value", rename_all = "camelCase")] +#[derive(Default)] +pub enum LineHeight { + #[default] + Auto, + Pixels(f64), + Percent(f64), +} + +impl LineHeight { + /// Resolved line height in px for `font_size` (`auto` uses the font's natural ratio). + pub fn resolve(self, font_size: f64, natural_ratio: f64) -> f64 { + match self { + LineHeight::Auto => font_size * natural_ratio, + LineHeight::Pixels(v) => v.max(0.0), + LineHeight::Percent(p) => font_size * p.max(0.0) / 100.0, + } + } + pub fn label(self) -> String { + match self { + LineHeight::Auto => "Auto".into(), + LineHeight::Pixels(v) => trim_num(v), + LineHeight::Percent(p) => format!("{}%", trim_num(p)), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "unit", content = "value", rename_all = "camelCase")] +pub enum LetterSpacing { + Pixels(f64), + Percent(f64), +} + +impl Default for LetterSpacing { + fn default() -> Self { + LetterSpacing::Percent(0.0) + } +} + +impl LetterSpacing { + pub fn resolve(self, font_size: f64) -> f64 { + match self { + LetterSpacing::Pixels(v) => v, + LetterSpacing::Percent(p) => font_size * p / 100.0, + } + } + pub fn label(self) -> String { + match self { + LetterSpacing::Pixels(v) => trim_num(v), + LetterSpacing::Percent(p) => format!("{}%", trim_num(p)), + } + } +} + +pub fn trim_num(v: f64) -> String { + if v.abs() >= 9.0e18 { + return format!("{v}"); + } + let r = (v * 100.0).round() / 100.0; + if r.fract().abs() < 1e-9 { format!("{}", r as i64) } else { format!("{r}") } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum TextDecoration { + #[default] + None, + Underline, + Strikethrough, +} + +/// How an underline or strikethrough is drawn. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum DecorationStyle { + #[default] + Solid, + Dotted, + Wavy, +} + +/// A decoration length: `Auto` (the font's own value), pixels, or a percentage of the font size. +pub type DecorationLength = LineHeight; + +/// How a decoration line looks; the default is the font's own solid line in the text's colour. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct DecorationLook { + pub style: DecorationStyle, + pub thickness: DecorationLength, + /// Underlines: from the baseline down to the line's top edge (negative: above the baseline). + pub offset: DecorationLength, + /// Underlines break where descenders cross them. + pub skip_ink: bool, + /// The line's own colour (None: the text's fills). + pub color: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum TextCase { + #[default] + Original, + Upper, + Lower, + Title, + SmallCaps, +} + +impl TextCase { + pub fn apply(self, s: &str) -> String { + self.apply_after(s, None) + } + + /// Apply the case to `s`, which follows `prev` in the text (so title case only capitalises + /// the first letter of each word even when a word spans several styled runs). An apostrophe + /// as `prev` is taken to continue a word. + pub fn apply_after(self, s: &str, prev: Option) -> String { + self.apply_impl(s, prev, prev.is_some_and(is_word_char)) + } + + /// Like [`apply_after`](Self::apply_after), also given the char `before` `prev`: an apostrophe + /// only continues a word when a letter or digit precedes it. + pub fn apply_between(self, s: &str, before: Option, prev: Option) -> String { + let continues = prev.is_some_and(|c| is_word_char(c) && (c.is_alphanumeric() || before.is_some_and(char::is_alphanumeric))); + self.apply_impl(s, prev, continues) + } + + /// `in_word` says whether `prev` continues a word (rather than a new word starting here). + fn apply_impl(self, s: &str, prev: Option, in_word: bool) -> String { + match self { + TextCase::Original | TextCase::SmallCaps => s.to_string(), + TextCase::Upper => s.to_uppercase(), + TextCase::Lower => s.to_lowercase(), + TextCase::Title => { + let mut out = String::with_capacity(s.len()); + let mut start = !in_word; + let mut last = prev; + for ch in s.chars() { + if start && ch.is_alphanumeric() { + out.extend(ch.to_uppercase()); + start = false; + } else { + out.push(ch); + // An apostrophe only continues a word right after a letter or digit. + let opening_apostrophe = + matches!(ch, '\'' | '\u{2019}') && !last.is_some_and(|c| c.is_alphanumeric() || is_combining_mark(c)); + // A combining mark attaches to the char before it and never changes word state. + if !is_combining_mark(ch) { + start = !is_word_char(ch) || opening_apostrophe; + } + } + last = Some(ch); + } + out + } + } + } + + /// Whether the char at a position preceded by `prev` starts a word (for title case). + pub fn starts_word(prev: Option) -> bool { + prev.is_none_or(|c| !is_word_char(c)) + } +} + +/// Letters, digits, combining marks and apostrophes continue a word (so "don't" stays "Don't" +/// and decomposed accents stay inside their word). +fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || is_combining_mark(c) || matches!(c, '\'' | '\u{2019}') +} + +/// Combining marks such as the U+0301 accent in decomposed text: non-alphanumeric chars that +/// extend the grapheme of the char before them. +fn is_combining_mark(c: char) -> bool { + use unicode_segmentation::UnicodeSegmentation; + if c.is_ascii() || c.is_alphanumeric() { + return false; + } + let mut buf = [0u8; 8]; + buf[0] = b'a'; + let end = 1 + c.encode_utf8(&mut buf[1..]).len(); + std::str::from_utf8(&buf[..end]).is_ok_and(|s| s.graphemes(true).count() == 1) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum TextAlignH { + #[default] + Left, + Center, + Right, + Justified, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum TextAlignV { + #[default] + Top, + Middle, + Bottom, +} + +/// How a text box resizes as its content changes. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum TextAutoResize { + /// Auto width: one line per paragraph, box grows both ways. + #[default] + WidthAndHeight, + /// Fixed width, height grows. + Height, + /// Fixed size. + None, + /// Fixed size, truncate with an ellipsis. + Truncate, +} + +/// Vertical trim: what bounds a text box vertically. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum LeadingTrim { + /// The full line height of the first and last lines. + #[default] + None, + /// From the first line's cap height to the last line's baseline. + CapHeight, +} + +/// Where lines break when text wraps to more than one line ("Wrap style controls where lines +/// break when text wraps to more than one line", Explore text properties). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum WrapStyle { + /// "Figma fits as many words as possible on each line before wrapping to the next." + #[default] + Auto, + /// "Figma distributes text as evenly as possible across every line, so each line is close to + /// the same length." + Balance, + /// "Figma keeps each line full, but adjusts the last four lines to avoid an orphan -- a single + /// word stranded alone on the last line." + Pretty, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum ListStyle { + #[default] + None, + Bulleted, + Numbered, +} + +/// Deepest list nesting (levels 0..=MAX_LIST_INDENT). +pub const MAX_LIST_INDENT: u8 = 8; + +/// One paragraph's list settings. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct ParaList { + pub list: ListStyle, + /// Nesting level (0 = outermost). A plain paragraph is 0, except one that left its list by + /// Backspace or Delete at its start ([`TextData::unlist_keeping_levels`]): it keeps the level + /// it had and starts where that level's item text does ("delete the counter, but keep the + /// same level of indentation"). + pub indent: u8, + /// A numbered list that starts counting at this paragraph counts from `start_offset + 1` + /// (items that go on counting ignore it; other paragraphs are always 0). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub start_offset: u32, +} + +/// Character style. In runs, every field is an override; `None` means "inherit the base". +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct TextStyle { + pub font_family: String, + /// The style name, e.g. "Regular", "Bold", "Semi Bold Italic". + pub font_style: String, + pub font_size: f64, + pub line_height: LineHeight, + pub letter_spacing: LetterSpacing, + pub decoration: TextDecoration, + /// How the decoration looks (None: the default look; boxed to keep styles small). + #[serde(skip_serializing_if = "Option::is_none")] + pub decoration_look: Option>, + pub case: TextCase, + /// Run fills (None = node fills). + pub fills: Option>, + /// Hyperlink. + pub link: Option, + /// Variable font axis values in user space, keyed by 4-char OpenType axis tag (e.g. `wght`: + /// 550, `wdth`: 85). Empty = what the style name implies; axes the face lacks are ignored. + #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub variations: std::collections::BTreeMap, + /// The text style (shared style id) these chars are linked to. A layer's chars can link to + /// different styles; editing what a style sets unlinks the chars edited. + #[serde(skip_serializing_if = "Option::is_none")] + pub style_id: Option, +} + +/// What a text style sets at the paragraph and layer level, beside its character style +/// ([`TextStyle`]): paragraph spacing and indent, the list style and the OpenType features +/// ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType +/// features", Create and apply text styles). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct ParagraphStyle { + #[serde(skip_serializing_if = "crate::node::is_default")] + pub paragraph_spacing: f64, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub paragraph_indent: f64, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub list: ListStyle, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub features: std::collections::BTreeMap, +} + +/// The font new text starts in. A build can name another (`FIGCRAFT_DEFAULT_FONT="Noto Sans Thai"`, +/// for a distribution whose users mostly write a script Inter lacks); empty means Inter. +pub const DEFAULT_FONT: &str = match option_env!("FIGCRAFT_DEFAULT_FONT") { + Some(family) if !family.is_empty() => family, + _ => "Inter", +}; + +impl Default for TextStyle { + fn default() -> Self { + Self { + font_family: DEFAULT_FONT.into(), + font_style: "Regular".into(), + font_size: 12.0, + line_height: LineHeight::Auto, + letter_spacing: LetterSpacing::default(), + decoration: TextDecoration::None, + decoration_look: None, + case: TextCase::Original, + fills: None, + link: None, + variations: std::collections::BTreeMap::new(), + style_id: None, + } + } +} + +/// Axes a style name implies (weight, italic, slant): picking a named style clears their custom values. +pub const STYLE_AXES: [&str; 3] = ["wght", "ital", "slnt"]; + +impl TextStyle { + /// Numeric weight: a custom `wght` axis value, else the one implied by the style name. + pub fn weight(&self) -> u16 { + match self.variations.get("wght") { + Some(w) if w.is_finite() => w.round().clamp(1.0, 1000.0) as u16, + _ => style_weight(&self.font_style), + } + } + pub fn italic(&self) -> bool { + self.font_style.to_ascii_lowercase().contains("italic") + || self.variations.get("ital").is_some_and(|v| *v >= 0.5) + || self.variations.get("slnt").is_some_and(|v| *v != 0.0) + } + + /// Take what text style `def` sets (font, size, line height, letter spacing, decoration, case + /// and axis values) and link to it as `id`; fills and hyperlinks stay. + pub fn take_text_style(&mut self, def: &TextStyle, id: &str) { + *self = TextStyle { fills: self.fills.take(), link: self.link.take(), style_id: Some(id.to_string()), ..def.clone() }; + } + + /// Whether these chars show what text style `def` sets. + pub fn shows_text_style(&self, def: &TextStyle) -> bool { + self.font_family == def.font_family + && self.font_style == def.font_style + && self.font_size == def.font_size + && self.line_height == def.line_height + && self.letter_spacing == def.letter_spacing + && self.decoration == def.decoration + && self.decoration_look == def.decoration_look + && self.case == def.case + && self.variations == def.variations + } + + /// This style as a text style's definition (linked to nothing). + pub fn as_text_style(&self) -> TextStyle { + TextStyle { style_id: None, link: None, ..self.clone() } + } + + /// How the decoration looks. + pub fn look(&self) -> DecorationLook { + self.decoration_look.as_deref().copied().unwrap_or_default() + } + + /// Change how the decoration looks (the default look is stored as None). + pub fn set_look(&mut self, f: impl FnOnce(&mut DecorationLook)) { + let mut look = self.look(); + f(&mut look); + self.decoration_look = (look != DecorationLook::default()).then(|| Box::new(look)); + } + + /// Switch to the named style, dropping custom values of the axes the name implies. + pub fn set_font_style(&mut self, name: impl Into) { + self.font_style = name.into(); + for tag in STYLE_AXES { + self.variations.remove(tag); + } + } +} + +/// Weight from a style name ("Semi Bold Italic" → 600). +pub fn style_weight(style: &str) -> u16 { + let s = style.to_ascii_lowercase().replace([' ', '-'], ""); + // A weight number ("600", "600 Italic"), as some apps name styles. + let digits: String = s.chars().take_while(char::is_ascii_digit).collect(); + if let Ok(n) = digits.parse::() + && (1..=1000).contains(&n) + { + return n; + } + let table: [(&str, u16); 10] = [ + ("thin", 100), + ("extralight", 200), + ("ultralight", 200), + ("light", 300), + ("medium", 500), + ("semibold", 600), + ("demibold", 600), + ("extrabold", 800), + ("ultrabold", 800), + ("black", 900), + ]; + for (k, w) in table { + if s.contains(k) { + return w; + } + } + if s.contains("bold") { + return 700; + } + if s.contains("heavy") { + return 900; + } + 400 +} + +/// A styled range of characters (char indices, end exclusive). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StyleRun { + pub start: usize, + pub end: usize, + pub style: TextStyle, +} + +/// Text node content. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct TextData { + pub characters: String, + pub style: TextStyle, + /// Ranges whose style differs from `style`; full styles (already merged), non-overlapping, sorted. + #[serde(skip_serializing_if = "crate::node::is_default")] + pub runs: Vec, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub auto_resize: TextAutoResize, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub align_h: TextAlignH, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub align_v: TextAlignV, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub paragraph_spacing: f64, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub paragraph_indent: f64, + /// The list style of paragraphs `paragraphs` doesn't cover (all of them when it's empty). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub list: ListStyle, + /// Per-paragraph list settings, by paragraph index; empty when every paragraph is `list` at + /// level 0. Kept in step with the characters by [`TextData::replace_range`]. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub paragraphs: Vec, + /// Space between two items of a list (px), in place of the paragraph spacing. + #[serde(skip_serializing_if = "crate::node::is_default")] + pub list_spacing: f64, + /// Opening and closing punctuation at a line's aligned edge sits outside the box. + #[serde(skip_serializing_if = "crate::node::is_default")] + pub hanging_punctuation: bool, + /// List bullets and numbers sit outside the box; item text lines up with plain paragraphs. + #[serde(skip_serializing_if = "crate::node::is_default")] + pub hanging_list: bool, + /// Vertical trim (`capHeight`: the box runs from the first line's cap height to the last + /// line's baseline). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub leading_trim: LeadingTrim, + /// Max lines when truncating, or before an auto-height text ends in an ellipsis (0 = as many + /// as fit). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub max_lines: u32, + /// Where lines break when the text wraps: greedily (Auto), as evenly as the words allow + /// (Balance), or full with no one word alone on the last line (Pretty). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub wrap_style: WrapStyle, + /// OpenType feature toggles (e.g. "liga": false). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub features: std::collections::BTreeMap, + /// The path the text follows (text on a path); `None`: lines in the box. Boxed to keep text + /// data small. + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option>, + /// Where the file this text came from wrapped its lines (an imported `.fig`'s saved layout). + /// Layout follows them only while the box width and everything that moves a line break are + /// as they were saved, so an edit that could change the wrap drops them. + #[serde(skip_serializing_if = "Option::is_none")] + pub saved_breaks: Option>, +} + +/// Line breaks a file saved with a text: the chars that start a wrapped line (not those after a +/// hard break), for the box width and the content ([`TextData::layout_key`]) they were made for. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct SavedBreaks { + pub starts: Vec, + pub width: f64, + pub for_text: u64, +} + +/// Text on a path: the baseline follows `d`. The text starts at `start` (aligned there by the +/// horizontal alignment: left starts, centre centres, right ends at it); glyphs past an open +/// path's ends are hidden, a closed path wraps them around. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct TextPath { + /// SVG path data in the layer's own space. + pub d: String, + /// Where the text starts along the path, 0..1 by length. + pub start: f64, + /// On the other side of the path, running the other way. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub flip: bool, +} + +impl TextPath { + /// A text path following `path` (layer space). + pub fn new(path: &figcraft_geom::BezPath) -> Self { + Self { d: path.to_svg(), start: 0.0, flip: false } + } + + /// The path (empty when `d` doesn't parse). + pub fn path(&self) -> figcraft_geom::BezPath { + figcraft_geom::BezPath::from_svg(&self.d).unwrap_or_default() + } + + /// The start offset clamped into 0..1 (NaN: 0). + pub fn start(&self) -> f64 { + if self.start.is_finite() { self.start.clamp(0.0, 1.0) } else { 0.0 } + } + + /// Scale the path by `sx`, `sy` (the layer was resized). + pub fn scale(&mut self, sx: f64, sy: f64) { + if sx.is_finite() && sy.is_finite() { + let mut p = self.path(); + p.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy)); + self.d = p.to_svg(); + } + } +} + +/// Whether `tag` is a valid OpenType feature tag (four printable ASCII chars). +pub fn valid_feature_tag(tag: &str) -> bool { + tag.len() == 4 && tag.bytes().all(|b| (0x20..0x7f).contains(&b)) +} + +/// Whether `tag` is a valid OpenType variation axis tag (same shape as a feature tag). +pub fn valid_axis_tag(tag: &str) -> bool { + valid_feature_tag(tag) +} + +impl TextData { + pub fn new(characters: impl Into) -> Self { + Self { characters: characters.into(), ..Default::default() } + } + + /// A stable hash of what decides where lines break: everything but paints, links, style links, + /// decorations, vertical settings and the saved breaks themselves (FNV-1a over the JSON, so it + /// stays the same across builds and can be saved). + pub fn layout_key(&self) -> u64 { + let clear = |s: &mut TextStyle| { + (s.fills, s.link, s.style_id, s.decoration, s.decoration_look) = (None, None, None, TextDecoration::None, None); + }; + let mut t = TextData { + align_v: TextAlignV::default(), + paragraph_spacing: 0.0, + list_spacing: 0.0, + leading_trim: LeadingTrim::default(), + max_lines: 0, + saved_breaks: None, + ..self.clone() + }; + clear(&mut t.style); + t.runs.iter_mut().for_each(|r| clear(&mut r.style)); + t.normalize(); + serde_json::to_vec(&t).unwrap_or_default().iter().fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3)) + } + + /// Keep `starts` (chars that start a wrapped line) as this text's saved line breaks in a box + /// `width` wide. + pub fn save_breaks(&mut self, starts: Vec, width: f64) { + self.saved_breaks = Some(Box::new(SavedBreaks { starts, width, for_text: self.layout_key() })); + } + + /// The saved line starts (empty: no wrapped line) while they still hold: the same box width and + /// layout content, and starts increasing inside the text. + pub fn saved_breaks_for(&self, width: f64) -> Option<&[usize]> { + let b = self.saved_breaks.as_deref()?; + let inside = b.starts.first().is_none_or(|s| *s > 0) && b.starts.last().is_none_or(|s| *s < self.char_count()); + let same_box = (b.width - width).abs() <= 1e-6 * width.abs().max(1.0); + (inside && same_box && b.starts.is_sorted_by(|a, b| a < b) && b.for_text == self.layout_key()).then_some(b.starts.as_slice()) + } + + /// How far text on a path paints past its path's box (glyphs stand on the path, lines after + /// the first run beside it): about one and a half of the largest size per line; 0 off a path. + pub fn path_reach(&self) -> f64 { + if self.path.is_none() { + return 0.0; + } + let lines = self.characters.lines().count().clamp(1, 10_000) as f64; + self.max_size() * 1.5 * lines + } + + /// How far vertically trimmed text paints past its box (descenders below the last baseline, + /// accents above the first cap height): half the largest size; 0 untrimmed. + pub fn trim_reach(&self) -> f64 { + if self.leading_trim == LeadingTrim::None { 0.0 } else { self.max_size() * 0.5 } + } + + /// The largest font size used (capped like layout caps it). + fn max_size(&self) -> f64 { + self.spans().iter().map(|(_, _, s)| s.font_size).filter(|v| v.is_finite()).fold(0.0, f64::max).min(5000.0) + } + + pub fn char_count(&self) -> usize { + self.characters.chars().count() + } + + /// Whether text that runs out of room ends in an ellipsis: a truncating box, or auto height + /// or auto width with a line limit (the box hugs up to `max_lines` lines: "Max lines is + /// available only if truncate text is enabled, and: Text resizing is set to auto height or + /// auto width"). + pub fn truncates(&self) -> bool { + match self.auto_resize { + TextAutoResize::Truncate => true, + TextAutoResize::Height | TextAutoResize::WidthAndHeight => self.max_lines > 0, + TextAutoResize::None => false, + } + } + + /// The resize mode once the box is given an explicit `width` and/or `height`: an explicit + /// height fixes the box, an explicit width turns auto width into auto height, and truncating + /// text keeps truncating. + pub fn fix_size(&mut self, width: bool, height: bool) { + self.auto_resize = match self.auto_resize { + _ if height && self.truncates() => TextAutoResize::Truncate, + _ if height => TextAutoResize::None, + TextAutoResize::WidthAndHeight if width => TextAutoResize::Height, + mode => mode, + }; + } + + /// The style at char index `i`. + pub fn style_at(&self, i: usize) -> &TextStyle { + self.runs.iter().find(|r| r.start <= i && i < r.end).map(|r| &r.style).unwrap_or(&self.style) + } + + /// Spans of uniform style covering the whole text: (start, end, style). + pub fn spans(&self) -> Vec<(usize, usize, &TextStyle)> { + let n = self.char_count(); + let mut out = Vec::new(); + let mut pos = 0; + let mut runs: Vec<&StyleRun> = self.runs.iter().filter(|r| r.start < r.end && r.start < n).collect(); + runs.sort_by_key(|r| r.start); + for r in runs { + let start = r.start.max(pos); + let end = r.end.min(n); + if start >= end { + continue; + } + if start > pos { + out.push((pos, start, &self.style)); + } + out.push((start, end, &r.style)); + pos = end; + } + if pos < n || out.is_empty() { + out.push((pos, n, &self.style)); + } + out + } + + /// Apply `f` to the style of chars `start..end` (all text when the range is the whole text or + /// empty text), splitting runs as needed and merging equal neighbours. + pub fn restyle(&mut self, start: usize, end: usize, f: impl Fn(&mut TextStyle)) { + let n = self.char_count(); + let (start, end) = (start.min(n), end.min(n)); + if n == 0 || (start == 0 && end >= n) { + f(&mut self.style); + for r in &mut self.runs { + f(&mut r.style); + } + self.normalize(); + return; + } + if start >= end { + return; + } + // Expand to per-char styles, modify, re-compress. + let mut per: Vec = (0..n).map(|i| self.style_at(i).clone()).collect(); + for s in per.iter_mut().take(end).skip(start) { + f(s); + } + self.runs.clear(); + let mut i = 0; + while i < n { + let Some(s) = per.get(i) else { break }; + let mut j = i + 1; + while per.get(j).is_some_and(|t| t == s) { + j += 1; + } + if *s != self.style { + self.runs.push(StyleRun { start: i, end: j, style: s.clone() }); + } + i = j; + } + } + + /// Drop empty/out-of-range runs and runs equal to the base style, and merge touching equal runs. + pub fn normalize(&mut self) { + let n = self.char_count(); + let base = self.style.clone(); + self.runs.retain(|r| r.start < r.end && r.start < n && r.style != base); + for r in &mut self.runs { + r.end = r.end.min(n); + } + self.runs.sort_by_key(|r| r.start); + let mut merged: Vec = Vec::with_capacity(self.runs.len()); + for mut r in std::mem::take(&mut self.runs) { + // Runs never overlap: a later run starts where the previous one ends at the earliest. + if let Some(prev) = merged.last() { + r.start = r.start.max(prev.end); + if r.start >= r.end { + continue; + } + } + match merged.last_mut() { + Some(prev) if prev.end == r.start && prev.style == r.style => prev.end = r.end, + _ => merged.push(r), + } + } + self.runs = merged; + } + + /// Replace chars `start..end` with `text`, shifting runs (inserted text takes the style at `start`). + pub fn replace_range(&mut self, start: usize, end: usize, text: &str) { + let n = self.char_count(); + let (start, end) = (start.min(n), end.min(n).max(start.min(n))); + let byte = |i: usize| self.characters.char_indices().nth(i).map(|(b, _)| b).unwrap_or(self.characters.len()); + let (bs, be) = (byte(start), byte(end)); + if !self.paragraphs.is_empty() { + // Paragraphs merged by removed newlines take the first one's list; new ones copy it. + let p = self.para_of(start); + let removed = self.characters.get(bs..be).unwrap_or("").matches('\n').count(); + let added = text.matches('\n').count(); + self.fill_paragraphs(); + let at = self.para_list(p); + let tail = (p + 1).min(self.paragraphs.len()); + self.paragraphs.drain(tail..(p + 1 + removed).min(self.paragraphs.len())); + self.paragraphs.splice(tail..tail, std::iter::repeat_n(at, added)); + } + let mut s = String::with_capacity(self.characters.len() + text.len()); + s.push_str(self.characters.get(..bs).unwrap_or("")); + s.push_str(text); + s.push_str(self.characters.get(be..).unwrap_or("")); + self.characters = s; + let inserted = text.chars().count(); + let removed = end - start; + for r in &mut self.runs { + let shift = |p: usize| -> usize { + if p <= start { + p + } else if p >= end { + // Runs of a file that reach past the text (nothing clamps them on load) + // would otherwise overflow here; `normalize` below drops what is left. + p.saturating_sub(removed).saturating_add(inserted) + } else { + start + inserted + } + }; + // A run touching the insertion point at its end grows with the typed text. + let new_start = shift(r.start); + let new_end = if r.end == start && r.start < start { start + inserted } else { shift(r.end) }; + r.start = new_start; + r.end = new_end; + } + self.normalize(); + } + + pub fn paragraph_count(&self) -> usize { + self.characters.matches('\n').count() + 1 + } + + /// The paragraph char index `i` is in. + pub fn para_of(&self, i: usize) -> usize { + self.characters.chars().take(i).filter(|c| *c == '\n').count() + } + + /// Paragraph `p`'s list settings. + pub fn para_list(&self, p: usize) -> ParaList { + self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, ..ParaList::default() }) + } + + /// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries). + fn fill_paragraphs(&mut self) { + let n = self.paragraph_count(); + let fill = ParaList { list: self.list, ..ParaList::default() }; + self.paragraphs.resize(n, fill); + } + + /// The paragraphs chars `start..end` touch (a caret: its paragraph; a selection ending just + /// after a newline leaves the next paragraph out). + pub fn para_span(&self, start: usize, end: usize) -> (usize, usize) { + let (s, e) = (start.min(end), start.max(end)); + let (a, b) = (self.para_of(s), self.para_of(e)); + let ends_at_start = e > s && b > a && self.characters.chars().nth(e - 1) == Some('\n'); + (a, if ends_at_start { b - 1 } else { b }) + } + + /// Apply `f` to the list settings of the paragraphs chars `start..end` touch (plain + /// paragraphs stay at level 0, only numbered ones keep a start, levels stop at + /// [`MAX_LIST_INDENT`]); one style for all with no nesting or start folds back into `list`. + pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) { + self.set_para_lists_with(start, end, false, f); + } + + /// Take the list items among the paragraphs chars `start..end` touch out of their list at the + /// level they had, as Backspace or Delete at an item's start does: "delete the counter, but + /// keep the same level of indentation" (Create bulleted and numbered lists). + pub fn unlist_keeping_levels(&mut self, start: usize, end: usize) { + self.set_para_lists_with(start, end, true, |pl| pl.list = ListStyle::None); + } + + fn set_para_lists_with(&mut self, start: usize, end: usize, keep_levels: bool, f: impl Fn(&mut ParaList)) { + let (a, b) = self.para_span(start, end); + self.set_para_lists_between(a, b, keep_levels, f); + } + + /// As [`set_para_lists`](Self::set_para_lists), for paragraphs `a..=b` addressed directly. + fn set_para_lists_between(&mut self, a: usize, b: usize, keep_levels: bool, f: impl Fn(&mut ParaList)) { + self.fill_paragraphs(); + for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) { + f(pl); + if pl.list == ListStyle::None && !keep_levels { + pl.indent = 0; + } + if pl.list != ListStyle::Numbered { + pl.start_offset = 0; + } + pl.indent = pl.indent.min(MAX_LIST_INDENT); + } + if let Some(first) = self.paragraphs.first().copied() + && first == (ParaList { list: first.list, ..ParaList::default() }) + && self.paragraphs.iter().all(|p| *p == first) + { + self.list = first.list; + self.paragraphs.clear(); + } + } + + /// The list style and level shared by the paragraphs chars `start..end` touch (the first + /// one's settings), or None when they differ. Start numbers don't count. + pub fn common_para_list(&self, start: usize, end: usize) -> Option { + let (a, b) = self.para_span(start, end); + let first = self.para_list(a); + (a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first) + } + + /// What a text style made from this text sets at the paragraph and layer level: the paragraph + /// spacing and indent, the list style (the one every paragraph shares; the base one when they + /// differ) and the OpenType features. + pub fn paragraph_style(&self) -> ParagraphStyle { + let list = match self.paragraphs.first() { + Some(first) if self.paragraphs.iter().all(|p| p.list == first.list) => first.list, + _ => self.list, + }; + ParagraphStyle { paragraph_spacing: self.paragraph_spacing, paragraph_indent: self.paragraph_indent, list, features: self.features.clone() } + } + + /// Take what text style `ps` sets at the paragraph and layer level: the spacing, indent and + /// features on the layer, the list style on every paragraph. + pub fn take_paragraph_style(&mut self, ps: &ParagraphStyle) { + self.paragraph_spacing = ps.paragraph_spacing; + self.paragraph_indent = ps.paragraph_indent; + self.features.clone_from(&ps.features); + // Every paragraph by index: `para_span` leaves out an empty one after a final newline. + let last = self.paragraph_count() - 1; + self.set_para_lists_between(0, last, false, |pl| pl.list = ps.list); + } + + /// As [`take_paragraph_style`](Self::take_paragraph_style), with the list style going to the + /// paragraphs chars `start..end` touch only; the spacing, indent and features are the + /// layer's, as in Figma. + pub fn take_paragraph_style_in(&mut self, start: usize, end: usize, ps: &ParagraphStyle) { + self.paragraph_spacing = ps.paragraph_spacing; + self.paragraph_indent = ps.paragraph_indent; + self.features.clone_from(&ps.features); + self.set_para_lists(start, end, |pl| pl.list = ps.list); + } + + /// Follow text style `id`'s paragraph-level definition wherever chars link to it. + pub fn update_paragraph_style(&mut self, id: &str, ps: &ParagraphStyle) { + let linked: Vec<(usize, usize)> = + self.spans().into_iter().filter(|(_, _, s)| s.style_id.as_deref() == Some(id)).map(|(a, b, _)| (a, b)).collect(); + for (a, b) in linked { + self.take_paragraph_style_in(a, b, ps); + } + } + + /// Whether chars `start..end` show what text style `ps` sets at the paragraph and layer + /// level: the layer's spacing, indent and features, and their paragraphs' list style. + pub fn shows_paragraph_style_in(&self, start: usize, end: usize, ps: &ParagraphStyle) -> bool { + self.paragraph_spacing == ps.paragraph_spacing + && self.paragraph_indent == ps.paragraph_indent + && self.features == ps.features + && self.common_para_list(start, end).is_some_and(|pl| pl.list == ps.list) + } + + /// Whether the whole text shows what text style `ps` sets at the paragraph and layer level. + pub fn shows_paragraph_style(&self, ps: &ParagraphStyle) -> bool { + self.shows_paragraph_style_in(0, self.char_count(), ps) + } + + /// Link chars `start..end` (all of them when the range is empty or the whole text) to text + /// style `id`, defined as `def`. + pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) { + let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) }; + self.restyle(start, end, |s| s.take_text_style(def, id)); + } + + /// Unlink chars `start..end` (all when empty or whole) from their text styles; values stay. + pub fn unlink_text_style(&mut self, start: usize, end: usize) { + let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) }; + self.restyle(start, end, |s| s.style_id = None); + } + + /// The text style every char is linked to: `Some(None)` when none is, None when they differ. + pub fn text_style_link(&self) -> Option> { + let mut spans = self.spans().into_iter().map(|(_, _, s)| s.style_id.as_deref()); + let first = spans.next()?; + spans.all(|s| s == first).then_some(first) + } + + /// Apply `f` to every distinct style (the base and each run) and merge runs that became equal. + pub(crate) fn each_style(&mut self, mut f: impl FnMut(&mut TextStyle)) { + f(&mut self.style); + for r in &mut self.runs { + f(&mut r.style); + } + self.normalize(); + } + + /// Whether any char links to text style `id`. + pub fn links_text_style(&self, id: &str) -> bool { + std::iter::once(&self.style).chain(self.runs.iter().map(|r| &r.style)).any(|s| s.style_id.as_deref() == Some(id)) + } + + /// Follow text style `id`'s new definition wherever chars link to it. + pub fn update_text_style(&mut self, id: &str, def: &TextStyle) { + self.each_style(|s| { + if s.style_id.as_deref() == Some(id) { + s.take_text_style(def, id); + } + }); + } + + /// Point text style links elsewhere: `f(id)` gives the new id, None unlinks (values stay). + pub fn map_text_styles(&mut self, f: impl Fn(&str) -> Option) { + self.each_style(|s| s.style_id = s.style_id.as_deref().and_then(&f)); + } + + /// Unlink chars whose style is gone (`def` gives None) or that no longer show it (edited since). + pub fn check_text_styles<'a>(&mut self, def: impl Fn(&str) -> Option<&'a TextStyle>) { + self.each_style(|s| { + if s.style_id.as_deref().is_some_and(|id| !def(id).is_some_and(|d| s.shows_text_style(d))) { + s.style_id = None; + } + }); + } + + /// Whether every char shares one style (the panel shows "Mixed" otherwise). + pub fn is_uniform(&self) -> bool { + self.runs.is_empty() + } +} + +#[cfg(test)] +mod case_tests { + use super::*; + + #[test] + fn trim_num_prints_huge_whole_numbers() { + assert_eq!(trim_num(1e19), "10000000000000000000"); + assert_eq!(trim_num(-1e19), "-10000000000000000000"); + assert_eq!(trim_num(12.0), "12"); + let v = 12345678901234567890.0_f64; + assert_eq!(trim_num(v).parse::().unwrap(), v); + } + + #[test] + fn title_case_respects_context() { + assert_eq!(TextCase::Title.apply("hello wORLD don't 3d x-ray"), "Hello WORLD Don't 3d X-Ray"); + // Continuing a word from a previous run: no capital. + assert_eq!(TextCase::Title.apply_after("lo world", Some('l')), "lo World"); + assert_eq!(TextCase::Title.apply_after("lo", Some(' ')), "Lo"); + assert_eq!(TextCase::Upper.apply_after("ß", Some('a')), "SS"); + assert!(valid_feature_tag("liga") && valid_feature_tag("ss01")); + assert!(!valid_feature_tag("lig") && !valid_feature_tag("ligatures") && !valid_feature_tag("li\u{e9}")); + } + + #[test] + fn new_text_starts_in_the_builds_default_font() { + // Inter, unless the build names another with `FIGCRAFT_DEFAULT_FONT` (empty means Inter). + let named = option_env!("FIGCRAFT_DEFAULT_FONT").filter(|f| !f.is_empty()); + assert_eq!(DEFAULT_FONT, named.unwrap_or("Inter")); + let st = TextStyle::default(); + assert_eq!(st.font_family, DEFAULT_FONT); + // The family is always written, so a saved file keeps it in a build with another default. + let saved = serde_json::to_value(&st).unwrap(); + assert_eq!(saved["font_family"], DEFAULT_FONT); + } + + #[test] + fn variations_are_backward_compatible_and_imply_weight() { + // Files written before variable fonts load with no custom axes, and stay byte-identical. + let old = r#"{"font_family":"Inter","font_style":"Bold","font_size":12.0}"#; + let st: TextStyle = serde_json::from_str(old).unwrap(); + assert!(st.variations.is_empty()); + assert!(!serde_json::to_string(&st).unwrap().contains("variations")); + let mut st = TextStyle { font_style: "Regular".into(), ..Default::default() }; + st.variations.insert("wght".into(), 640.0); + st.variations.insert("wdth".into(), 80.0); + assert_eq!(st.weight(), 640); + assert!(!st.italic()); + st.variations.insert("slnt".into(), -8.0); + assert!(st.italic()); + let round: TextStyle = serde_json::from_str(&serde_json::to_string(&st).unwrap()).unwrap(); + assert_eq!(round, st); + // Picking a named style drops the axes its name implies, keeps the others. + st.set_font_style("Bold"); + assert_eq!(st.weight(), 700); + assert_eq!(st.variations.keys().collect::>(), ["wdth"]); + assert!(valid_axis_tag("wght") && !valid_axis_tag("weight")); + } + + #[test] + fn list_starts_load_from_old_files_and_follow_edits() { + use ListStyle::{Bulleted as B, None as P, Numbered as N}; + // Files written before start numbers load with none, and write the same bytes. + let old = r#"{"list":"numbered","indent":1}"#; + let pl: ParaList = serde_json::from_str(old).unwrap(); + assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 }); + assert_eq!(serde_json::to_string(&pl).unwrap(), old); + // One numbered paragraph with a start keeps it (it doesn't fold into `list`). + let mut t = TextData { characters: "Next".into(), ..Default::default() }; + t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 }); + assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 }); + // Enter copies it to the new item; different starts don't make the list mixed. + t.replace_range(4, 4, "\nMore"); + assert_eq!(t.para_list(1).start_offset, 1); + t.set_para_lists(5, 5, |p| p.start_offset = 0); + assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N)); + // Bullets have no number, so no start. + t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 }); + assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 }); + // A plain paragraph has none. + t.set_para_lists(0, 0, |p| p.list = P); + assert_eq!(t.para_list(0), ParaList::default()); + } +} + +#[cfg(test)] +mod title_case_tests { + use super::TextCase; + + #[test] + fn title_case_after_opening_apostrophe() { + assert_eq!(TextCase::Title.apply("'hello world'"), "'Hello World'"); + assert_eq!(TextCase::Title.apply("say 'hi'"), "Say 'Hi'"); + assert_eq!(TextCase::Title.apply("don't"), "Don't"); + assert_eq!(TextCase::Title.apply("\u{2019}hello"), "\u{2019}Hello"); + } + + #[test] + fn title_case_between_runs_sees_char_before_apostrophe() { + assert_eq!(TextCase::Title.apply_after("t", Some('\'')), "t"); + assert_eq!(TextCase::Title.apply_between("hello", None, Some('\'')), "Hello"); + assert_eq!(TextCase::Title.apply_between("t", Some('n'), Some('\'')), "t"); + assert_eq!(TextCase::Title.apply_between("t", Some(' '), Some('\u{2019}')), "T"); + } + + #[test] + fn title_case_combining_marks_do_not_break_words() { + assert_eq!(TextCase::Title.apply("re\u{301}sume\u{301}"), "Re\u{301}sume\u{301}"); + assert_eq!(TextCase::Title.apply("re\u{301}sume\u{301} bon"), "Re\u{301}sume\u{301} Bon"); + assert_eq!(TextCase::Title.apply("a \u{301}b"), "A \u{301}B"); + assert_eq!(TextCase::Title.apply("cafe\u{301}'s"), "Cafe\u{301}'s"); + } +} + +#[cfg(test)] +mod whole_text_paragraph_style_tests { + use super::*; + + #[test] + fn a_text_style_on_the_whole_layer_reaches_a_trailing_empty_paragraph() { + let ps = ParagraphStyle { list: ListStyle::Bulleted, ..Default::default() }; + let mut t = TextData::new("a\nb\n"); + t.take_paragraph_style(&ps); + assert_eq!(t.paragraph_count(), 3); + assert_eq!(t.para_list(2).list, ListStyle::Bulleted); + assert_eq!(t.list, ListStyle::Bulleted); + assert!(t.paragraphs.is_empty()); + let mut plain = TextData::new("a\nb"); + plain.take_paragraph_style(&ps); + assert_eq!((plain.list, plain.paragraphs.len()), (t.list, t.paragraphs.len())); + } +} From 9e356096e382abc60f0f26e1d518c9affa4f3cd1 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Thu, 5 Nov 2026 17:22:53 -0500 Subject: [PATCH 3/4] text.rs keeps its LF line endings The previous commit saved the file with CRLF endings; only the 30 changed lines are a real change. Co-Authored-By: Claude Fable 5.1 --- crates/doc/src/text.rs | 2274 ++++++++++++++++++++-------------------- 1 file changed, 1137 insertions(+), 1137 deletions(-) diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index f4d087589..7183efbf6 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -1,1137 +1,1137 @@ -//! Text node data: characters plus a base style and styled runs. - -use figcraft_color::{Color, Paint}; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -#[serde(tag = "unit", content = "value", rename_all = "camelCase")] -#[derive(Default)] -pub enum LineHeight { - #[default] - Auto, - Pixels(f64), - Percent(f64), -} - -impl LineHeight { - /// Resolved line height in px for `font_size` (`auto` uses the font's natural ratio). - pub fn resolve(self, font_size: f64, natural_ratio: f64) -> f64 { - match self { - LineHeight::Auto => font_size * natural_ratio, - LineHeight::Pixels(v) => v.max(0.0), - LineHeight::Percent(p) => font_size * p.max(0.0) / 100.0, - } - } - pub fn label(self) -> String { - match self { - LineHeight::Auto => "Auto".into(), - LineHeight::Pixels(v) => trim_num(v), - LineHeight::Percent(p) => format!("{}%", trim_num(p)), - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -#[serde(tag = "unit", content = "value", rename_all = "camelCase")] -pub enum LetterSpacing { - Pixels(f64), - Percent(f64), -} - -impl Default for LetterSpacing { - fn default() -> Self { - LetterSpacing::Percent(0.0) - } -} - -impl LetterSpacing { - pub fn resolve(self, font_size: f64) -> f64 { - match self { - LetterSpacing::Pixels(v) => v, - LetterSpacing::Percent(p) => font_size * p / 100.0, - } - } - pub fn label(self) -> String { - match self { - LetterSpacing::Pixels(v) => trim_num(v), - LetterSpacing::Percent(p) => format!("{}%", trim_num(p)), - } - } -} - -pub fn trim_num(v: f64) -> String { - if v.abs() >= 9.0e18 { - return format!("{v}"); - } - let r = (v * 100.0).round() / 100.0; - if r.fract().abs() < 1e-9 { format!("{}", r as i64) } else { format!("{r}") } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum TextDecoration { - #[default] - None, - Underline, - Strikethrough, -} - -/// How an underline or strikethrough is drawn. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum DecorationStyle { - #[default] - Solid, - Dotted, - Wavy, -} - -/// A decoration length: `Auto` (the font's own value), pixels, or a percentage of the font size. -pub type DecorationLength = LineHeight; - -/// How a decoration line looks; the default is the font's own solid line in the text's colour. -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct DecorationLook { - pub style: DecorationStyle, - pub thickness: DecorationLength, - /// Underlines: from the baseline down to the line's top edge (negative: above the baseline). - pub offset: DecorationLength, - /// Underlines break where descenders cross them. - pub skip_ink: bool, - /// The line's own colour (None: the text's fills). - pub color: Option, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum TextCase { - #[default] - Original, - Upper, - Lower, - Title, - SmallCaps, -} - -impl TextCase { - pub fn apply(self, s: &str) -> String { - self.apply_after(s, None) - } - - /// Apply the case to `s`, which follows `prev` in the text (so title case only capitalises - /// the first letter of each word even when a word spans several styled runs). An apostrophe - /// as `prev` is taken to continue a word. - pub fn apply_after(self, s: &str, prev: Option) -> String { - self.apply_impl(s, prev, prev.is_some_and(is_word_char)) - } - - /// Like [`apply_after`](Self::apply_after), also given the char `before` `prev`: an apostrophe - /// only continues a word when a letter or digit precedes it. - pub fn apply_between(self, s: &str, before: Option, prev: Option) -> String { - let continues = prev.is_some_and(|c| is_word_char(c) && (c.is_alphanumeric() || before.is_some_and(char::is_alphanumeric))); - self.apply_impl(s, prev, continues) - } - - /// `in_word` says whether `prev` continues a word (rather than a new word starting here). - fn apply_impl(self, s: &str, prev: Option, in_word: bool) -> String { - match self { - TextCase::Original | TextCase::SmallCaps => s.to_string(), - TextCase::Upper => s.to_uppercase(), - TextCase::Lower => s.to_lowercase(), - TextCase::Title => { - let mut out = String::with_capacity(s.len()); - let mut start = !in_word; - let mut last = prev; - for ch in s.chars() { - if start && ch.is_alphanumeric() { - out.extend(ch.to_uppercase()); - start = false; - } else { - out.push(ch); - // An apostrophe only continues a word right after a letter or digit. - let opening_apostrophe = - matches!(ch, '\'' | '\u{2019}') && !last.is_some_and(|c| c.is_alphanumeric() || is_combining_mark(c)); - // A combining mark attaches to the char before it and never changes word state. - if !is_combining_mark(ch) { - start = !is_word_char(ch) || opening_apostrophe; - } - } - last = Some(ch); - } - out - } - } - } - - /// Whether the char at a position preceded by `prev` starts a word (for title case). - pub fn starts_word(prev: Option) -> bool { - prev.is_none_or(|c| !is_word_char(c)) - } -} - -/// Letters, digits, combining marks and apostrophes continue a word (so "don't" stays "Don't" -/// and decomposed accents stay inside their word). -fn is_word_char(c: char) -> bool { - c.is_alphanumeric() || is_combining_mark(c) || matches!(c, '\'' | '\u{2019}') -} - -/// Combining marks such as the U+0301 accent in decomposed text: non-alphanumeric chars that -/// extend the grapheme of the char before them. -fn is_combining_mark(c: char) -> bool { - use unicode_segmentation::UnicodeSegmentation; - if c.is_ascii() || c.is_alphanumeric() { - return false; - } - let mut buf = [0u8; 8]; - buf[0] = b'a'; - let end = 1 + c.encode_utf8(&mut buf[1..]).len(); - std::str::from_utf8(&buf[..end]).is_ok_and(|s| s.graphemes(true).count() == 1) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum TextAlignH { - #[default] - Left, - Center, - Right, - Justified, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum TextAlignV { - #[default] - Top, - Middle, - Bottom, -} - -/// How a text box resizes as its content changes. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum TextAutoResize { - /// Auto width: one line per paragraph, box grows both ways. - #[default] - WidthAndHeight, - /// Fixed width, height grows. - Height, - /// Fixed size. - None, - /// Fixed size, truncate with an ellipsis. - Truncate, -} - -/// Vertical trim: what bounds a text box vertically. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum LeadingTrim { - /// The full line height of the first and last lines. - #[default] - None, - /// From the first line's cap height to the last line's baseline. - CapHeight, -} - -/// Where lines break when text wraps to more than one line ("Wrap style controls where lines -/// break when text wraps to more than one line", Explore text properties). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum WrapStyle { - /// "Figma fits as many words as possible on each line before wrapping to the next." - #[default] - Auto, - /// "Figma distributes text as evenly as possible across every line, so each line is close to - /// the same length." - Balance, - /// "Figma keeps each line full, but adjusts the last four lines to avoid an orphan -- a single - /// word stranded alone on the last line." - Pretty, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum ListStyle { - #[default] - None, - Bulleted, - Numbered, -} - -/// Deepest list nesting (levels 0..=MAX_LIST_INDENT). -pub const MAX_LIST_INDENT: u8 = 8; - -/// One paragraph's list settings. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct ParaList { - pub list: ListStyle, - /// Nesting level (0 = outermost). A plain paragraph is 0, except one that left its list by - /// Backspace or Delete at its start ([`TextData::unlist_keeping_levels`]): it keeps the level - /// it had and starts where that level's item text does ("delete the counter, but keep the - /// same level of indentation"). - pub indent: u8, - /// A numbered list that starts counting at this paragraph counts from `start_offset + 1` - /// (items that go on counting ignore it; other paragraphs are always 0). - #[serde(skip_serializing_if = "crate::node::is_default")] - pub start_offset: u32, -} - -/// Character style. In runs, every field is an override; `None` means "inherit the base". -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct TextStyle { - pub font_family: String, - /// The style name, e.g. "Regular", "Bold", "Semi Bold Italic". - pub font_style: String, - pub font_size: f64, - pub line_height: LineHeight, - pub letter_spacing: LetterSpacing, - pub decoration: TextDecoration, - /// How the decoration looks (None: the default look; boxed to keep styles small). - #[serde(skip_serializing_if = "Option::is_none")] - pub decoration_look: Option>, - pub case: TextCase, - /// Run fills (None = node fills). - pub fills: Option>, - /// Hyperlink. - pub link: Option, - /// Variable font axis values in user space, keyed by 4-char OpenType axis tag (e.g. `wght`: - /// 550, `wdth`: 85). Empty = what the style name implies; axes the face lacks are ignored. - #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")] - pub variations: std::collections::BTreeMap, - /// The text style (shared style id) these chars are linked to. A layer's chars can link to - /// different styles; editing what a style sets unlinks the chars edited. - #[serde(skip_serializing_if = "Option::is_none")] - pub style_id: Option, -} - -/// What a text style sets at the paragraph and layer level, beside its character style -/// ([`TextStyle`]): paragraph spacing and indent, the list style and the OpenType features -/// ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType -/// features", Create and apply text styles). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct ParagraphStyle { - #[serde(skip_serializing_if = "crate::node::is_default")] - pub paragraph_spacing: f64, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub paragraph_indent: f64, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub list: ListStyle, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub features: std::collections::BTreeMap, -} - -/// The font new text starts in. A build can name another (`FIGCRAFT_DEFAULT_FONT="Noto Sans Thai"`, -/// for a distribution whose users mostly write a script Inter lacks); empty means Inter. -pub const DEFAULT_FONT: &str = match option_env!("FIGCRAFT_DEFAULT_FONT") { - Some(family) if !family.is_empty() => family, - _ => "Inter", -}; - -impl Default for TextStyle { - fn default() -> Self { - Self { - font_family: DEFAULT_FONT.into(), - font_style: "Regular".into(), - font_size: 12.0, - line_height: LineHeight::Auto, - letter_spacing: LetterSpacing::default(), - decoration: TextDecoration::None, - decoration_look: None, - case: TextCase::Original, - fills: None, - link: None, - variations: std::collections::BTreeMap::new(), - style_id: None, - } - } -} - -/// Axes a style name implies (weight, italic, slant): picking a named style clears their custom values. -pub const STYLE_AXES: [&str; 3] = ["wght", "ital", "slnt"]; - -impl TextStyle { - /// Numeric weight: a custom `wght` axis value, else the one implied by the style name. - pub fn weight(&self) -> u16 { - match self.variations.get("wght") { - Some(w) if w.is_finite() => w.round().clamp(1.0, 1000.0) as u16, - _ => style_weight(&self.font_style), - } - } - pub fn italic(&self) -> bool { - self.font_style.to_ascii_lowercase().contains("italic") - || self.variations.get("ital").is_some_and(|v| *v >= 0.5) - || self.variations.get("slnt").is_some_and(|v| *v != 0.0) - } - - /// Take what text style `def` sets (font, size, line height, letter spacing, decoration, case - /// and axis values) and link to it as `id`; fills and hyperlinks stay. - pub fn take_text_style(&mut self, def: &TextStyle, id: &str) { - *self = TextStyle { fills: self.fills.take(), link: self.link.take(), style_id: Some(id.to_string()), ..def.clone() }; - } - - /// Whether these chars show what text style `def` sets. - pub fn shows_text_style(&self, def: &TextStyle) -> bool { - self.font_family == def.font_family - && self.font_style == def.font_style - && self.font_size == def.font_size - && self.line_height == def.line_height - && self.letter_spacing == def.letter_spacing - && self.decoration == def.decoration - && self.decoration_look == def.decoration_look - && self.case == def.case - && self.variations == def.variations - } - - /// This style as a text style's definition (linked to nothing). - pub fn as_text_style(&self) -> TextStyle { - TextStyle { style_id: None, link: None, ..self.clone() } - } - - /// How the decoration looks. - pub fn look(&self) -> DecorationLook { - self.decoration_look.as_deref().copied().unwrap_or_default() - } - - /// Change how the decoration looks (the default look is stored as None). - pub fn set_look(&mut self, f: impl FnOnce(&mut DecorationLook)) { - let mut look = self.look(); - f(&mut look); - self.decoration_look = (look != DecorationLook::default()).then(|| Box::new(look)); - } - - /// Switch to the named style, dropping custom values of the axes the name implies. - pub fn set_font_style(&mut self, name: impl Into) { - self.font_style = name.into(); - for tag in STYLE_AXES { - self.variations.remove(tag); - } - } -} - -/// Weight from a style name ("Semi Bold Italic" → 600). -pub fn style_weight(style: &str) -> u16 { - let s = style.to_ascii_lowercase().replace([' ', '-'], ""); - // A weight number ("600", "600 Italic"), as some apps name styles. - let digits: String = s.chars().take_while(char::is_ascii_digit).collect(); - if let Ok(n) = digits.parse::() - && (1..=1000).contains(&n) - { - return n; - } - let table: [(&str, u16); 10] = [ - ("thin", 100), - ("extralight", 200), - ("ultralight", 200), - ("light", 300), - ("medium", 500), - ("semibold", 600), - ("demibold", 600), - ("extrabold", 800), - ("ultrabold", 800), - ("black", 900), - ]; - for (k, w) in table { - if s.contains(k) { - return w; - } - } - if s.contains("bold") { - return 700; - } - if s.contains("heavy") { - return 900; - } - 400 -} - -/// A styled range of characters (char indices, end exclusive). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct StyleRun { - pub start: usize, - pub end: usize, - pub style: TextStyle, -} - -/// Text node content. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct TextData { - pub characters: String, - pub style: TextStyle, - /// Ranges whose style differs from `style`; full styles (already merged), non-overlapping, sorted. - #[serde(skip_serializing_if = "crate::node::is_default")] - pub runs: Vec, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub auto_resize: TextAutoResize, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub align_h: TextAlignH, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub align_v: TextAlignV, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub paragraph_spacing: f64, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub paragraph_indent: f64, - /// The list style of paragraphs `paragraphs` doesn't cover (all of them when it's empty). - #[serde(skip_serializing_if = "crate::node::is_default")] - pub list: ListStyle, - /// Per-paragraph list settings, by paragraph index; empty when every paragraph is `list` at - /// level 0. Kept in step with the characters by [`TextData::replace_range`]. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub paragraphs: Vec, - /// Space between two items of a list (px), in place of the paragraph spacing. - #[serde(skip_serializing_if = "crate::node::is_default")] - pub list_spacing: f64, - /// Opening and closing punctuation at a line's aligned edge sits outside the box. - #[serde(skip_serializing_if = "crate::node::is_default")] - pub hanging_punctuation: bool, - /// List bullets and numbers sit outside the box; item text lines up with plain paragraphs. - #[serde(skip_serializing_if = "crate::node::is_default")] - pub hanging_list: bool, - /// Vertical trim (`capHeight`: the box runs from the first line's cap height to the last - /// line's baseline). - #[serde(skip_serializing_if = "crate::node::is_default")] - pub leading_trim: LeadingTrim, - /// Max lines when truncating, or before an auto-height text ends in an ellipsis (0 = as many - /// as fit). - #[serde(skip_serializing_if = "crate::node::is_default")] - pub max_lines: u32, - /// Where lines break when the text wraps: greedily (Auto), as evenly as the words allow - /// (Balance), or full with no one word alone on the last line (Pretty). - #[serde(skip_serializing_if = "crate::node::is_default")] - pub wrap_style: WrapStyle, - /// OpenType feature toggles (e.g. "liga": false). - #[serde(skip_serializing_if = "crate::node::is_default")] - pub features: std::collections::BTreeMap, - /// The path the text follows (text on a path); `None`: lines in the box. Boxed to keep text - /// data small. - #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option>, - /// Where the file this text came from wrapped its lines (an imported `.fig`'s saved layout). - /// Layout follows them only while the box width and everything that moves a line break are - /// as they were saved, so an edit that could change the wrap drops them. - #[serde(skip_serializing_if = "Option::is_none")] - pub saved_breaks: Option>, -} - -/// Line breaks a file saved with a text: the chars that start a wrapped line (not those after a -/// hard break), for the box width and the content ([`TextData::layout_key`]) they were made for. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct SavedBreaks { - pub starts: Vec, - pub width: f64, - pub for_text: u64, -} - -/// Text on a path: the baseline follows `d`. The text starts at `start` (aligned there by the -/// horizontal alignment: left starts, centre centres, right ends at it); glyphs past an open -/// path's ends are hidden, a closed path wraps them around. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct TextPath { - /// SVG path data in the layer's own space. - pub d: String, - /// Where the text starts along the path, 0..1 by length. - pub start: f64, - /// On the other side of the path, running the other way. - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub flip: bool, -} - -impl TextPath { - /// A text path following `path` (layer space). - pub fn new(path: &figcraft_geom::BezPath) -> Self { - Self { d: path.to_svg(), start: 0.0, flip: false } - } - - /// The path (empty when `d` doesn't parse). - pub fn path(&self) -> figcraft_geom::BezPath { - figcraft_geom::BezPath::from_svg(&self.d).unwrap_or_default() - } - - /// The start offset clamped into 0..1 (NaN: 0). - pub fn start(&self) -> f64 { - if self.start.is_finite() { self.start.clamp(0.0, 1.0) } else { 0.0 } - } - - /// Scale the path by `sx`, `sy` (the layer was resized). - pub fn scale(&mut self, sx: f64, sy: f64) { - if sx.is_finite() && sy.is_finite() { - let mut p = self.path(); - p.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy)); - self.d = p.to_svg(); - } - } -} - -/// Whether `tag` is a valid OpenType feature tag (four printable ASCII chars). -pub fn valid_feature_tag(tag: &str) -> bool { - tag.len() == 4 && tag.bytes().all(|b| (0x20..0x7f).contains(&b)) -} - -/// Whether `tag` is a valid OpenType variation axis tag (same shape as a feature tag). -pub fn valid_axis_tag(tag: &str) -> bool { - valid_feature_tag(tag) -} - -impl TextData { - pub fn new(characters: impl Into) -> Self { - Self { characters: characters.into(), ..Default::default() } - } - - /// A stable hash of what decides where lines break: everything but paints, links, style links, - /// decorations, vertical settings and the saved breaks themselves (FNV-1a over the JSON, so it - /// stays the same across builds and can be saved). - pub fn layout_key(&self) -> u64 { - let clear = |s: &mut TextStyle| { - (s.fills, s.link, s.style_id, s.decoration, s.decoration_look) = (None, None, None, TextDecoration::None, None); - }; - let mut t = TextData { - align_v: TextAlignV::default(), - paragraph_spacing: 0.0, - list_spacing: 0.0, - leading_trim: LeadingTrim::default(), - max_lines: 0, - saved_breaks: None, - ..self.clone() - }; - clear(&mut t.style); - t.runs.iter_mut().for_each(|r| clear(&mut r.style)); - t.normalize(); - serde_json::to_vec(&t).unwrap_or_default().iter().fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3)) - } - - /// Keep `starts` (chars that start a wrapped line) as this text's saved line breaks in a box - /// `width` wide. - pub fn save_breaks(&mut self, starts: Vec, width: f64) { - self.saved_breaks = Some(Box::new(SavedBreaks { starts, width, for_text: self.layout_key() })); - } - - /// The saved line starts (empty: no wrapped line) while they still hold: the same box width and - /// layout content, and starts increasing inside the text. - pub fn saved_breaks_for(&self, width: f64) -> Option<&[usize]> { - let b = self.saved_breaks.as_deref()?; - let inside = b.starts.first().is_none_or(|s| *s > 0) && b.starts.last().is_none_or(|s| *s < self.char_count()); - let same_box = (b.width - width).abs() <= 1e-6 * width.abs().max(1.0); - (inside && same_box && b.starts.is_sorted_by(|a, b| a < b) && b.for_text == self.layout_key()).then_some(b.starts.as_slice()) - } - - /// How far text on a path paints past its path's box (glyphs stand on the path, lines after - /// the first run beside it): about one and a half of the largest size per line; 0 off a path. - pub fn path_reach(&self) -> f64 { - if self.path.is_none() { - return 0.0; - } - let lines = self.characters.lines().count().clamp(1, 10_000) as f64; - self.max_size() * 1.5 * lines - } - - /// How far vertically trimmed text paints past its box (descenders below the last baseline, - /// accents above the first cap height): half the largest size; 0 untrimmed. - pub fn trim_reach(&self) -> f64 { - if self.leading_trim == LeadingTrim::None { 0.0 } else { self.max_size() * 0.5 } - } - - /// The largest font size used (capped like layout caps it). - fn max_size(&self) -> f64 { - self.spans().iter().map(|(_, _, s)| s.font_size).filter(|v| v.is_finite()).fold(0.0, f64::max).min(5000.0) - } - - pub fn char_count(&self) -> usize { - self.characters.chars().count() - } - - /// Whether text that runs out of room ends in an ellipsis: a truncating box, or auto height - /// or auto width with a line limit (the box hugs up to `max_lines` lines: "Max lines is - /// available only if truncate text is enabled, and: Text resizing is set to auto height or - /// auto width"). - pub fn truncates(&self) -> bool { - match self.auto_resize { - TextAutoResize::Truncate => true, - TextAutoResize::Height | TextAutoResize::WidthAndHeight => self.max_lines > 0, - TextAutoResize::None => false, - } - } - - /// The resize mode once the box is given an explicit `width` and/or `height`: an explicit - /// height fixes the box, an explicit width turns auto width into auto height, and truncating - /// text keeps truncating. - pub fn fix_size(&mut self, width: bool, height: bool) { - self.auto_resize = match self.auto_resize { - _ if height && self.truncates() => TextAutoResize::Truncate, - _ if height => TextAutoResize::None, - TextAutoResize::WidthAndHeight if width => TextAutoResize::Height, - mode => mode, - }; - } - - /// The style at char index `i`. - pub fn style_at(&self, i: usize) -> &TextStyle { - self.runs.iter().find(|r| r.start <= i && i < r.end).map(|r| &r.style).unwrap_or(&self.style) - } - - /// Spans of uniform style covering the whole text: (start, end, style). - pub fn spans(&self) -> Vec<(usize, usize, &TextStyle)> { - let n = self.char_count(); - let mut out = Vec::new(); - let mut pos = 0; - let mut runs: Vec<&StyleRun> = self.runs.iter().filter(|r| r.start < r.end && r.start < n).collect(); - runs.sort_by_key(|r| r.start); - for r in runs { - let start = r.start.max(pos); - let end = r.end.min(n); - if start >= end { - continue; - } - if start > pos { - out.push((pos, start, &self.style)); - } - out.push((start, end, &r.style)); - pos = end; - } - if pos < n || out.is_empty() { - out.push((pos, n, &self.style)); - } - out - } - - /// Apply `f` to the style of chars `start..end` (all text when the range is the whole text or - /// empty text), splitting runs as needed and merging equal neighbours. - pub fn restyle(&mut self, start: usize, end: usize, f: impl Fn(&mut TextStyle)) { - let n = self.char_count(); - let (start, end) = (start.min(n), end.min(n)); - if n == 0 || (start == 0 && end >= n) { - f(&mut self.style); - for r in &mut self.runs { - f(&mut r.style); - } - self.normalize(); - return; - } - if start >= end { - return; - } - // Expand to per-char styles, modify, re-compress. - let mut per: Vec = (0..n).map(|i| self.style_at(i).clone()).collect(); - for s in per.iter_mut().take(end).skip(start) { - f(s); - } - self.runs.clear(); - let mut i = 0; - while i < n { - let Some(s) = per.get(i) else { break }; - let mut j = i + 1; - while per.get(j).is_some_and(|t| t == s) { - j += 1; - } - if *s != self.style { - self.runs.push(StyleRun { start: i, end: j, style: s.clone() }); - } - i = j; - } - } - - /// Drop empty/out-of-range runs and runs equal to the base style, and merge touching equal runs. - pub fn normalize(&mut self) { - let n = self.char_count(); - let base = self.style.clone(); - self.runs.retain(|r| r.start < r.end && r.start < n && r.style != base); - for r in &mut self.runs { - r.end = r.end.min(n); - } - self.runs.sort_by_key(|r| r.start); - let mut merged: Vec = Vec::with_capacity(self.runs.len()); - for mut r in std::mem::take(&mut self.runs) { - // Runs never overlap: a later run starts where the previous one ends at the earliest. - if let Some(prev) = merged.last() { - r.start = r.start.max(prev.end); - if r.start >= r.end { - continue; - } - } - match merged.last_mut() { - Some(prev) if prev.end == r.start && prev.style == r.style => prev.end = r.end, - _ => merged.push(r), - } - } - self.runs = merged; - } - - /// Replace chars `start..end` with `text`, shifting runs (inserted text takes the style at `start`). - pub fn replace_range(&mut self, start: usize, end: usize, text: &str) { - let n = self.char_count(); - let (start, end) = (start.min(n), end.min(n).max(start.min(n))); - let byte = |i: usize| self.characters.char_indices().nth(i).map(|(b, _)| b).unwrap_or(self.characters.len()); - let (bs, be) = (byte(start), byte(end)); - if !self.paragraphs.is_empty() { - // Paragraphs merged by removed newlines take the first one's list; new ones copy it. - let p = self.para_of(start); - let removed = self.characters.get(bs..be).unwrap_or("").matches('\n').count(); - let added = text.matches('\n').count(); - self.fill_paragraphs(); - let at = self.para_list(p); - let tail = (p + 1).min(self.paragraphs.len()); - self.paragraphs.drain(tail..(p + 1 + removed).min(self.paragraphs.len())); - self.paragraphs.splice(tail..tail, std::iter::repeat_n(at, added)); - } - let mut s = String::with_capacity(self.characters.len() + text.len()); - s.push_str(self.characters.get(..bs).unwrap_or("")); - s.push_str(text); - s.push_str(self.characters.get(be..).unwrap_or("")); - self.characters = s; - let inserted = text.chars().count(); - let removed = end - start; - for r in &mut self.runs { - let shift = |p: usize| -> usize { - if p <= start { - p - } else if p >= end { - // Runs of a file that reach past the text (nothing clamps them on load) - // would otherwise overflow here; `normalize` below drops what is left. - p.saturating_sub(removed).saturating_add(inserted) - } else { - start + inserted - } - }; - // A run touching the insertion point at its end grows with the typed text. - let new_start = shift(r.start); - let new_end = if r.end == start && r.start < start { start + inserted } else { shift(r.end) }; - r.start = new_start; - r.end = new_end; - } - self.normalize(); - } - - pub fn paragraph_count(&self) -> usize { - self.characters.matches('\n').count() + 1 - } - - /// The paragraph char index `i` is in. - pub fn para_of(&self, i: usize) -> usize { - self.characters.chars().take(i).filter(|c| *c == '\n').count() - } - - /// Paragraph `p`'s list settings. - pub fn para_list(&self, p: usize) -> ParaList { - self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, ..ParaList::default() }) - } - - /// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries). - fn fill_paragraphs(&mut self) { - let n = self.paragraph_count(); - let fill = ParaList { list: self.list, ..ParaList::default() }; - self.paragraphs.resize(n, fill); - } - - /// The paragraphs chars `start..end` touch (a caret: its paragraph; a selection ending just - /// after a newline leaves the next paragraph out). - pub fn para_span(&self, start: usize, end: usize) -> (usize, usize) { - let (s, e) = (start.min(end), start.max(end)); - let (a, b) = (self.para_of(s), self.para_of(e)); - let ends_at_start = e > s && b > a && self.characters.chars().nth(e - 1) == Some('\n'); - (a, if ends_at_start { b - 1 } else { b }) - } - - /// Apply `f` to the list settings of the paragraphs chars `start..end` touch (plain - /// paragraphs stay at level 0, only numbered ones keep a start, levels stop at - /// [`MAX_LIST_INDENT`]); one style for all with no nesting or start folds back into `list`. - pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) { - self.set_para_lists_with(start, end, false, f); - } - - /// Take the list items among the paragraphs chars `start..end` touch out of their list at the - /// level they had, as Backspace or Delete at an item's start does: "delete the counter, but - /// keep the same level of indentation" (Create bulleted and numbered lists). - pub fn unlist_keeping_levels(&mut self, start: usize, end: usize) { - self.set_para_lists_with(start, end, true, |pl| pl.list = ListStyle::None); - } - - fn set_para_lists_with(&mut self, start: usize, end: usize, keep_levels: bool, f: impl Fn(&mut ParaList)) { - let (a, b) = self.para_span(start, end); - self.set_para_lists_between(a, b, keep_levels, f); - } - - /// As [`set_para_lists`](Self::set_para_lists), for paragraphs `a..=b` addressed directly. - fn set_para_lists_between(&mut self, a: usize, b: usize, keep_levels: bool, f: impl Fn(&mut ParaList)) { - self.fill_paragraphs(); - for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) { - f(pl); - if pl.list == ListStyle::None && !keep_levels { - pl.indent = 0; - } - if pl.list != ListStyle::Numbered { - pl.start_offset = 0; - } - pl.indent = pl.indent.min(MAX_LIST_INDENT); - } - if let Some(first) = self.paragraphs.first().copied() - && first == (ParaList { list: first.list, ..ParaList::default() }) - && self.paragraphs.iter().all(|p| *p == first) - { - self.list = first.list; - self.paragraphs.clear(); - } - } - - /// The list style and level shared by the paragraphs chars `start..end` touch (the first - /// one's settings), or None when they differ. Start numbers don't count. - pub fn common_para_list(&self, start: usize, end: usize) -> Option { - let (a, b) = self.para_span(start, end); - let first = self.para_list(a); - (a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first) - } - - /// What a text style made from this text sets at the paragraph and layer level: the paragraph - /// spacing and indent, the list style (the one every paragraph shares; the base one when they - /// differ) and the OpenType features. - pub fn paragraph_style(&self) -> ParagraphStyle { - let list = match self.paragraphs.first() { - Some(first) if self.paragraphs.iter().all(|p| p.list == first.list) => first.list, - _ => self.list, - }; - ParagraphStyle { paragraph_spacing: self.paragraph_spacing, paragraph_indent: self.paragraph_indent, list, features: self.features.clone() } - } - - /// Take what text style `ps` sets at the paragraph and layer level: the spacing, indent and - /// features on the layer, the list style on every paragraph. - pub fn take_paragraph_style(&mut self, ps: &ParagraphStyle) { - self.paragraph_spacing = ps.paragraph_spacing; - self.paragraph_indent = ps.paragraph_indent; - self.features.clone_from(&ps.features); - // Every paragraph by index: `para_span` leaves out an empty one after a final newline. - let last = self.paragraph_count() - 1; - self.set_para_lists_between(0, last, false, |pl| pl.list = ps.list); - } - - /// As [`take_paragraph_style`](Self::take_paragraph_style), with the list style going to the - /// paragraphs chars `start..end` touch only; the spacing, indent and features are the - /// layer's, as in Figma. - pub fn take_paragraph_style_in(&mut self, start: usize, end: usize, ps: &ParagraphStyle) { - self.paragraph_spacing = ps.paragraph_spacing; - self.paragraph_indent = ps.paragraph_indent; - self.features.clone_from(&ps.features); - self.set_para_lists(start, end, |pl| pl.list = ps.list); - } - - /// Follow text style `id`'s paragraph-level definition wherever chars link to it. - pub fn update_paragraph_style(&mut self, id: &str, ps: &ParagraphStyle) { - let linked: Vec<(usize, usize)> = - self.spans().into_iter().filter(|(_, _, s)| s.style_id.as_deref() == Some(id)).map(|(a, b, _)| (a, b)).collect(); - for (a, b) in linked { - self.take_paragraph_style_in(a, b, ps); - } - } - - /// Whether chars `start..end` show what text style `ps` sets at the paragraph and layer - /// level: the layer's spacing, indent and features, and their paragraphs' list style. - pub fn shows_paragraph_style_in(&self, start: usize, end: usize, ps: &ParagraphStyle) -> bool { - self.paragraph_spacing == ps.paragraph_spacing - && self.paragraph_indent == ps.paragraph_indent - && self.features == ps.features - && self.common_para_list(start, end).is_some_and(|pl| pl.list == ps.list) - } - - /// Whether the whole text shows what text style `ps` sets at the paragraph and layer level. - pub fn shows_paragraph_style(&self, ps: &ParagraphStyle) -> bool { - self.shows_paragraph_style_in(0, self.char_count(), ps) - } - - /// Link chars `start..end` (all of them when the range is empty or the whole text) to text - /// style `id`, defined as `def`. - pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) { - let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) }; - self.restyle(start, end, |s| s.take_text_style(def, id)); - } - - /// Unlink chars `start..end` (all when empty or whole) from their text styles; values stay. - pub fn unlink_text_style(&mut self, start: usize, end: usize) { - let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) }; - self.restyle(start, end, |s| s.style_id = None); - } - - /// The text style every char is linked to: `Some(None)` when none is, None when they differ. - pub fn text_style_link(&self) -> Option> { - let mut spans = self.spans().into_iter().map(|(_, _, s)| s.style_id.as_deref()); - let first = spans.next()?; - spans.all(|s| s == first).then_some(first) - } - - /// Apply `f` to every distinct style (the base and each run) and merge runs that became equal. - pub(crate) fn each_style(&mut self, mut f: impl FnMut(&mut TextStyle)) { - f(&mut self.style); - for r in &mut self.runs { - f(&mut r.style); - } - self.normalize(); - } - - /// Whether any char links to text style `id`. - pub fn links_text_style(&self, id: &str) -> bool { - std::iter::once(&self.style).chain(self.runs.iter().map(|r| &r.style)).any(|s| s.style_id.as_deref() == Some(id)) - } - - /// Follow text style `id`'s new definition wherever chars link to it. - pub fn update_text_style(&mut self, id: &str, def: &TextStyle) { - self.each_style(|s| { - if s.style_id.as_deref() == Some(id) { - s.take_text_style(def, id); - } - }); - } - - /// Point text style links elsewhere: `f(id)` gives the new id, None unlinks (values stay). - pub fn map_text_styles(&mut self, f: impl Fn(&str) -> Option) { - self.each_style(|s| s.style_id = s.style_id.as_deref().and_then(&f)); - } - - /// Unlink chars whose style is gone (`def` gives None) or that no longer show it (edited since). - pub fn check_text_styles<'a>(&mut self, def: impl Fn(&str) -> Option<&'a TextStyle>) { - self.each_style(|s| { - if s.style_id.as_deref().is_some_and(|id| !def(id).is_some_and(|d| s.shows_text_style(d))) { - s.style_id = None; - } - }); - } - - /// Whether every char shares one style (the panel shows "Mixed" otherwise). - pub fn is_uniform(&self) -> bool { - self.runs.is_empty() - } -} - -#[cfg(test)] -mod case_tests { - use super::*; - - #[test] - fn trim_num_prints_huge_whole_numbers() { - assert_eq!(trim_num(1e19), "10000000000000000000"); - assert_eq!(trim_num(-1e19), "-10000000000000000000"); - assert_eq!(trim_num(12.0), "12"); - let v = 12345678901234567890.0_f64; - assert_eq!(trim_num(v).parse::().unwrap(), v); - } - - #[test] - fn title_case_respects_context() { - assert_eq!(TextCase::Title.apply("hello wORLD don't 3d x-ray"), "Hello WORLD Don't 3d X-Ray"); - // Continuing a word from a previous run: no capital. - assert_eq!(TextCase::Title.apply_after("lo world", Some('l')), "lo World"); - assert_eq!(TextCase::Title.apply_after("lo", Some(' ')), "Lo"); - assert_eq!(TextCase::Upper.apply_after("ß", Some('a')), "SS"); - assert!(valid_feature_tag("liga") && valid_feature_tag("ss01")); - assert!(!valid_feature_tag("lig") && !valid_feature_tag("ligatures") && !valid_feature_tag("li\u{e9}")); - } - - #[test] - fn new_text_starts_in_the_builds_default_font() { - // Inter, unless the build names another with `FIGCRAFT_DEFAULT_FONT` (empty means Inter). - let named = option_env!("FIGCRAFT_DEFAULT_FONT").filter(|f| !f.is_empty()); - assert_eq!(DEFAULT_FONT, named.unwrap_or("Inter")); - let st = TextStyle::default(); - assert_eq!(st.font_family, DEFAULT_FONT); - // The family is always written, so a saved file keeps it in a build with another default. - let saved = serde_json::to_value(&st).unwrap(); - assert_eq!(saved["font_family"], DEFAULT_FONT); - } - - #[test] - fn variations_are_backward_compatible_and_imply_weight() { - // Files written before variable fonts load with no custom axes, and stay byte-identical. - let old = r#"{"font_family":"Inter","font_style":"Bold","font_size":12.0}"#; - let st: TextStyle = serde_json::from_str(old).unwrap(); - assert!(st.variations.is_empty()); - assert!(!serde_json::to_string(&st).unwrap().contains("variations")); - let mut st = TextStyle { font_style: "Regular".into(), ..Default::default() }; - st.variations.insert("wght".into(), 640.0); - st.variations.insert("wdth".into(), 80.0); - assert_eq!(st.weight(), 640); - assert!(!st.italic()); - st.variations.insert("slnt".into(), -8.0); - assert!(st.italic()); - let round: TextStyle = serde_json::from_str(&serde_json::to_string(&st).unwrap()).unwrap(); - assert_eq!(round, st); - // Picking a named style drops the axes its name implies, keeps the others. - st.set_font_style("Bold"); - assert_eq!(st.weight(), 700); - assert_eq!(st.variations.keys().collect::>(), ["wdth"]); - assert!(valid_axis_tag("wght") && !valid_axis_tag("weight")); - } - - #[test] - fn list_starts_load_from_old_files_and_follow_edits() { - use ListStyle::{Bulleted as B, None as P, Numbered as N}; - // Files written before start numbers load with none, and write the same bytes. - let old = r#"{"list":"numbered","indent":1}"#; - let pl: ParaList = serde_json::from_str(old).unwrap(); - assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 }); - assert_eq!(serde_json::to_string(&pl).unwrap(), old); - // One numbered paragraph with a start keeps it (it doesn't fold into `list`). - let mut t = TextData { characters: "Next".into(), ..Default::default() }; - t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 }); - assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 }); - // Enter copies it to the new item; different starts don't make the list mixed. - t.replace_range(4, 4, "\nMore"); - assert_eq!(t.para_list(1).start_offset, 1); - t.set_para_lists(5, 5, |p| p.start_offset = 0); - assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N)); - // Bullets have no number, so no start. - t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 }); - assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 }); - // A plain paragraph has none. - t.set_para_lists(0, 0, |p| p.list = P); - assert_eq!(t.para_list(0), ParaList::default()); - } -} - -#[cfg(test)] -mod title_case_tests { - use super::TextCase; - - #[test] - fn title_case_after_opening_apostrophe() { - assert_eq!(TextCase::Title.apply("'hello world'"), "'Hello World'"); - assert_eq!(TextCase::Title.apply("say 'hi'"), "Say 'Hi'"); - assert_eq!(TextCase::Title.apply("don't"), "Don't"); - assert_eq!(TextCase::Title.apply("\u{2019}hello"), "\u{2019}Hello"); - } - - #[test] - fn title_case_between_runs_sees_char_before_apostrophe() { - assert_eq!(TextCase::Title.apply_after("t", Some('\'')), "t"); - assert_eq!(TextCase::Title.apply_between("hello", None, Some('\'')), "Hello"); - assert_eq!(TextCase::Title.apply_between("t", Some('n'), Some('\'')), "t"); - assert_eq!(TextCase::Title.apply_between("t", Some(' '), Some('\u{2019}')), "T"); - } - - #[test] - fn title_case_combining_marks_do_not_break_words() { - assert_eq!(TextCase::Title.apply("re\u{301}sume\u{301}"), "Re\u{301}sume\u{301}"); - assert_eq!(TextCase::Title.apply("re\u{301}sume\u{301} bon"), "Re\u{301}sume\u{301} Bon"); - assert_eq!(TextCase::Title.apply("a \u{301}b"), "A \u{301}B"); - assert_eq!(TextCase::Title.apply("cafe\u{301}'s"), "Cafe\u{301}'s"); - } -} - -#[cfg(test)] -mod whole_text_paragraph_style_tests { - use super::*; - - #[test] - fn a_text_style_on_the_whole_layer_reaches_a_trailing_empty_paragraph() { - let ps = ParagraphStyle { list: ListStyle::Bulleted, ..Default::default() }; - let mut t = TextData::new("a\nb\n"); - t.take_paragraph_style(&ps); - assert_eq!(t.paragraph_count(), 3); - assert_eq!(t.para_list(2).list, ListStyle::Bulleted); - assert_eq!(t.list, ListStyle::Bulleted); - assert!(t.paragraphs.is_empty()); - let mut plain = TextData::new("a\nb"); - plain.take_paragraph_style(&ps); - assert_eq!((plain.list, plain.paragraphs.len()), (t.list, t.paragraphs.len())); - } -} +//! Text node data: characters plus a base style and styled runs. + +use figcraft_color::{Color, Paint}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "unit", content = "value", rename_all = "camelCase")] +#[derive(Default)] +pub enum LineHeight { + #[default] + Auto, + Pixels(f64), + Percent(f64), +} + +impl LineHeight { + /// Resolved line height in px for `font_size` (`auto` uses the font's natural ratio). + pub fn resolve(self, font_size: f64, natural_ratio: f64) -> f64 { + match self { + LineHeight::Auto => font_size * natural_ratio, + LineHeight::Pixels(v) => v.max(0.0), + LineHeight::Percent(p) => font_size * p.max(0.0) / 100.0, + } + } + pub fn label(self) -> String { + match self { + LineHeight::Auto => "Auto".into(), + LineHeight::Pixels(v) => trim_num(v), + LineHeight::Percent(p) => format!("{}%", trim_num(p)), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "unit", content = "value", rename_all = "camelCase")] +pub enum LetterSpacing { + Pixels(f64), + Percent(f64), +} + +impl Default for LetterSpacing { + fn default() -> Self { + LetterSpacing::Percent(0.0) + } +} + +impl LetterSpacing { + pub fn resolve(self, font_size: f64) -> f64 { + match self { + LetterSpacing::Pixels(v) => v, + LetterSpacing::Percent(p) => font_size * p / 100.0, + } + } + pub fn label(self) -> String { + match self { + LetterSpacing::Pixels(v) => trim_num(v), + LetterSpacing::Percent(p) => format!("{}%", trim_num(p)), + } + } +} + +pub fn trim_num(v: f64) -> String { + if v.abs() >= 9.0e18 { + return format!("{v}"); + } + let r = (v * 100.0).round() / 100.0; + if r.fract().abs() < 1e-9 { format!("{}", r as i64) } else { format!("{r}") } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum TextDecoration { + #[default] + None, + Underline, + Strikethrough, +} + +/// How an underline or strikethrough is drawn. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum DecorationStyle { + #[default] + Solid, + Dotted, + Wavy, +} + +/// A decoration length: `Auto` (the font's own value), pixels, or a percentage of the font size. +pub type DecorationLength = LineHeight; + +/// How a decoration line looks; the default is the font's own solid line in the text's colour. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct DecorationLook { + pub style: DecorationStyle, + pub thickness: DecorationLength, + /// Underlines: from the baseline down to the line's top edge (negative: above the baseline). + pub offset: DecorationLength, + /// Underlines break where descenders cross them. + pub skip_ink: bool, + /// The line's own colour (None: the text's fills). + pub color: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum TextCase { + #[default] + Original, + Upper, + Lower, + Title, + SmallCaps, +} + +impl TextCase { + pub fn apply(self, s: &str) -> String { + self.apply_after(s, None) + } + + /// Apply the case to `s`, which follows `prev` in the text (so title case only capitalises + /// the first letter of each word even when a word spans several styled runs). An apostrophe + /// as `prev` is taken to continue a word. + pub fn apply_after(self, s: &str, prev: Option) -> String { + self.apply_impl(s, prev, prev.is_some_and(is_word_char)) + } + + /// Like [`apply_after`](Self::apply_after), also given the char `before` `prev`: an apostrophe + /// only continues a word when a letter or digit precedes it. + pub fn apply_between(self, s: &str, before: Option, prev: Option) -> String { + let continues = prev.is_some_and(|c| is_word_char(c) && (c.is_alphanumeric() || before.is_some_and(char::is_alphanumeric))); + self.apply_impl(s, prev, continues) + } + + /// `in_word` says whether `prev` continues a word (rather than a new word starting here). + fn apply_impl(self, s: &str, prev: Option, in_word: bool) -> String { + match self { + TextCase::Original | TextCase::SmallCaps => s.to_string(), + TextCase::Upper => s.to_uppercase(), + TextCase::Lower => s.to_lowercase(), + TextCase::Title => { + let mut out = String::with_capacity(s.len()); + let mut start = !in_word; + let mut last = prev; + for ch in s.chars() { + if start && ch.is_alphanumeric() { + out.extend(ch.to_uppercase()); + start = false; + } else { + out.push(ch); + // An apostrophe only continues a word right after a letter or digit. + let opening_apostrophe = + matches!(ch, '\'' | '\u{2019}') && !last.is_some_and(|c| c.is_alphanumeric() || is_combining_mark(c)); + // A combining mark attaches to the char before it and never changes word state. + if !is_combining_mark(ch) { + start = !is_word_char(ch) || opening_apostrophe; + } + } + last = Some(ch); + } + out + } + } + } + + /// Whether the char at a position preceded by `prev` starts a word (for title case). + pub fn starts_word(prev: Option) -> bool { + prev.is_none_or(|c| !is_word_char(c)) + } +} + +/// Letters, digits, combining marks and apostrophes continue a word (so "don't" stays "Don't" +/// and decomposed accents stay inside their word). +fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || is_combining_mark(c) || matches!(c, '\'' | '\u{2019}') +} + +/// Combining marks such as the U+0301 accent in decomposed text: non-alphanumeric chars that +/// extend the grapheme of the char before them. +fn is_combining_mark(c: char) -> bool { + use unicode_segmentation::UnicodeSegmentation; + if c.is_ascii() || c.is_alphanumeric() { + return false; + } + let mut buf = [0u8; 8]; + buf[0] = b'a'; + let end = 1 + c.encode_utf8(&mut buf[1..]).len(); + std::str::from_utf8(&buf[..end]).is_ok_and(|s| s.graphemes(true).count() == 1) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum TextAlignH { + #[default] + Left, + Center, + Right, + Justified, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum TextAlignV { + #[default] + Top, + Middle, + Bottom, +} + +/// How a text box resizes as its content changes. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum TextAutoResize { + /// Auto width: one line per paragraph, box grows both ways. + #[default] + WidthAndHeight, + /// Fixed width, height grows. + Height, + /// Fixed size. + None, + /// Fixed size, truncate with an ellipsis. + Truncate, +} + +/// Vertical trim: what bounds a text box vertically. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum LeadingTrim { + /// The full line height of the first and last lines. + #[default] + None, + /// From the first line's cap height to the last line's baseline. + CapHeight, +} + +/// Where lines break when text wraps to more than one line ("Wrap style controls where lines +/// break when text wraps to more than one line", Explore text properties). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum WrapStyle { + /// "Figma fits as many words as possible on each line before wrapping to the next." + #[default] + Auto, + /// "Figma distributes text as evenly as possible across every line, so each line is close to + /// the same length." + Balance, + /// "Figma keeps each line full, but adjusts the last four lines to avoid an orphan -- a single + /// word stranded alone on the last line." + Pretty, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum ListStyle { + #[default] + None, + Bulleted, + Numbered, +} + +/// Deepest list nesting (levels 0..=MAX_LIST_INDENT). +pub const MAX_LIST_INDENT: u8 = 8; + +/// One paragraph's list settings. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct ParaList { + pub list: ListStyle, + /// Nesting level (0 = outermost). A plain paragraph is 0, except one that left its list by + /// Backspace or Delete at its start ([`TextData::unlist_keeping_levels`]): it keeps the level + /// it had and starts where that level's item text does ("delete the counter, but keep the + /// same level of indentation"). + pub indent: u8, + /// A numbered list that starts counting at this paragraph counts from `start_offset + 1` + /// (items that go on counting ignore it; other paragraphs are always 0). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub start_offset: u32, +} + +/// Character style. In runs, every field is an override; `None` means "inherit the base". +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct TextStyle { + pub font_family: String, + /// The style name, e.g. "Regular", "Bold", "Semi Bold Italic". + pub font_style: String, + pub font_size: f64, + pub line_height: LineHeight, + pub letter_spacing: LetterSpacing, + pub decoration: TextDecoration, + /// How the decoration looks (None: the default look; boxed to keep styles small). + #[serde(skip_serializing_if = "Option::is_none")] + pub decoration_look: Option>, + pub case: TextCase, + /// Run fills (None = node fills). + pub fills: Option>, + /// Hyperlink. + pub link: Option, + /// Variable font axis values in user space, keyed by 4-char OpenType axis tag (e.g. `wght`: + /// 550, `wdth`: 85). Empty = what the style name implies; axes the face lacks are ignored. + #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub variations: std::collections::BTreeMap, + /// The text style (shared style id) these chars are linked to. A layer's chars can link to + /// different styles; editing what a style sets unlinks the chars edited. + #[serde(skip_serializing_if = "Option::is_none")] + pub style_id: Option, +} + +/// What a text style sets at the paragraph and layer level, beside its character style +/// ([`TextStyle`]): paragraph spacing and indent, the list style and the OpenType features +/// ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType +/// features", Create and apply text styles). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct ParagraphStyle { + #[serde(skip_serializing_if = "crate::node::is_default")] + pub paragraph_spacing: f64, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub paragraph_indent: f64, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub list: ListStyle, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub features: std::collections::BTreeMap, +} + +/// The font new text starts in. A build can name another (`FIGCRAFT_DEFAULT_FONT="Noto Sans Thai"`, +/// for a distribution whose users mostly write a script Inter lacks); empty means Inter. +pub const DEFAULT_FONT: &str = match option_env!("FIGCRAFT_DEFAULT_FONT") { + Some(family) if !family.is_empty() => family, + _ => "Inter", +}; + +impl Default for TextStyle { + fn default() -> Self { + Self { + font_family: DEFAULT_FONT.into(), + font_style: "Regular".into(), + font_size: 12.0, + line_height: LineHeight::Auto, + letter_spacing: LetterSpacing::default(), + decoration: TextDecoration::None, + decoration_look: None, + case: TextCase::Original, + fills: None, + link: None, + variations: std::collections::BTreeMap::new(), + style_id: None, + } + } +} + +/// Axes a style name implies (weight, italic, slant): picking a named style clears their custom values. +pub const STYLE_AXES: [&str; 3] = ["wght", "ital", "slnt"]; + +impl TextStyle { + /// Numeric weight: a custom `wght` axis value, else the one implied by the style name. + pub fn weight(&self) -> u16 { + match self.variations.get("wght") { + Some(w) if w.is_finite() => w.round().clamp(1.0, 1000.0) as u16, + _ => style_weight(&self.font_style), + } + } + pub fn italic(&self) -> bool { + self.font_style.to_ascii_lowercase().contains("italic") + || self.variations.get("ital").is_some_and(|v| *v >= 0.5) + || self.variations.get("slnt").is_some_and(|v| *v != 0.0) + } + + /// Take what text style `def` sets (font, size, line height, letter spacing, decoration, case + /// and axis values) and link to it as `id`; fills and hyperlinks stay. + pub fn take_text_style(&mut self, def: &TextStyle, id: &str) { + *self = TextStyle { fills: self.fills.take(), link: self.link.take(), style_id: Some(id.to_string()), ..def.clone() }; + } + + /// Whether these chars show what text style `def` sets. + pub fn shows_text_style(&self, def: &TextStyle) -> bool { + self.font_family == def.font_family + && self.font_style == def.font_style + && self.font_size == def.font_size + && self.line_height == def.line_height + && self.letter_spacing == def.letter_spacing + && self.decoration == def.decoration + && self.decoration_look == def.decoration_look + && self.case == def.case + && self.variations == def.variations + } + + /// This style as a text style's definition (linked to nothing). + pub fn as_text_style(&self) -> TextStyle { + TextStyle { style_id: None, link: None, ..self.clone() } + } + + /// How the decoration looks. + pub fn look(&self) -> DecorationLook { + self.decoration_look.as_deref().copied().unwrap_or_default() + } + + /// Change how the decoration looks (the default look is stored as None). + pub fn set_look(&mut self, f: impl FnOnce(&mut DecorationLook)) { + let mut look = self.look(); + f(&mut look); + self.decoration_look = (look != DecorationLook::default()).then(|| Box::new(look)); + } + + /// Switch to the named style, dropping custom values of the axes the name implies. + pub fn set_font_style(&mut self, name: impl Into) { + self.font_style = name.into(); + for tag in STYLE_AXES { + self.variations.remove(tag); + } + } +} + +/// Weight from a style name ("Semi Bold Italic" → 600). +pub fn style_weight(style: &str) -> u16 { + let s = style.to_ascii_lowercase().replace([' ', '-'], ""); + // A weight number ("600", "600 Italic"), as some apps name styles. + let digits: String = s.chars().take_while(char::is_ascii_digit).collect(); + if let Ok(n) = digits.parse::() + && (1..=1000).contains(&n) + { + return n; + } + let table: [(&str, u16); 10] = [ + ("thin", 100), + ("extralight", 200), + ("ultralight", 200), + ("light", 300), + ("medium", 500), + ("semibold", 600), + ("demibold", 600), + ("extrabold", 800), + ("ultrabold", 800), + ("black", 900), + ]; + for (k, w) in table { + if s.contains(k) { + return w; + } + } + if s.contains("bold") { + return 700; + } + if s.contains("heavy") { + return 900; + } + 400 +} + +/// A styled range of characters (char indices, end exclusive). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StyleRun { + pub start: usize, + pub end: usize, + pub style: TextStyle, +} + +/// Text node content. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct TextData { + pub characters: String, + pub style: TextStyle, + /// Ranges whose style differs from `style`; full styles (already merged), non-overlapping, sorted. + #[serde(skip_serializing_if = "crate::node::is_default")] + pub runs: Vec, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub auto_resize: TextAutoResize, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub align_h: TextAlignH, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub align_v: TextAlignV, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub paragraph_spacing: f64, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub paragraph_indent: f64, + /// The list style of paragraphs `paragraphs` doesn't cover (all of them when it's empty). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub list: ListStyle, + /// Per-paragraph list settings, by paragraph index; empty when every paragraph is `list` at + /// level 0. Kept in step with the characters by [`TextData::replace_range`]. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub paragraphs: Vec, + /// Space between two items of a list (px), in place of the paragraph spacing. + #[serde(skip_serializing_if = "crate::node::is_default")] + pub list_spacing: f64, + /// Opening and closing punctuation at a line's aligned edge sits outside the box. + #[serde(skip_serializing_if = "crate::node::is_default")] + pub hanging_punctuation: bool, + /// List bullets and numbers sit outside the box; item text lines up with plain paragraphs. + #[serde(skip_serializing_if = "crate::node::is_default")] + pub hanging_list: bool, + /// Vertical trim (`capHeight`: the box runs from the first line's cap height to the last + /// line's baseline). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub leading_trim: LeadingTrim, + /// Max lines when truncating, or before an auto-height text ends in an ellipsis (0 = as many + /// as fit). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub max_lines: u32, + /// Where lines break when the text wraps: greedily (Auto), as evenly as the words allow + /// (Balance), or full with no one word alone on the last line (Pretty). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub wrap_style: WrapStyle, + /// OpenType feature toggles (e.g. "liga": false). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub features: std::collections::BTreeMap, + /// The path the text follows (text on a path); `None`: lines in the box. Boxed to keep text + /// data small. + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option>, + /// Where the file this text came from wrapped its lines (an imported `.fig`'s saved layout). + /// Layout follows them only while the box width and everything that moves a line break are + /// as they were saved, so an edit that could change the wrap drops them. + #[serde(skip_serializing_if = "Option::is_none")] + pub saved_breaks: Option>, +} + +/// Line breaks a file saved with a text: the chars that start a wrapped line (not those after a +/// hard break), for the box width and the content ([`TextData::layout_key`]) they were made for. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct SavedBreaks { + pub starts: Vec, + pub width: f64, + pub for_text: u64, +} + +/// Text on a path: the baseline follows `d`. The text starts at `start` (aligned there by the +/// horizontal alignment: left starts, centre centres, right ends at it); glyphs past an open +/// path's ends are hidden, a closed path wraps them around. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct TextPath { + /// SVG path data in the layer's own space. + pub d: String, + /// Where the text starts along the path, 0..1 by length. + pub start: f64, + /// On the other side of the path, running the other way. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub flip: bool, +} + +impl TextPath { + /// A text path following `path` (layer space). + pub fn new(path: &figcraft_geom::BezPath) -> Self { + Self { d: path.to_svg(), start: 0.0, flip: false } + } + + /// The path (empty when `d` doesn't parse). + pub fn path(&self) -> figcraft_geom::BezPath { + figcraft_geom::BezPath::from_svg(&self.d).unwrap_or_default() + } + + /// The start offset clamped into 0..1 (NaN: 0). + pub fn start(&self) -> f64 { + if self.start.is_finite() { self.start.clamp(0.0, 1.0) } else { 0.0 } + } + + /// Scale the path by `sx`, `sy` (the layer was resized). + pub fn scale(&mut self, sx: f64, sy: f64) { + if sx.is_finite() && sy.is_finite() { + let mut p = self.path(); + p.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy)); + self.d = p.to_svg(); + } + } +} + +/// Whether `tag` is a valid OpenType feature tag (four printable ASCII chars). +pub fn valid_feature_tag(tag: &str) -> bool { + tag.len() == 4 && tag.bytes().all(|b| (0x20..0x7f).contains(&b)) +} + +/// Whether `tag` is a valid OpenType variation axis tag (same shape as a feature tag). +pub fn valid_axis_tag(tag: &str) -> bool { + valid_feature_tag(tag) +} + +impl TextData { + pub fn new(characters: impl Into) -> Self { + Self { characters: characters.into(), ..Default::default() } + } + + /// A stable hash of what decides where lines break: everything but paints, links, style links, + /// decorations, vertical settings and the saved breaks themselves (FNV-1a over the JSON, so it + /// stays the same across builds and can be saved). + pub fn layout_key(&self) -> u64 { + let clear = |s: &mut TextStyle| { + (s.fills, s.link, s.style_id, s.decoration, s.decoration_look) = (None, None, None, TextDecoration::None, None); + }; + let mut t = TextData { + align_v: TextAlignV::default(), + paragraph_spacing: 0.0, + list_spacing: 0.0, + leading_trim: LeadingTrim::default(), + max_lines: 0, + saved_breaks: None, + ..self.clone() + }; + clear(&mut t.style); + t.runs.iter_mut().for_each(|r| clear(&mut r.style)); + t.normalize(); + serde_json::to_vec(&t).unwrap_or_default().iter().fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3)) + } + + /// Keep `starts` (chars that start a wrapped line) as this text's saved line breaks in a box + /// `width` wide. + pub fn save_breaks(&mut self, starts: Vec, width: f64) { + self.saved_breaks = Some(Box::new(SavedBreaks { starts, width, for_text: self.layout_key() })); + } + + /// The saved line starts (empty: no wrapped line) while they still hold: the same box width and + /// layout content, and starts increasing inside the text. + pub fn saved_breaks_for(&self, width: f64) -> Option<&[usize]> { + let b = self.saved_breaks.as_deref()?; + let inside = b.starts.first().is_none_or(|s| *s > 0) && b.starts.last().is_none_or(|s| *s < self.char_count()); + let same_box = (b.width - width).abs() <= 1e-6 * width.abs().max(1.0); + (inside && same_box && b.starts.is_sorted_by(|a, b| a < b) && b.for_text == self.layout_key()).then_some(b.starts.as_slice()) + } + + /// How far text on a path paints past its path's box (glyphs stand on the path, lines after + /// the first run beside it): about one and a half of the largest size per line; 0 off a path. + pub fn path_reach(&self) -> f64 { + if self.path.is_none() { + return 0.0; + } + let lines = self.characters.lines().count().clamp(1, 10_000) as f64; + self.max_size() * 1.5 * lines + } + + /// How far vertically trimmed text paints past its box (descenders below the last baseline, + /// accents above the first cap height): half the largest size; 0 untrimmed. + pub fn trim_reach(&self) -> f64 { + if self.leading_trim == LeadingTrim::None { 0.0 } else { self.max_size() * 0.5 } + } + + /// The largest font size used (capped like layout caps it). + fn max_size(&self) -> f64 { + self.spans().iter().map(|(_, _, s)| s.font_size).filter(|v| v.is_finite()).fold(0.0, f64::max).min(5000.0) + } + + pub fn char_count(&self) -> usize { + self.characters.chars().count() + } + + /// Whether text that runs out of room ends in an ellipsis: a truncating box, or auto height + /// or auto width with a line limit (the box hugs up to `max_lines` lines: "Max lines is + /// available only if truncate text is enabled, and: Text resizing is set to auto height or + /// auto width"). + pub fn truncates(&self) -> bool { + match self.auto_resize { + TextAutoResize::Truncate => true, + TextAutoResize::Height | TextAutoResize::WidthAndHeight => self.max_lines > 0, + TextAutoResize::None => false, + } + } + + /// The resize mode once the box is given an explicit `width` and/or `height`: an explicit + /// height fixes the box, an explicit width turns auto width into auto height, and truncating + /// text keeps truncating. + pub fn fix_size(&mut self, width: bool, height: bool) { + self.auto_resize = match self.auto_resize { + _ if height && self.truncates() => TextAutoResize::Truncate, + _ if height => TextAutoResize::None, + TextAutoResize::WidthAndHeight if width => TextAutoResize::Height, + mode => mode, + }; + } + + /// The style at char index `i`. + pub fn style_at(&self, i: usize) -> &TextStyle { + self.runs.iter().find(|r| r.start <= i && i < r.end).map(|r| &r.style).unwrap_or(&self.style) + } + + /// Spans of uniform style covering the whole text: (start, end, style). + pub fn spans(&self) -> Vec<(usize, usize, &TextStyle)> { + let n = self.char_count(); + let mut out = Vec::new(); + let mut pos = 0; + let mut runs: Vec<&StyleRun> = self.runs.iter().filter(|r| r.start < r.end && r.start < n).collect(); + runs.sort_by_key(|r| r.start); + for r in runs { + let start = r.start.max(pos); + let end = r.end.min(n); + if start >= end { + continue; + } + if start > pos { + out.push((pos, start, &self.style)); + } + out.push((start, end, &r.style)); + pos = end; + } + if pos < n || out.is_empty() { + out.push((pos, n, &self.style)); + } + out + } + + /// Apply `f` to the style of chars `start..end` (all text when the range is the whole text or + /// empty text), splitting runs as needed and merging equal neighbours. + pub fn restyle(&mut self, start: usize, end: usize, f: impl Fn(&mut TextStyle)) { + let n = self.char_count(); + let (start, end) = (start.min(n), end.min(n)); + if n == 0 || (start == 0 && end >= n) { + f(&mut self.style); + for r in &mut self.runs { + f(&mut r.style); + } + self.normalize(); + return; + } + if start >= end { + return; + } + // Expand to per-char styles, modify, re-compress. + let mut per: Vec = (0..n).map(|i| self.style_at(i).clone()).collect(); + for s in per.iter_mut().take(end).skip(start) { + f(s); + } + self.runs.clear(); + let mut i = 0; + while i < n { + let Some(s) = per.get(i) else { break }; + let mut j = i + 1; + while per.get(j).is_some_and(|t| t == s) { + j += 1; + } + if *s != self.style { + self.runs.push(StyleRun { start: i, end: j, style: s.clone() }); + } + i = j; + } + } + + /// Drop empty/out-of-range runs and runs equal to the base style, and merge touching equal runs. + pub fn normalize(&mut self) { + let n = self.char_count(); + let base = self.style.clone(); + self.runs.retain(|r| r.start < r.end && r.start < n && r.style != base); + for r in &mut self.runs { + r.end = r.end.min(n); + } + self.runs.sort_by_key(|r| r.start); + let mut merged: Vec = Vec::with_capacity(self.runs.len()); + for mut r in std::mem::take(&mut self.runs) { + // Runs never overlap: a later run starts where the previous one ends at the earliest. + if let Some(prev) = merged.last() { + r.start = r.start.max(prev.end); + if r.start >= r.end { + continue; + } + } + match merged.last_mut() { + Some(prev) if prev.end == r.start && prev.style == r.style => prev.end = r.end, + _ => merged.push(r), + } + } + self.runs = merged; + } + + /// Replace chars `start..end` with `text`, shifting runs (inserted text takes the style at `start`). + pub fn replace_range(&mut self, start: usize, end: usize, text: &str) { + let n = self.char_count(); + let (start, end) = (start.min(n), end.min(n).max(start.min(n))); + let byte = |i: usize| self.characters.char_indices().nth(i).map(|(b, _)| b).unwrap_or(self.characters.len()); + let (bs, be) = (byte(start), byte(end)); + if !self.paragraphs.is_empty() { + // Paragraphs merged by removed newlines take the first one's list; new ones copy it. + let p = self.para_of(start); + let removed = self.characters.get(bs..be).unwrap_or("").matches('\n').count(); + let added = text.matches('\n').count(); + self.fill_paragraphs(); + let at = self.para_list(p); + let tail = (p + 1).min(self.paragraphs.len()); + self.paragraphs.drain(tail..(p + 1 + removed).min(self.paragraphs.len())); + self.paragraphs.splice(tail..tail, std::iter::repeat_n(at, added)); + } + let mut s = String::with_capacity(self.characters.len() + text.len()); + s.push_str(self.characters.get(..bs).unwrap_or("")); + s.push_str(text); + s.push_str(self.characters.get(be..).unwrap_or("")); + self.characters = s; + let inserted = text.chars().count(); + let removed = end - start; + for r in &mut self.runs { + let shift = |p: usize| -> usize { + if p <= start { + p + } else if p >= end { + // Runs of a file that reach past the text (nothing clamps them on load) + // would otherwise overflow here; `normalize` below drops what is left. + p.saturating_sub(removed).saturating_add(inserted) + } else { + start + inserted + } + }; + // A run touching the insertion point at its end grows with the typed text. + let new_start = shift(r.start); + let new_end = if r.end == start && r.start < start { start + inserted } else { shift(r.end) }; + r.start = new_start; + r.end = new_end; + } + self.normalize(); + } + + pub fn paragraph_count(&self) -> usize { + self.characters.matches('\n').count() + 1 + } + + /// The paragraph char index `i` is in. + pub fn para_of(&self, i: usize) -> usize { + self.characters.chars().take(i).filter(|c| *c == '\n').count() + } + + /// Paragraph `p`'s list settings. + pub fn para_list(&self, p: usize) -> ParaList { + self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, ..ParaList::default() }) + } + + /// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries). + fn fill_paragraphs(&mut self) { + let n = self.paragraph_count(); + let fill = ParaList { list: self.list, ..ParaList::default() }; + self.paragraphs.resize(n, fill); + } + + /// The paragraphs chars `start..end` touch (a caret: its paragraph; a selection ending just + /// after a newline leaves the next paragraph out). + pub fn para_span(&self, start: usize, end: usize) -> (usize, usize) { + let (s, e) = (start.min(end), start.max(end)); + let (a, b) = (self.para_of(s), self.para_of(e)); + let ends_at_start = e > s && b > a && self.characters.chars().nth(e - 1) == Some('\n'); + (a, if ends_at_start { b - 1 } else { b }) + } + + /// Apply `f` to the list settings of the paragraphs chars `start..end` touch (plain + /// paragraphs stay at level 0, only numbered ones keep a start, levels stop at + /// [`MAX_LIST_INDENT`]); one style for all with no nesting or start folds back into `list`. + pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) { + self.set_para_lists_with(start, end, false, f); + } + + /// Take the list items among the paragraphs chars `start..end` touch out of their list at the + /// level they had, as Backspace or Delete at an item's start does: "delete the counter, but + /// keep the same level of indentation" (Create bulleted and numbered lists). + pub fn unlist_keeping_levels(&mut self, start: usize, end: usize) { + self.set_para_lists_with(start, end, true, |pl| pl.list = ListStyle::None); + } + + fn set_para_lists_with(&mut self, start: usize, end: usize, keep_levels: bool, f: impl Fn(&mut ParaList)) { + let (a, b) = self.para_span(start, end); + self.set_para_lists_between(a, b, keep_levels, f); + } + + /// As [`set_para_lists`](Self::set_para_lists), for paragraphs `a..=b` addressed directly. + fn set_para_lists_between(&mut self, a: usize, b: usize, keep_levels: bool, f: impl Fn(&mut ParaList)) { + self.fill_paragraphs(); + for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) { + f(pl); + if pl.list == ListStyle::None && !keep_levels { + pl.indent = 0; + } + if pl.list != ListStyle::Numbered { + pl.start_offset = 0; + } + pl.indent = pl.indent.min(MAX_LIST_INDENT); + } + if let Some(first) = self.paragraphs.first().copied() + && first == (ParaList { list: first.list, ..ParaList::default() }) + && self.paragraphs.iter().all(|p| *p == first) + { + self.list = first.list; + self.paragraphs.clear(); + } + } + + /// The list style and level shared by the paragraphs chars `start..end` touch (the first + /// one's settings), or None when they differ. Start numbers don't count. + pub fn common_para_list(&self, start: usize, end: usize) -> Option { + let (a, b) = self.para_span(start, end); + let first = self.para_list(a); + (a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first) + } + + /// What a text style made from this text sets at the paragraph and layer level: the paragraph + /// spacing and indent, the list style (the one every paragraph shares; the base one when they + /// differ) and the OpenType features. + pub fn paragraph_style(&self) -> ParagraphStyle { + let list = match self.paragraphs.first() { + Some(first) if self.paragraphs.iter().all(|p| p.list == first.list) => first.list, + _ => self.list, + }; + ParagraphStyle { paragraph_spacing: self.paragraph_spacing, paragraph_indent: self.paragraph_indent, list, features: self.features.clone() } + } + + /// Take what text style `ps` sets at the paragraph and layer level: the spacing, indent and + /// features on the layer, the list style on every paragraph. + pub fn take_paragraph_style(&mut self, ps: &ParagraphStyle) { + self.paragraph_spacing = ps.paragraph_spacing; + self.paragraph_indent = ps.paragraph_indent; + self.features.clone_from(&ps.features); + // Every paragraph by index: `para_span` leaves out an empty one after a final newline. + let last = self.paragraph_count() - 1; + self.set_para_lists_between(0, last, false, |pl| pl.list = ps.list); + } + + /// As [`take_paragraph_style`](Self::take_paragraph_style), with the list style going to the + /// paragraphs chars `start..end` touch only; the spacing, indent and features are the + /// layer's, as in Figma. + pub fn take_paragraph_style_in(&mut self, start: usize, end: usize, ps: &ParagraphStyle) { + self.paragraph_spacing = ps.paragraph_spacing; + self.paragraph_indent = ps.paragraph_indent; + self.features.clone_from(&ps.features); + self.set_para_lists(start, end, |pl| pl.list = ps.list); + } + + /// Follow text style `id`'s paragraph-level definition wherever chars link to it. + pub fn update_paragraph_style(&mut self, id: &str, ps: &ParagraphStyle) { + let linked: Vec<(usize, usize)> = + self.spans().into_iter().filter(|(_, _, s)| s.style_id.as_deref() == Some(id)).map(|(a, b, _)| (a, b)).collect(); + for (a, b) in linked { + self.take_paragraph_style_in(a, b, ps); + } + } + + /// Whether chars `start..end` show what text style `ps` sets at the paragraph and layer + /// level: the layer's spacing, indent and features, and their paragraphs' list style. + pub fn shows_paragraph_style_in(&self, start: usize, end: usize, ps: &ParagraphStyle) -> bool { + self.paragraph_spacing == ps.paragraph_spacing + && self.paragraph_indent == ps.paragraph_indent + && self.features == ps.features + && self.common_para_list(start, end).is_some_and(|pl| pl.list == ps.list) + } + + /// Whether the whole text shows what text style `ps` sets at the paragraph and layer level. + pub fn shows_paragraph_style(&self, ps: &ParagraphStyle) -> bool { + self.shows_paragraph_style_in(0, self.char_count(), ps) + } + + /// Link chars `start..end` (all of them when the range is empty or the whole text) to text + /// style `id`, defined as `def`. + pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) { + let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) }; + self.restyle(start, end, |s| s.take_text_style(def, id)); + } + + /// Unlink chars `start..end` (all when empty or whole) from their text styles; values stay. + pub fn unlink_text_style(&mut self, start: usize, end: usize) { + let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) }; + self.restyle(start, end, |s| s.style_id = None); + } + + /// The text style every char is linked to: `Some(None)` when none is, None when they differ. + pub fn text_style_link(&self) -> Option> { + let mut spans = self.spans().into_iter().map(|(_, _, s)| s.style_id.as_deref()); + let first = spans.next()?; + spans.all(|s| s == first).then_some(first) + } + + /// Apply `f` to every distinct style (the base and each run) and merge runs that became equal. + pub(crate) fn each_style(&mut self, mut f: impl FnMut(&mut TextStyle)) { + f(&mut self.style); + for r in &mut self.runs { + f(&mut r.style); + } + self.normalize(); + } + + /// Whether any char links to text style `id`. + pub fn links_text_style(&self, id: &str) -> bool { + std::iter::once(&self.style).chain(self.runs.iter().map(|r| &r.style)).any(|s| s.style_id.as_deref() == Some(id)) + } + + /// Follow text style `id`'s new definition wherever chars link to it. + pub fn update_text_style(&mut self, id: &str, def: &TextStyle) { + self.each_style(|s| { + if s.style_id.as_deref() == Some(id) { + s.take_text_style(def, id); + } + }); + } + + /// Point text style links elsewhere: `f(id)` gives the new id, None unlinks (values stay). + pub fn map_text_styles(&mut self, f: impl Fn(&str) -> Option) { + self.each_style(|s| s.style_id = s.style_id.as_deref().and_then(&f)); + } + + /// Unlink chars whose style is gone (`def` gives None) or that no longer show it (edited since). + pub fn check_text_styles<'a>(&mut self, def: impl Fn(&str) -> Option<&'a TextStyle>) { + self.each_style(|s| { + if s.style_id.as_deref().is_some_and(|id| !def(id).is_some_and(|d| s.shows_text_style(d))) { + s.style_id = None; + } + }); + } + + /// Whether every char shares one style (the panel shows "Mixed" otherwise). + pub fn is_uniform(&self) -> bool { + self.runs.is_empty() + } +} + +#[cfg(test)] +mod case_tests { + use super::*; + + #[test] + fn trim_num_prints_huge_whole_numbers() { + assert_eq!(trim_num(1e19), "10000000000000000000"); + assert_eq!(trim_num(-1e19), "-10000000000000000000"); + assert_eq!(trim_num(12.0), "12"); + let v = 12345678901234567890.0_f64; + assert_eq!(trim_num(v).parse::().unwrap(), v); + } + + #[test] + fn title_case_respects_context() { + assert_eq!(TextCase::Title.apply("hello wORLD don't 3d x-ray"), "Hello WORLD Don't 3d X-Ray"); + // Continuing a word from a previous run: no capital. + assert_eq!(TextCase::Title.apply_after("lo world", Some('l')), "lo World"); + assert_eq!(TextCase::Title.apply_after("lo", Some(' ')), "Lo"); + assert_eq!(TextCase::Upper.apply_after("ß", Some('a')), "SS"); + assert!(valid_feature_tag("liga") && valid_feature_tag("ss01")); + assert!(!valid_feature_tag("lig") && !valid_feature_tag("ligatures") && !valid_feature_tag("li\u{e9}")); + } + + #[test] + fn new_text_starts_in_the_builds_default_font() { + // Inter, unless the build names another with `FIGCRAFT_DEFAULT_FONT` (empty means Inter). + let named = option_env!("FIGCRAFT_DEFAULT_FONT").filter(|f| !f.is_empty()); + assert_eq!(DEFAULT_FONT, named.unwrap_or("Inter")); + let st = TextStyle::default(); + assert_eq!(st.font_family, DEFAULT_FONT); + // The family is always written, so a saved file keeps it in a build with another default. + let saved = serde_json::to_value(&st).unwrap(); + assert_eq!(saved["font_family"], DEFAULT_FONT); + } + + #[test] + fn variations_are_backward_compatible_and_imply_weight() { + // Files written before variable fonts load with no custom axes, and stay byte-identical. + let old = r#"{"font_family":"Inter","font_style":"Bold","font_size":12.0}"#; + let st: TextStyle = serde_json::from_str(old).unwrap(); + assert!(st.variations.is_empty()); + assert!(!serde_json::to_string(&st).unwrap().contains("variations")); + let mut st = TextStyle { font_style: "Regular".into(), ..Default::default() }; + st.variations.insert("wght".into(), 640.0); + st.variations.insert("wdth".into(), 80.0); + assert_eq!(st.weight(), 640); + assert!(!st.italic()); + st.variations.insert("slnt".into(), -8.0); + assert!(st.italic()); + let round: TextStyle = serde_json::from_str(&serde_json::to_string(&st).unwrap()).unwrap(); + assert_eq!(round, st); + // Picking a named style drops the axes its name implies, keeps the others. + st.set_font_style("Bold"); + assert_eq!(st.weight(), 700); + assert_eq!(st.variations.keys().collect::>(), ["wdth"]); + assert!(valid_axis_tag("wght") && !valid_axis_tag("weight")); + } + + #[test] + fn list_starts_load_from_old_files_and_follow_edits() { + use ListStyle::{Bulleted as B, None as P, Numbered as N}; + // Files written before start numbers load with none, and write the same bytes. + let old = r#"{"list":"numbered","indent":1}"#; + let pl: ParaList = serde_json::from_str(old).unwrap(); + assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 }); + assert_eq!(serde_json::to_string(&pl).unwrap(), old); + // One numbered paragraph with a start keeps it (it doesn't fold into `list`). + let mut t = TextData { characters: "Next".into(), ..Default::default() }; + t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 }); + assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 }); + // Enter copies it to the new item; different starts don't make the list mixed. + t.replace_range(4, 4, "\nMore"); + assert_eq!(t.para_list(1).start_offset, 1); + t.set_para_lists(5, 5, |p| p.start_offset = 0); + assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N)); + // Bullets have no number, so no start. + t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 }); + assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 }); + // A plain paragraph has none. + t.set_para_lists(0, 0, |p| p.list = P); + assert_eq!(t.para_list(0), ParaList::default()); + } +} + +#[cfg(test)] +mod title_case_tests { + use super::TextCase; + + #[test] + fn title_case_after_opening_apostrophe() { + assert_eq!(TextCase::Title.apply("'hello world'"), "'Hello World'"); + assert_eq!(TextCase::Title.apply("say 'hi'"), "Say 'Hi'"); + assert_eq!(TextCase::Title.apply("don't"), "Don't"); + assert_eq!(TextCase::Title.apply("\u{2019}hello"), "\u{2019}Hello"); + } + + #[test] + fn title_case_between_runs_sees_char_before_apostrophe() { + assert_eq!(TextCase::Title.apply_after("t", Some('\'')), "t"); + assert_eq!(TextCase::Title.apply_between("hello", None, Some('\'')), "Hello"); + assert_eq!(TextCase::Title.apply_between("t", Some('n'), Some('\'')), "t"); + assert_eq!(TextCase::Title.apply_between("t", Some(' '), Some('\u{2019}')), "T"); + } + + #[test] + fn title_case_combining_marks_do_not_break_words() { + assert_eq!(TextCase::Title.apply("re\u{301}sume\u{301}"), "Re\u{301}sume\u{301}"); + assert_eq!(TextCase::Title.apply("re\u{301}sume\u{301} bon"), "Re\u{301}sume\u{301} Bon"); + assert_eq!(TextCase::Title.apply("a \u{301}b"), "A \u{301}B"); + assert_eq!(TextCase::Title.apply("cafe\u{301}'s"), "Cafe\u{301}'s"); + } +} + +#[cfg(test)] +mod whole_text_paragraph_style_tests { + use super::*; + + #[test] + fn a_text_style_on_the_whole_layer_reaches_a_trailing_empty_paragraph() { + let ps = ParagraphStyle { list: ListStyle::Bulleted, ..Default::default() }; + let mut t = TextData::new("a\nb\n"); + t.take_paragraph_style(&ps); + assert_eq!(t.paragraph_count(), 3); + assert_eq!(t.para_list(2).list, ListStyle::Bulleted); + assert_eq!(t.list, ListStyle::Bulleted); + assert!(t.paragraphs.is_empty()); + let mut plain = TextData::new("a\nb"); + plain.take_paragraph_style(&ps); + assert_eq!((plain.list, plain.paragraphs.len()), (t.list, t.paragraphs.len())); + } +} From fb33f78269c06fad868f0d9ebb61347ff63dc358 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 7 Nov 2026 08:17:37 +0700 Subject: [PATCH 4/4] Text styles: a style with no paragraph values keeps a bulleted text linked --- crates/engine/src/cmd/styles.rs | 7 ++++++- crates/engine/src/styles_behaviour_tests.rs | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index ff7838108..61f2c265f 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -782,7 +782,12 @@ pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) { let gone: Vec<(usize, usize)> = t2 .spans() .iter() - .filter(|(a, b, s)| s.style_id.as_deref().is_some_and(|sid| paras.get(sid).is_none_or(|ps| !t2.shows_paragraph_style_in(*a, *b, ps)))) + .filter(|(a, b, s)| { + // A style with no paragraph values (every style in older files) asks nothing of them. + s.style_id + .as_deref() + .is_some_and(|sid| paras.get(sid).is_none_or(|ps| **ps != ParagraphStyle::default() && !t2.shows_paragraph_style_in(*a, *b, ps))) + }) .map(|(a, b, _)| (*a, *b)) .collect(); for (a, b) in gone { diff --git a/crates/engine/src/styles_behaviour_tests.rs b/crates/engine/src/styles_behaviour_tests.rs index f1ebd96de..73fe1edef 100644 --- a/crates/engine/src/styles_behaviour_tests.rs +++ b/crates/engine/src/styles_behaviour_tests.rs @@ -473,3 +473,21 @@ fn descriptions_and_library_styles() { run(&mut s, "style.update", json!({"style": st, "description": "Body text"})); assert_eq!(s.doc().unwrap().style(&st).unwrap().description, "Body text"); } + +#[test] +fn a_style_without_paragraph_values_keeps_a_bulleted_text_linked() { + // Styles from older files set no paragraph values, so a list or spacing on their text isn't + // an edit of the style: it stays linked (docs silent, pinned to how such files worked). + let mut s = Session::new(); + let t = text(&mut s, 0.0); + run( + &mut s, + "node.set", + json!({"id": t, "props": {"characters": "A +B"}}), + ); + let st = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Plain", "value": {"fontSize": 11}}))); + run(&mut s, "style.apply", json!({"ids": [t], "style": st})); + run(&mut s, "node.set", json!({"id": t, "props": {"listStyle": "bulleted", "paragraphSpacing": 6}})); + assert_eq!(node(&s, t).styles.text.as_deref(), Some(st.as_str())); +}