Files
figcraft/crates/doc/src/text.rs
T

415 lines
13 KiB
Rust

//! Text node data: characters plus a base style and styled runs.
use figcraft_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,
}
#[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,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum ListStyle {
#[default]
None,
Bulleted,
Numbered,
}
/// 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,
pub case: TextCase,
/// Run fills (None = node fills).
pub fills: Option<Vec<Paint>>,
/// Hyperlink.
pub link: 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,
case: TextCase::Original,
fills: None,
link: None,
}
}
}
impl TextStyle {
/// Numeric weight implied by the style name.
pub fn weight(&self) -> u16 {
style_weight(&self.font_style)
}
pub fn italic(&self) -> bool {
self.font_style.to_ascii_lowercase().contains("italic")
}
}
/// 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.
pub runs: Vec<StyleRun>,
pub auto_resize: TextAutoResize,
pub align_h: TextAlignH,
pub align_v: TextAlignV,
pub paragraph_spacing: f64,
pub paragraph_indent: f64,
pub list: ListStyle,
/// Max lines when truncating (0 = as many as fit).
pub max_lines: u32,
/// OpenType feature toggles (e.g. "liga": false).
pub features: std::collections::BTreeMap<String, bool>,
}
/// 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))
}
impl TextData {
pub fn new(characters: impl Into<String>) -> Self {
Self { characters: characters.into(), ..Default::default() }
}
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));
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();
}
/// 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}"));
}
}