Files
figcraft/crates/doc/src/text.rs
T
bflatastic bf82f7ebf9 M4: text lists, hanging punctuation and decoration styles
The tail of the text area, from the public documentation of the target app's text settings, with a table-driven behaviour suite (each case derived in one line; "silent" cases pin today's behaviour).

- **Lists per paragraph** (`TextData::paragraphs`, one `ParaList {list, indent}` per paragraph; empty when the layer has one list style at level 0, so old files load and save unchanged). The list buttons, Shift+Cmd+8 / Shift+Cmd+7, `text.listStyle` and Type settings act on the paragraphs the selection touches while editing (the whole layer otherwise). The per-paragraph list follows edits: a new paragraph copies the one it split from, joined paragraphs keep the first one's.
- **Nesting** up to 8 levels: Tab nests a list item, Shift+Tab moves it out; Enter on an empty item and Backspace at an item's start move it a level out, then out of the list. Typing `- `, `* ` or `1. ` at the start of a plain paragraph starts a list. Numbers count per level (1. a. i., then round again), bullets go • ◦ ▪; a plain paragraph, a shallower item or the other list kind starts the count again. Each level indents by one marker gutter; a nested bullet a font lacks comes from a fallback font.
- **List spacing** (space between two items of a list, in place of paragraph spacing), **hanging lists** (the outer gutter outside the box, item text lined up with plain paragraphs) and **hanging punctuation** (opening quotes and brackets outside the start edge of left-aligned and justified lines, closing punctuation outside the end edge of right-aligned and justified ones; hung punctuation takes no room when lines wrap).
- **Decoration look** per character (`TextStyle::decoration_look`, boxed, None by default): style (solid, dotted, wavy), thickness and underline offset (auto, px or % of the font size), skip ink (underlines break around descenders) and colour (its own style index in the layout, so canvas, PNG and PDF paint it without renderer changes). Decorations are drawn from `Decoration::path()` (bar, dots or wave) on the canvas, in PNG, PDF and outlined SVG.
- `node.set` keys: `listStyle` and `indentation` (range-aware with start/end), `listSpacing`, `hangingPunctuation`, `hangingList`, `textDecorationStyle`, `textDecorationThickness`, `textDecorationOffset`, `textDecorationSkipInk`, `textDecorationColor`; documented in `KEYS` and docs/mcp.md. `text.edit.key` documents Tab.
- Inspect: `listStyle`, or `lineTypes` and `lineIndentations` per paragraph; `listSpacing`, `hangingPunctuation`, `hangingList`, the decoration keys. CSS: `text-decoration-style`, `-thickness`, `-color`, `text-underline-offset`, `text-decoration-skip-ink: none` (browsers skip ink by default). Dev Mode rows: decoration style, list spacing.
- REST import: `lineTypes` with `lineIndentations` per paragraph (they collapsed to one list style for the layer before), `listSpacing`, `hangingPunctuation`, `hangingList`, the decoration style, thickness, offset, skip ink and colour; instance override names for them.
- Type settings ▸ Basics: decoration style, thickness and offset fields (with Auto), Skip ink, decoration colour; List spacing; Hanging punctuation and Hanging list checkboxes; the list control acts on the edited paragraphs. New strings in en/es.
- Components carry the new text fields in the characters and text-style override groups; scaling scales list spacing and px decoration lengths.
- `TextData::path` is boxed (`Option<Box<TextPath>>`) to keep `NodeKind` under clippy's enum size limit after the new fields.
2026-10-12 02:09:10 +07:00

774 lines
28 KiB
Rust

//! 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 {
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<Color>,
}
#[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).
pub fn apply_after(self, s: &str, prev: Option<char>) -> 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 = prev.is_none_or(|c| !is_word_char(c));
for ch in s.chars() {
if start && ch.is_alphanumeric() {
out.extend(ch.to_uppercase());
start = false;
} else {
out.push(ch);
start = !is_word_char(ch);
}
}
out
}
}
}
/// Whether the char at a position preceded by `prev` starts a word (for title case).
pub fn starts_word(prev: Option<char>) -> bool {
prev.is_none_or(|c| !is_word_char(c))
}
}
/// Letters, digits and apostrophes continue a word (so "don't" stays "Don't").
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || matches!(c, '\'' | '\u{2019}')
}
#[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,
}
#[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; plain paragraphs are always 0).
pub indent: u8,
}
/// 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<Box<DecorationLook>>,
pub case: TextCase,
/// Run fills (None = node fills).
pub fills: Option<Vec<Paint>>,
/// Hyperlink.
pub link: Option<String>,
/// 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<String, f64>,
/// 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<String>,
}
impl Default for TextStyle {
fn default() -> Self {
Self {
font_family: "Inter".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<String>) {
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([' ', '-'], "");
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<StyleRun>,
#[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<ParaList>,
/// 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 (0 = as many as fit).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub max_lines: u32,
/// OpenType feature toggles (e.g. "liga": false).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub features: std::collections::BTreeMap<String, bool>,
/// 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<Box<TextPath>>,
}
/// 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<String>) -> Self {
Self { characters: characters.into(), ..Default::default() }
}
/// 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()
}
/// 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<TextStyle> = (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.
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);
}
/// 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 {
p - removed + 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, indent: 0 })
}
/// 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, indent: 0 };
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, levels stop at [`MAX_LIST_INDENT`]); one style for all with
/// no nesting folds back into `list`.
pub fn set_para_lists(&mut self, start: usize, end: usize, 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);
pl.indent = if pl.list == ListStyle::None { 0 } else { pl.indent.min(MAX_LIST_INDENT) };
}
if let Some(first) = self.paragraphs.first().copied()
&& first.indent == 0
&& self.paragraphs.iter().all(|p| *p == first)
{
self.list = first.list;
self.paragraphs.clear();
}
}
/// The list settings shared by the paragraphs chars `start..end` touch, or None when they differ.
pub fn common_para_list(&self, start: usize, end: usize) -> Option<ParaList> {
let (a, b) = self.para_span(start, end);
let first = self.para_list(a);
(a..=b).all(|p| self.para_list(p) == first).then_some(first)
}
/// 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) {
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) {
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<Option<&str>> {
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<String>) {
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 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 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::<Vec<_>>(), ["wdth"]);
assert!(valid_axis_tag("wght") && !valid_axis_tag("weight"));
}
}