//! Text objects (model only; layout and glyph outlines live in `vectorcraft-text`). use serde::{Deserialize, Serialize}; use vectorcraft_color::{Color, Paint}; use vectorcraft_geom::{Affine, PathData, Point, Rect}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum Justify { #[default] Left, Center, Right, JustifyLeft, JustifyCenter, JustifyRight, JustifyAll, } /// Character attributes (the Character panel). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct CharStyle { pub font_family: String, #[serde(default = "regular")] pub font_style: String, /// Size in points. pub size: f64, /// Leading in points; None = Auto (120% of size). #[serde(default, skip_serializing_if = "Option::is_none")] pub leading: Option, /// Tracking in 1/1000 em. #[serde(default)] pub tracking: f64, /// Kerning: None = Auto (metrics), Some(v) = manual in 1/1000 em. #[serde(default, skip_serializing_if = "Option::is_none")] pub kerning: Option, #[serde(default)] pub baseline_shift: f64, #[serde(default = "hundred")] pub h_scale: f64, #[serde(default = "hundred")] pub v_scale: f64, #[serde(default)] pub rotation: f64, pub fill: Paint, #[serde(default)] pub stroke: Paint, #[serde(default)] pub stroke_width: f64, #[serde(default)] pub underline: bool, #[serde(default)] pub strikethrough: bool, #[serde(default)] pub all_caps: bool, /// OpenType features that differ from the defaults (OpenType panel), as tags: `"dlig"` turns /// a feature on, `"-liga"` off. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub features: Vec, /// Character style (Character Styles panel) these attributes come from; None = Normal. #[serde(default, skip_serializing_if = "Option::is_none")] pub style_name: Option, } fn regular() -> String { "Regular".into() } fn hundred() -> f64 { 100.0 } impl Default for CharStyle { fn default() -> Self { Self { font_family: "Source Sans 3".into(), font_style: "Regular".into(), size: 12.0, leading: None, tracking: 0.0, kerning: None, baseline_shift: 0.0, h_scale: 100.0, v_scale: 100.0, rotation: 0.0, fill: Paint::solid(Color::BLACK), stroke: Paint::None, stroke_width: 0.0, underline: false, strikethrough: false, all_caps: false, features: vec![], style_name: None, } } } impl CharStyle { pub fn effective_leading(&self) -> f64 { self.leading.unwrap_or(self.size * 1.2) } } /// Tab stop alignment (Tabs panel). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum TabAlign { #[default] Left, Center, Right, /// Aligns on the first `align_on` character (a decimal point by default). Decimal, } /// A tab stop, measured from the left edge of the text (area type: the frame's left edge). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TabStop { pub position: f64, #[serde(default)] pub align: TabAlign, /// Leader characters repeated across the tab's gap (e.g. ". "). #[serde(default, skip_serializing_if = "String::is_empty")] pub leader: String, /// Decimal tabs align on this character. #[serde(default = "default_align_on")] pub align_on: char, } fn default_align_on() -> char { '.' } /// Distance between default tab stops when no explicit stop applies (½ inch). pub const DEFAULT_TAB_INTERVAL: f64 = 36.0; /// Paragraph attributes (the Paragraph panel). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct ParaStyle { #[serde(default)] pub justify: Justify, #[serde(default)] pub left_indent: f64, #[serde(default)] pub right_indent: f64, #[serde(default)] pub first_line_indent: f64, #[serde(default)] pub space_before: f64, #[serde(default)] pub space_after: f64, #[serde(default)] pub hyphenate: bool, /// Tab stops (Tabs panel), sorted by position. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tabs: Vec, /// Paragraph style (Paragraph Styles panel) these attributes come from; None = Normal. #[serde(default, skip_serializing_if = "Option::is_none")] pub style_name: Option, } /// Area Type Options "First Baseline" offset. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum FirstBaseline { /// The tallest glyph ascent touches the frame top (Illustrator's default). #[default] Ascent, CapHeight, XHeight, /// The first line's leading. Leading, /// Exactly `first_baseline_min` below the top. Fixed, } /// Text Wrap Options of a wrap object (Object → Text Wrap). #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct TextWrap { /// Distance kept between the text and the object, in points (Illustrator's default 6 pt). pub offset: f64, /// Invert Wrap: text flows inside the object instead of around it. pub invert: bool, } impl Default for TextWrap { fn default() -> Self { Self { offset: 6.0, invert: false } } } /// A resolved wrap shape on an area text object: the outline of a wrap object in text space. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct WrapShape { pub path: PathData, #[serde(flatten)] pub wrap: TextWrap, } /// Type on a Path effect: how each glyph is oriented on the path (Type → Type on a Path). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum PathEffect { /// Glyphs rotate with the path (the default). #[default] Rainbow, /// Vertical edges stay vertical; the baseline follows the path. Skew, /// Horizontal edges stay horizontal; vertical edges are perpendicular to the path. #[serde(rename = "3dRibbon")] Ribbon3d, /// No rotation: the left end of each glyph's baseline sits on the path. StairStep, /// The baseline centre sits on the path and glyphs point away from the path's centre. Gravity, } impl PathEffect { pub const ALL: [PathEffect; 5] = [PathEffect::Rainbow, PathEffect::Skew, PathEffect::Ribbon3d, PathEffect::StairStep, PathEffect::Gravity]; pub fn id(self) -> &'static str { match self { PathEffect::Rainbow => "rainbow", PathEffect::Skew => "skew", PathEffect::Ribbon3d => "3dRibbon", PathEffect::StairStep => "stairStep", PathEffect::Gravity => "gravity", } } pub fn parse(s: &str) -> Option { let k = s.to_ascii_lowercase().replace([' ', '-', '_'], ""); Self::ALL.into_iter().find(|e| e.id().to_ascii_lowercase() == k || (k == "ribbon3d" && *e == PathEffect::Ribbon3d)) } } /// Area Type Options: rows and columns, gutters, inset and first baseline of area type. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct AreaOptions { pub rows: usize, pub columns: usize, /// Gutter between rows/columns in points. pub gutter: f64, /// Inset from the frame edges in points. pub inset: f64, pub first_baseline: FirstBaseline, /// Minimum first-baseline offset in points. pub first_baseline_min: f64, } impl Default for AreaOptions { fn default() -> Self { Self { rows: 1, columns: 1, gutter: 18.0, inset: 0.0, first_baseline: FirstBaseline::Ascent, first_baseline_min: 0.0 } } } /// A named character or paragraph style: the attributes it sets (a subset of [`CharStyle`] or /// [`ParaStyle`] fields, by their serialized names). Text using it records the name. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct TextStyleDef { pub name: String, #[serde(default)] pub attrs: serde_json::Map, } /// A run of text sharing one character style. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct TextRun { pub text: String, pub style: CharStyle, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum TextKind { /// Point type: anchored at the baseline origin of the first line. Point, /// Area type flowed inside `frame` (document coordinates, untransformed by `xf`). Area { frame: PathData }, /// Type on a path, starting at `start` (0..1 of the path length). OnPath { path: PathData, start: f64 }, } /// A text object. `runs` split into paragraphs at `\n`. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct TextObject { pub kind: TextKind, /// Maps text space (origin = first baseline start for point type) to the document. pub xf: Affine, pub runs: Vec, #[serde(default)] pub para: ParaStyle, /// Area Type Options (area type only). #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub area: AreaOptions, /// Type on a Path effect (type on a path only). #[serde(default, rename = "pathEffect", skip_serializing_if = "crate::skip::is_default")] pub path_effect: PathEffect, /// Wrap objects above this area type, resolved by the engine after each edit (text space). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub wrap: Vec, /// Cached layout bounds in text space, filled in by the layout engine (not serialized). #[serde(skip)] pub cached_bounds: Option, } impl TextObject { pub fn point(origin: Point, text: &str, style: CharStyle) -> Self { Self { kind: TextKind::Point, xf: Affine::translate(origin.to_vec2()), runs: vec![TextRun { text: text.into(), style }], para: ParaStyle::default(), area: AreaOptions::default(), path_effect: PathEffect::default(), wrap: Vec::new(), cached_bounds: None, } } pub fn plain_text(&self) -> String { self.runs.iter().map(|r| r.text.as_str()).collect() } pub fn first_style(&self) -> CharStyle { self.runs.first().map(|r| r.style.clone()).unwrap_or_default() } /// Approximate bounds when no layout cache is available (0.55 em average advance). pub fn estimate_bounds(&self) -> Rect { let st = self.first_style(); let text = self.plain_text(); let lines: Vec<&str> = text.split('\n').collect(); let w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as f64 * st.size * 0.55; let lead = st.effective_leading(); Rect::new(0.0, -st.size * 0.8, w.max(1.0), -st.size * 0.8 + lead * lines.len().max(1) as f64) } pub fn bounds(&self) -> Option { match &self.kind { TextKind::Area { frame } => frame.bounds().map(|b| self.xf.transform_rect_bbox(b)), TextKind::OnPath { path, .. } => path.bounds().map(|b| self.xf.transform_rect_bbox(b)), TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))), } } pub fn transform(&mut self, a: Affine) { self.xf = a * self.xf; } } #[cfg(test)] mod tests { use super::*; #[test] fn point_text_basics() { let t = TextObject::point(Point::new(10.0, 20.0), "Hello", CharStyle::default()); assert_eq!(t.plain_text(), "Hello"); let b = t.bounds().unwrap(); assert!(b.x0 >= 10.0 - 1e-9 && b.y1 > 20.0); assert_eq!(CharStyle::default().effective_leading(), 14.399999999999999); } }