//! 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, Vec2}; use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum Justify { /// Align to the start of each paragraph's direction ([`ParaStyle::direction`]): left for /// left-to-right paragraphs, right for right-to-left ones. New type's alignment. Auto, #[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, /// Which installed version of the font (its version string) when several versions of the /// family and style are installed and the text was set in one the family and style alone /// wouldn't pick (an imported file's type matched to it); None = the one they pick. #[serde(default, skip_serializing_if = "Option::is_none")] pub font_version: Option, /// Variation axis settings of a variable font (Character panel › Variable Font), by axis tag /// (`wght`, `wdth`), in the axis's own units. They replace the named instance's setting of /// each axis they name; the other axes keep the style's settings. Empty: the style as it is. #[serde(default, rename = "fontAxes", skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub font_axes: std::collections::BTreeMap, /// 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, /// Overprint Fill / Overprint Stroke of these characters (see [`crate::FillLayer::overprint`]). #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub overprint_fill: bool, #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub overprint_stroke: bool, /// Cap, join, miter limit and dashes of the character stroke (Stroke panel, with type /// selected); defaults as for object strokes. #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub stroke_cap: LineCap, #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub stroke_join: LineJoin, #[serde(default = "ten", skip_serializing_if = "is_ten")] pub stroke_miter_limit: f64, #[serde(default, skip_serializing_if = "Option::is_none")] pub stroke_dash: Option, /// Align Stroke of the character stroke (Stroke panel, with type selected): centred on the /// glyph outlines, inside or outside them, as for object strokes; the type stays live. #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub stroke_align: StrokeAlign, /// Superscript or subscript (Character panel). #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub position: CharPosition, /// Small Caps (Character panel): lowercase letters drawn as capitals at this percentage of the /// size (Document Setup → Type → Small Caps); None = off. #[serde(default, skip_serializing_if = "Option::is_none")] pub small_caps: Option, /// Character Alignment (Character panel menu): where characters smaller than the largest on /// their line line up with it. #[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")] pub char_align: CharAlign, /// Character Direction (Character panel menu, Middle Eastern options): these characters are /// set left to right or right to left inside their paragraph, each stretch of them a Unicode /// bidi embedding (UAX #9 LRE or RLE); None = Default, the direction the bidi algorithm gives /// them. #[serde(default, rename = "charDirection", skip_serializing_if = "Option::is_none")] pub char_direction: Option, /// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the /// font's own proportional widths, OpenType `palt` (#966); in vertical type upright glyphs take /// its proportional heights, `vpal`. #[serde(default, rename = "proportionalMetrics", skip_serializing_if = "crate::skip::is_default")] pub proportional_metrics: bool, /// Tate-chu-yoko (Character panel menu, East Asian options): in vertical type these characters /// stand side by side across one em of the column. Horizontal type keeps the mark and does not /// move them. Two or three half-width digits do this without the mark. /// #[serde(default, rename = "tateChuYoko", skip_serializing_if = "crate::skip::is_default")] pub tate_chu_yoko: bool, /// Digits (Character panel menu, Middle Eastern options): the numeral system the digits of /// these characters are drawn in, whatever digits were typed. #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub digits: Digits, /// No Break (Character panel menu): these characters are not broken across lines, including /// at a space or a hyphenation point inside the run. A run longer than the line stays together /// and can overflow it. /// #[serde(default, rename = "noBreak", skip_serializing_if = "crate::skip::is_default")] pub no_break: bool, } /// The numeral system digits are drawn in ([`CharStyle::digits`]): as typed, or every digit of /// the Western, Arabic-Indic and Eastern Arabic-Indic sets drawn in one of them. The text keeps /// the digits that were typed; only the glyphs change (like All Caps). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Digits { /// Each digit as typed. #[default] Default, /// Western digits 0–9 (the digits Arabic typography calls Arabic). Arabic, /// Arabic-Indic digits ٠–٩ (U+0660–U+0669), used in Arabic. Hindi, /// Eastern Arabic-Indic digits ۰–۹ (U+06F0–U+06F9), used in Persian and Urdu. Farsi, } impl Digits { /// The digit `c` is drawn as in this numeral system: the same digit in the chosen set, or `c` /// itself when `c` isn't a digit of the Western, Arabic-Indic or Eastern Arabic-Indic set. pub fn map(self, c: char) -> char { let value = match c { '0'..='9' => c as u32 - '0' as u32, '\u{660}'..='\u{669}' => c as u32 - 0x660, '\u{6F0}'..='\u{6F9}' => c as u32 - 0x6F0, _ => return c, }; let base = match self { Digits::Default => return c, Digits::Arabic => '0' as u32, Digits::Hindi => 0x660, Digits::Farsi => 0x6F0, }; char::from_u32(base + value).unwrap_or(c) } } /// Where a character smaller than the largest on its line lines up with it: on the Roman /// baseline, at the top (right, in vertical type), centre or bottom (left) of the ideographic /// em boxes, or at the top (right) or bottom (left) of the ideographic character faces (ICF, the /// average box of the ideographs inside the em box: OpenType's `icft` and `icfb` baselines). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum CharAlign { #[default] RomanBaseline, EmBoxTop, EmBoxCenter, EmBoxBottom, IcfTop, IcfBottom, } /// Superscript or subscript proportions in percent of the font size (Document Setup → Type). #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub struct ScriptMetrics { /// Glyph size. pub size: f64, /// Baseline offset: up for superscript, down for subscript. pub position: f64, } impl ScriptMetrics { pub const DEFAULT: Self = Self { size: 58.3, position: 33.3 }; /// Returns these proportions with the size kept to 1 to 500% and the position kept to -500 to /// 500%, the ranges `document.setup` takes for `superscript` and `subscript`. A value outside /// its range becomes the nearer end. pub fn in_range(self) -> Self { Self { size: self.size.clamp(1.0, 500.0), position: self.position.clamp(-500.0, 500.0) } } } /// Returns a small caps size kept to 1 to 200%, the range `document.setup` takes for /// `smallCapsSize`. A size outside it becomes the nearer end. fn small_caps_in_range(size: f64) -> f64 { size.clamp(1.0, 200.0) } impl Default for ScriptMetrics { fn default() -> Self { Self::DEFAULT } } /// Character position (Character panel Superscript / Subscript). The proportions are the /// document's when the position is applied, and follow later Document Setup changes. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum CharPosition { #[default] Normal, Superscript(ScriptMetrics), Subscript(ScriptMetrics), } impl CharPosition { /// Glyph scale and baseline shift (points, positive = up) for text of `size` points. pub fn scale_shift(self, size: f64) -> (f64, f64) { match self { CharPosition::Normal => (1.0, 0.0), CharPosition::Superscript(m) => (m.size / 100.0, m.position / 100.0 * size), CharPosition::Subscript(m) => (m.size / 100.0, -m.position / 100.0 * size), } } /// `normal`, `superscript` or `subscript`. pub fn id(self) -> &'static str { match self { CharPosition::Normal => "normal", CharPosition::Superscript(_) => "superscript", CharPosition::Subscript(_) => "subscript", } } /// Returns this position with its proportions kept by [`ScriptMetrics::in_range`]. pub fn in_range(self) -> Self { match self { CharPosition::Normal => CharPosition::Normal, CharPosition::Superscript(m) => CharPosition::Superscript(m.in_range()), CharPosition::Subscript(m) => CharPosition::Subscript(m.in_range()), } } } fn ten() -> f64 { 10.0 } fn is_ten(v: &f64) -> bool { *v == 10.0 } fn regular() -> String { "Regular".into() } fn hundred() -> f64 { 100.0 } fn yes() -> bool { true } impl Default for CharStyle { fn default() -> Self { Self { font_family: "Source Sans 3".into(), font_style: "Regular".into(), font_version: None, font_axes: std::collections::BTreeMap::new(), 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, overprint_fill: false, overprint_stroke: false, stroke_cap: LineCap::Butt, stroke_join: LineJoin::Miter, stroke_miter_limit: 10.0, stroke_dash: None, stroke_align: StrokeAlign::Center, position: CharPosition::Normal, char_align: CharAlign::RomanBaseline, char_direction: None, proportional_metrics: false, tate_chu_yoko: false, small_caps: None, digits: Digits::Default, no_break: false, } } } impl CharStyle { pub fn effective_leading(&self) -> f64 { self.leading.unwrap_or(self.size * 1.2) } /// Keeps the size, leading, baseline shift and horizontal and vertical scales in range: a size /// or scale of 0 or less becomes the default new type takes (12 pt, 100%), a negative leading /// becomes Auto, and a value past [`crate::MAX_COORD`] (a baseline shift past it either way) /// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a /// baseline shift that is not a number becomes 0. pub fn keep_dimensions_in_range(&mut self) { let default = Self::default(); let within = |v: f64, or: f64| if v > 0.0 { v.min(crate::MAX_COORD) } else { or }; self.size = within(self.size, default.size); self.h_scale = within(self.h_scale, default.h_scale); self.v_scale = within(self.v_scale, default.v_scale); self.leading = self.leading.filter(|l| *l >= 0.0).map(|l| l.min(crate::MAX_COORD)); self.baseline_shift = if self.baseline_shift.is_nan() { 0.0 } else { self.baseline_shift.clamp(-crate::MAX_COORD, crate::MAX_COORD) }; } /// Do the characters draw a stroke (a paint and a positive weight)? pub fn has_stroke(&self) -> bool { !self.stroke.is_none() && self.stroke_width > 0.0 } /// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes, /// alignment and overprint), so type strokes share the object strokes' geometry, rendering /// and export. pub fn stroke_layer(&self) -> StrokeLayer { StrokeLayer { overprint: self.overprint_stroke, cap: self.stroke_cap, join: self.stroke_join, miter_limit: self.stroke_miter_limit, dash: self.stroke_dash.clone(), align: self.stroke_align, ..StrokeLayer::new(self.stroke.clone(), self.stroke_width) } } /// Take the paint, weight, cap, join, miter limit, dashes and alignment of `st` as the /// character stroke (the options characters have no use for, such as arrowheads and width /// profiles, are dropped). pub fn set_stroke_layer(&mut self, st: &StrokeLayer) { self.stroke = st.paint.clone(); self.stroke_width = st.width; self.stroke_cap = st.cap; self.stroke_join = st.join; self.stroke_miter_limit = st.miter_limit; self.stroke_dash = st.dash.clone(); self.stroke_align = st.align; } /// How far the character stroke paints past the glyph outlines (text space): half the weight /// centred, the whole weight outside, nothing inside (miter spikes aside). pub fn stroke_outset(&self) -> f64 { if !self.has_stroke() { return 0.0; } match self.stroke_align { StrokeAlign::Center => self.stroke_width / 2.0, StrokeAlign::Outside => self.stroke_width, StrokeAlign::Inside => 0.0, } } /// The character fill as a fill layer, overprinting as the characters do. fn fill_layer(&self) -> FillLayer { FillLayer { overprint: self.overprint_fill, ..FillLayer::new(self.fill.clone()) } } /// The characters' paint as an object appearance (their outlines'): the fill, plus the /// stroke when it draws one, overprinting as the characters do. pub fn appearance(&self) -> Appearance { let mut a = Appearance { items: vec![AppearanceItem::Fill(self.fill_layer())], ..Default::default() }; if self.has_stroke() { a.items.push(AppearanceItem::Stroke(self.stroke_layer())); } a } /// The characters' paint as the basic fill and stroke rows (the stroke row even when it has /// no paint): the appearance the Eyedropper and graphic styles take from type. pub fn basic_appearance(&self) -> Appearance { Appearance { items: vec![AppearanceItem::Fill(self.fill_layer()), AppearanceItem::Stroke(self.stroke_layer())], ..Default::default() } } /// Keeps the superscript or subscript proportions in range with [`CharPosition::in_range`] and /// keeps the small caps size to 1 to 200%, the range `document.setup` takes for `smallCapsSize`. pub fn keep_scripts_in_range(&mut self) { self.position = self.position.in_range(); self.small_caps = self.small_caps.map(small_caps_in_range); } } impl crate::Document { /// Keeps the superscript, subscript and small caps proportions to the ranges `document.setup` /// takes ([`CharStyle::keep_scripts_in_range`]) in the type of the layers, symbols, graph /// designs, pattern swatches and the pattern definition that pattern editing's Cancel puts /// back (opacity-mask art included), in graphs' label styles and in the character styles. It /// recurses through the art: call it on a document no deeper than [`crate::depth::MAX_DEPTH`]. pub fn keep_scripts_in_range(&mut self) { let mut fix = |n: &mut crate::Node| { if let crate::NodeKind::Text(t) = &mut n.kind { for r in &mut t.runs { r.style.keep_scripts_in_range(); } } if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) { let l = &mut g.label_styles; for st in [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten() { st.keep_scripts_in_range(); } } }; let roots = self .layers .iter_mut() .chain(self.symbols.iter_mut().map(|s| &mut s.art)) .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())); for root in roots { crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix); } // Character styles store the attributes they set by their serialized names. for def in self.char_styles.iter_mut() { if let Some(v) = def.attrs.get_mut("position") && let Ok(p) = serde_json::from_value::(v.clone()) && p.in_range() != p { *v = serde_json::to_value(p.in_range()).unwrap_or(serde_json::Value::Null); } if let Some(v) = def.attrs.get_mut("small_caps") && let Some(size) = v.as_f64() && small_caps_in_range(size) != size { *v = small_caps_in_range(size).into(); } } } } /// 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 composer (Paragraph panel menu): how lines are broken. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Composer { /// Break each line as soon as it is full. SingleLine, /// Total fit over the whole paragraph (Illustrator's default): justified lines get even word /// spacing, ragged lines an even rag. #[default] EveryLine, } impl Composer { fn is_default(&self) -> bool { *self == Self::default() } } /// A paragraph's hyphenation options (Paragraph panel menu › Hyphenation…): which words automatic /// hyphenation may break, where, and on how many lines in a row. Saved only when they differ from /// the defaults: words longer than 5 letters, 3 letters on each side of the hyphen, no hyphen /// limit, no hyphenation zone, and capitalized words hyphenated. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct HyphenationOptions { /// Words Longer Than: only words with more letters than this are hyphenated. pub words_longer_than: u32, /// After First: the fewest letters a word keeps before the hyphen. pub after_first: u32, /// Before Last: the fewest letters a word keeps after the hyphen. pub before_last: u32, /// Hyphen Limit: the most consecutive lines that automatic hyphenation may end with a hyphen; /// 0 sets no limit. pub hyphen_limit: u32, /// Hyphenation Zone (pt): with the Single-line Composer, a line whose text before the next /// word already ends within this distance of its right edge breaks before that word instead /// of hyphenating it. 0 allows every hyphenation. pub zone: f64, /// Hyphenate Capitalized Words: when it is off, words that start with a capital letter are not /// hyphenated. Words in capitals only are never hyphenated. pub capitalized: bool, } impl Default for HyphenationOptions { fn default() -> Self { Self { words_longer_than: 5, after_first: 3, before_last: 3, hyphen_limit: 0, zone: 0.0, capitalized: true } } } /// How a justified line holding a single word is set ([`JustificationOptions::single_word`]). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum SingleWordJustify { /// Spread across the line, between its letters. #[default] FullJustify, AlignLeft, AlignCenter, AlignRight, } /// A paragraph's justification options (Paragraph panel menu › Justification…), in percent. /// /// Each spacing has a Minimum, a Desired and a Maximum value (`[min, desired, max]`). Desired /// applies to every line of the paragraph, justified or not: word spaces are that share of the /// font's word space, each letter is followed by that share of a word space, and glyphs are that /// wide. Justified lines may vary them between Minimum and Maximum to fill the measure. Saved only /// when they differ from the defaults: word spacing 80/100/133, letter spacing 0/0/0, glyph /// scaling 100/100/100, Auto Leading 120 and Full Justify. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct JustificationOptions { /// Word Spacing, percent of the font's word space (0..1000). pub word_spacing: [f64; 3], /// Letter Spacing, percent of the font's word space added after each letter (-100..500). pub letter_spacing: [f64; 3], /// Glyph Scaling, percent of the glyphs' width (50..200). pub glyph_scaling: [f64; 3], /// Auto Leading: the leading of characters whose leading is Auto, in percent of their size /// (0..500). pub auto_leading: f64, /// Single Word Justification: how a justified line holding one word is set. pub single_word: SingleWordJustify, } impl JustificationOptions { /// Word Spacing's range (percent). pub const WORD_SPACING: (f64, f64) = (0.0, 1000.0); /// Letter Spacing's range (percent). pub const LETTER_SPACING: (f64, f64) = (-100.0, 500.0); /// Glyph Scaling's range (percent). pub const GLYPH_SCALING: (f64, f64) = (50.0, 200.0); /// Auto Leading's range (percent). pub const AUTO_LEADING: (f64, f64) = (0.0, 500.0); /// Are Desired word spacing, letter spacing and glyph scaling the defaults (nothing to change /// in a line's natural widths)? pub fn desired_is_default(&self) -> bool { let d = Self::default(); self.word_spacing[1] == d.word_spacing[1] && self.letter_spacing[1] == d.letter_spacing[1] && self.glyph_scaling[1] == d.glyph_scaling[1] } /// Keeps options read from a file to what `text.setFormat` can set: a value that is not a /// finite number becomes its default, every value is clamped to its range, and a Minimum, /// Desired and Maximum out of order go back to their defaults together. pub fn keep_in_range(&mut self) { let d = Self::default(); for (v, dv, (lo, hi)) in [ (&mut self.word_spacing, d.word_spacing, Self::WORD_SPACING), (&mut self.letter_spacing, d.letter_spacing, Self::LETTER_SPACING), (&mut self.glyph_scaling, d.glyph_scaling, Self::GLYPH_SCALING), ] { for (x, dx) in v.iter_mut().zip(dv) { *x = if x.is_finite() { x.clamp(lo, hi) } else { dx }; } if !(v[0] <= v[1] && v[1] <= v[2]) { *v = dv; } } let (lo, hi) = Self::AUTO_LEADING; self.auto_leading = if self.auto_leading.is_finite() { self.auto_leading.clamp(lo, hi) } else { d.auto_leading }; } /// A paragraph's leading for characters of `size` whose leading is Auto. pub fn auto_leading_for(&self, size: f64) -> f64 { size * self.auto_leading / 100.0 } } impl Default for JustificationOptions { fn default() -> Self { Self { word_spacing: [80.0, 100.0, 133.0], letter_spacing: [0.0, 0.0, 0.0], glyph_scaling: [100.0, 100.0, 100.0], auto_leading: 120.0, single_word: SingleWordJustify::FullJustify, } } } /// Insert Kashidas (Paragraph panel): how long the kashidas are that lengthen the Arabic words of /// a justified paragraph's lines. A kashida is a tatweel (U+0640) drawn between a word's last pair /// of joined letters, at most one in each word. Short, Medium and Long kashidas are at most a quarter /// of an em, half an em and an em long; a Stylistic kashida is a row of whole tatweels as the font /// draws them, at most an em long. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Kashida { /// No kashidas: justified lines widen their word spaces. #[default] None, Short, Medium, Long, Stylistic, } impl Kashida { /// The longest kashida, in ems of the letter that follows it in the text; 0 for None. pub fn max_em(self) -> f64 { match self { Kashida::None => 0.0, Kashida::Short => 0.25, Kashida::Medium => 0.5, Kashida::Long | Kashida::Stylistic => 1.0, } } } /// Paragraph attributes (the Paragraph panel). /// /// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before /// [`Justify::Auto`]. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(from = "ParaStyleFile", into = "ParaStyleFile")] pub struct ParaStyle { pub justify: Justify, pub left_indent: f64, pub right_indent: f64, pub first_line_indent: f64, pub space_before: f64, pub space_after: f64, pub hyphenate: bool, /// The limits automatic hyphenation keeps to (Paragraph panel menu › Hyphenation…). pub hyphenation: HyphenationOptions, /// Word and letter spacing, glyph scaling, Auto Leading and Single Word Justification /// (Paragraph panel menu › Justification…). pub justification: JustificationOptions, /// Line breaking: Single-line or Every-line Composer. pub composer: Composer, /// Tab stops (Tabs panel), sorted by position. pub tabs: Vec, /// Paragraph style (Paragraph Styles panel) these attributes come from; None = Normal. pub style_name: Option, /// Japanese composition: the spacing of punctuation (Paragraph panel › Mojikumi). Type made /// with the Type tools and `text.create` takes [`Mojikumi::LineEndHalf`]; documents from before /// it and imported text (already set) keep [`Mojikumi::None`]. pub mojikumi: Mojikumi, /// Paragraph direction (Paragraph panel): the base direction of each paragraph for /// bidirectional text (UAX #9). None: from each paragraph's first strong character (Hebrew or /// Arabic: right to left). pub direction: Option, /// How leading is measured (Paragraph panel menu): from baseline to baseline, or from the top /// of one line's ideographic em box to the next. pub leading_model: LeadingModel, /// Hanging punctuation (Paragraph panel menu › Burasagari): a comma or full stop ending a line /// may stand outside the frame. New type takes [`Burasagari::Standard`]; documents from before /// it and imported text keep [`Burasagari::None`]. pub burasagari: Burasagari, /// Kinsoku (Paragraph panel › Kinsoku Set): which Japanese characters may not start or end a /// line. pub kinsoku: Kinsoku, /// Roman Hanging Punctuation (Paragraph panel menu): quotation marks, hyphens, full stops and /// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses, /// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari. pub roman_hanging: bool, /// Insert Kashidas (Paragraph panel): justified lines lengthen their Arabic words with /// kashidas before they widen their word spaces. pub kashida: Kashida, } impl ParaStyle { /// Keeps the indents and the space before and after to -[`crate::MAX_COORD`] to /// [`crate::MAX_COORD`], and the tab stop positions to 0 to 100,000 pt, the range /// `text.tabs.set` takes. A value outside its range becomes the nearer end, and one that is /// not a number becomes 0. pub fn keep_spacing_in_range(&mut self) { let within = |v: f64, lo: f64, hi: f64| if v.is_nan() { 0.0 } else { v.clamp(lo, hi) }; for v in [&mut self.left_indent, &mut self.right_indent, &mut self.first_line_indent, &mut self.space_before, &mut self.space_after] { *v = within(*v, -crate::MAX_COORD, crate::MAX_COORD); } for t in &mut self.tabs { t.position = within(t.position, 0.0, 100_000.0); } } } /// [`ParaStyle`] as saved. [`Justify::Auto`] is written as the alignment it has in the paragraph /// direction (`Right` for right to left, else `Left`) with `justify_auto` set: builds without Auto /// read the alignment and ignore the flag, and builds with it read Auto back. #[derive(Serialize, Deserialize)] struct ParaStyleFile { #[serde(default)] justify: Justify, #[serde(default, skip_serializing_if = "crate::skip::is_default")] justify_auto: bool, #[serde(default)] left_indent: f64, #[serde(default)] right_indent: f64, #[serde(default)] first_line_indent: f64, #[serde(default)] space_before: f64, #[serde(default)] space_after: f64, #[serde(default)] hyphenate: bool, #[serde(default, skip_serializing_if = "crate::skip::is_default")] hyphenation: HyphenationOptions, #[serde(default, skip_serializing_if = "crate::skip::is_default")] justification: JustificationOptions, #[serde(default, skip_serializing_if = "Composer::is_default")] composer: Composer, #[serde(default, skip_serializing_if = "Vec::is_empty")] tabs: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] style_name: Option, #[serde(default, skip_serializing_if = "Mojikumi::is_none")] mojikumi: Mojikumi, #[serde(default, skip_serializing_if = "Option::is_none")] direction: Option, #[serde(default, skip_serializing_if = "crate::skip::is_default")] leading_model: LeadingModel, #[serde(default, skip_serializing_if = "crate::skip::is_default")] burasagari: Burasagari, #[serde(default, skip_serializing_if = "crate::skip::is_default")] kinsoku: Kinsoku, #[serde(default, skip_serializing_if = "crate::skip::is_default")] roman_hanging: bool, #[serde(default, skip_serializing_if = "crate::skip::is_default")] kashida: Kashida, } impl From for ParaStyleFile { fn from(p: ParaStyle) -> Self { let justify_auto = p.justify == Justify::Auto; let justify = match p.justify { Justify::Auto if p.direction == Some(ParaDirection::RightToLeft) => Justify::Right, Justify::Auto => Justify::Left, j => j, }; let ParaStyle { left_indent, right_indent, first_line_indent, space_before, space_after, hyphenate, hyphenation, justification, composer, tabs, style_name, mojikumi, direction, leading_model, burasagari, kinsoku, roman_hanging, kashida, .. } = p; Self { justify, justify_auto, left_indent, right_indent, first_line_indent, space_before, space_after, hyphenate, hyphenation, justification, composer, tabs, style_name, mojikumi, direction, leading_model, burasagari, kinsoku, roman_hanging, kashida, } } } impl From for ParaStyle { fn from(f: ParaStyleFile) -> Self { let justify = if f.justify_auto { Justify::Auto } else { f.justify }; let mut justification = f.justification; justification.keep_in_range(); let ParaStyleFile { left_indent, right_indent, first_line_indent, space_before, space_after, hyphenate, hyphenation, composer, tabs, style_name, mojikumi, direction, leading_model, burasagari, kinsoku, roman_hanging, kashida, .. } = f; Self { justify, left_indent, right_indent, first_line_indent, space_before, space_after, hyphenate, hyphenation, justification, composer, tabs, style_name, mojikumi, direction, leading_model, burasagari, kinsoku, roman_hanging, kashida, } } } /// A paragraph's base direction ([`ParaStyle::direction`]), or the direction characters are set /// in ([`CharStyle::char_direction`]). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum ParaDirection { LeftToRight, RightToLeft, } /// How a paragraph's leading is measured. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum LeadingModel { /// From one line's baseline to the next's: a line's leading is the space above it, and area /// type's first baseline follows Area Type Options › First Baseline. #[default] RomanBaseline, /// From the top of one line's ideographic em box (the right side, in vertical type) to the /// next's: a line's leading is the space below it, and area type's first line touches the top /// of the frame. Japanese layout's usual model. EmBoxTop, } /// How Japanese punctuation is spaced (JLREQ 3.1). Full-width punctuation is half a glyph and half /// a space: an opening bracket's space before it, a closing bracket's, a comma's or a full stop's /// after it. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Mojikumi { /// Every character takes its full advance. #[default] None, /// Consecutive punctuation shares one half-em space (JLREQ 3.1.4), and a closing bracket, /// comma or full stop ending a line is set half width. LineEndHalf, } impl Mojikumi { pub fn is_none(&self) -> bool { *self == Mojikumi::None } } /// Which Japanese characters may not start or end a line (kinsoku shori). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Kinsoku { /// No kinsoku: a line may break between any two characters that allow a break. None, /// Closing brackets, commas, full stops, iteration marks, the prolonged sound mark, small /// kana and the like don't start a line; opening brackets don't end one. #[default] Hard, /// As Hard, except that 々, the prolonged sound mark ー and small kana may start a line /// (JLREQ's level 3 line-breaking rules, Appendix C.3). Soft, } /// Hanging punctuation (burasagari): an East Asian comma or full stop ending a line (、。,.、。) /// stands outside the line's measure, in the space its punctuation spacing gives it (half width /// with Line-end Punctuation Half Width). Closing brackets and Latin punctuation don't hang. Shown /// as None / Regular / Force. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Burasagari { /// A comma or full stop that doesn't fit goes to the next line with the character before it. #[default] None, /// A comma or full stop that doesn't fit hangs outside the line; one that fits stays inside. Standard, /// A comma or full stop ending a line always hangs, and the rest of the line fills the measure. Forced, } /// 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, } /// Area Type Options "Align" (vertical): where the lines of each row/column sit in it. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum VerticalAlign { /// Lines start at the top of the cell (the default). #[default] Top, /// The block of lines is centred in the cell. Center, /// The last line's descent touches the cell bottom. Bottom, /// The first line stays at the top, the last one moves to the bottom and the space left over /// is shared equally between the lines (no paragraph spacing limit). Justify, } impl VerticalAlign { pub const ALL: [VerticalAlign; 4] = [VerticalAlign::Top, VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify]; pub fn id(self) -> &'static str { match self { VerticalAlign::Top => "top", VerticalAlign::Center => "center", VerticalAlign::Bottom => "bottom", VerticalAlign::Justify => "justify", } } } /// 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)) } } /// Type on a Path Options › Align to Path: which height of the type runs along the path. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum PathAlign { /// The font's top edge: the type hangs below the path. Ascender, /// The font's bottom edge: the type stands above the path. Descender, /// Halfway between the ascender and the descender. Center, /// The baseline (the default). #[default] Baseline, } impl PathAlign { pub const ALL: [PathAlign; 4] = [PathAlign::Ascender, PathAlign::Descender, PathAlign::Center, PathAlign::Baseline]; pub fn id(self) -> &'static str { match self { PathAlign::Ascender => "ascender", PathAlign::Descender => "descender", PathAlign::Center => "center", PathAlign::Baseline => "baseline", } } pub fn parse(s: &str) -> Option { Self::ALL.into_iter().find(|a| a.id().eq_ignore_ascii_case(s.trim())) } } /// 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 { #[serde(deserialize_with = "de_rows_or_columns")] pub rows: usize, #[serde(deserialize_with = "de_rows_or_columns")] 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, /// Vertical alignment of the lines in each row/column. pub vertical_align: VerticalAlign, /// How the frame and its text fit each other (Auto Size, Shrink Text to Fit). #[serde(skip_serializing_if = "crate::skip::is_default")] pub fit: AreaFit, } 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, vertical_align: VerticalAlign::Top, fit: AreaFit::None, } } } impl AreaOptions { /// The most rows, and the most columns, area type can have. pub const MAX_ROWS_COLUMNS: usize = 100; } /// [`AreaOptions::rows`] or [`AreaOptions::columns`], read as a `u64` and clamped to /// 1..=[`AreaOptions::MAX_ROWS_COLUMNS`]. fn de_rows_or_columns<'de, D: serde::Deserializer<'de>>(d: D) -> Result { let n = usize::try_from(u64::deserialize(d)?).unwrap_or(usize::MAX); Ok(n.clamp(1, AreaOptions::MAX_ROWS_COLUMNS)) } /// How area type and its frame fit each other. Serialized as `"none"`, `"autoHeight"` or /// `{"shrinkText": {"minPercent": 50}}`. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum AreaFit { /// The frame keeps its size; text that doesn't fit overflows. #[default] None, /// The frame's height follows the text (Illustrator's Auto Size): resolved by the engine after /// each edit, for rectangular frames of horizontal type in one row. AutoHeight, /// Text that overflows is scaled down (size, leading and baseline shift; not paragraph /// spacing) by the largest factor down to `min_percent` % that makes it fit, at layout time. ShrinkText { #[serde(rename = "minPercent", default = "AreaFit::default_min_percent")] min_percent: f64, }, } impl AreaFit { /// The smallest Shrink Text to Fit percentage allowed. pub const MIN_PERCENT: f64 = 10.0; /// The Shrink Text to Fit percentage new settings start with. pub const DEFAULT_MIN_PERCENT: f64 = 50.0; fn default_min_percent() -> f64 { Self::DEFAULT_MIN_PERCENT } /// Its id: `none`, `autoHeight` or `shrinkText`. pub fn id(self) -> &'static str { match self { AreaFit::None => "none", AreaFit::AutoHeight => "autoHeight", AreaFit::ShrinkText { .. } => "shrinkText", } } /// The fit an id names (case, spaces, dashes and underscores ignored), with `min_percent` /// for Shrink Text (clamped to 10..100; a non-finite value gives the default). pub fn parse(id: &str, min_percent: Option) -> Option { match id.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() { "none" | "off" => Some(AreaFit::None), "autoheight" | "autosize" => Some(AreaFit::AutoHeight), "shrinktext" | "shrinktexttofit" | "shrink" => { Some(AreaFit::ShrinkText { min_percent: Self::clamp_percent(min_percent.unwrap_or(Self::DEFAULT_MIN_PERCENT)) }) } _ => None, } } /// A Shrink Text minimum percentage within 10..100 (the default when not finite). pub fn clamp_percent(p: f64) -> f64 { if p.is_finite() { p.clamp(Self::MIN_PERCENT, 100.0) } else { Self::DEFAULT_MIN_PERCENT } } /// Shrink Text's minimum scale factor (0.1..1), if this is Shrink Text. pub fn min_scale(self) -> Option { match self { AreaFit::ShrinkText { min_percent } => Some(Self::clamp_percent(min_percent) / 100.0), _ => None, } } } /// 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, /// An inline graphic: the run is one [`INLINE_CHAR`] drawn as a document symbol that flows /// with the text like a glyph (InDesign-style inline anchored object). Its `style` still sets /// the size and tracking; fills and strokes don't apply to the art. #[serde(default, skip_serializing_if = "Option::is_none")] pub inline: Option, } impl TextRun { /// A plain text run. pub fn new(text: impl Into, style: CharStyle) -> Self { Self { text: text.into(), style, inline: None } } /// An inline graphic run showing `art`. pub fn inline(art: InlineArt, style: CharStyle) -> Self { Self { text: INLINE_CHAR.to_string(), style, inline: Some(art) } } } /// The object replacement character: the plain text of an inline graphic run. pub const INLINE_CHAR: char = '\u{FFFC}'; /// Largest inline graphic scale (× the run's size) accepted from input. pub const INLINE_MAX_SCALE: f64 = 100.0; /// A document symbol placed inline in text ([`TextRun::inline`]). /// /// Placement: the art is scaled uniformly so its height (visual bounds) is `scale` × the run's /// font size, its left edge at the pen position, and its vertical centre on the middle of the /// run's cap height, raised by `baseline_shift` (points, positive = up). Centred on the cap /// height the art lines up with capitals and figures (like a mana symbol in rules text) and, at /// the default scale, stays inside the font's ascent and descent so the leading doesn't change. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct InlineArt { /// Name of the document symbol ([`crate::Symbol::name`]). pub symbol: String, /// Art height as a multiple of the run's font size. #[serde(default = "one")] pub scale: f64, /// Extra raise in points (positive = up). #[serde(default)] pub baseline_shift: f64, /// The symbol art's visual bounds at its natural size, resolved from the document's symbols by /// [`crate::Document::resolve_inline_art`] (not saved). `None`: not resolved or missing; the /// layout then reserves a one-em square and nothing is drawn. #[serde(skip)] pub bounds: Option, } fn one() -> f64 { 1.0 } fn fifty() -> f64 { 50.0 } impl InlineArt { pub fn new(symbol: impl Into) -> Self { Self { symbol: symbol.into(), scale: 1.0, baseline_shift: 0.0, bounds: None } } /// `scale`, made finite and positive (untrusted input). pub fn safe_scale(&self) -> f64 { if self.scale.is_finite() && self.scale > 0.0 { self.scale.min(INLINE_MAX_SCALE) } else { 1.0 } } /// `baseline_shift`, made finite (untrusted input). pub fn safe_shift(&self) -> f64 { if self.baseline_shift.is_finite() { self.baseline_shift.clamp(-1e5, 1e5) } else { 0.0 } } } #[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, flowing from its start bracket `start` to its end bracket `end` (0..1 of the /// path length; no `end`: the end of the path, or once round a closed path). See /// [`TextKind::path_span`]. OnPath { path: PathData, start: f64, #[serde(default, skip_serializing_if = "Option::is_none")] end: Option, }, } impl TextKind { /// Type on a path's span: from its start to its end bracket, as fractions of its path's length. /// Round a closed path the end may be past 1 (the span runs on past the path's start), a full /// turn without an end or with the end at the start. None for other type. pub fn path_span(&self) -> Option<(f64, f64)> { let TextKind::OnPath { path, start, end } = self else { return None }; let s = fraction(*start); Some(if path.is_closed() { let run = end.map_or(0.0, |e| (fraction(e) - s).rem_euclid(1.0)); (s, s + if run < 1e-9 { 1.0 } else { run }) } else { (s, end.map_or(1.0, |e| fraction(e).max(s))) }) } } /// `x` as a fraction of a path's length (0..1; 0 when not finite). fn fraction(x: f64) -> f64 { if x.is_finite() { x.clamp(0.0, 1.0) } else { 0.0 } } /// `path` run the other way, each point as far from its new start as it was from its old end /// (the subpaths in reverse order, a closed one keeping its first anchor first). fn reverse_from_end(path: &mut PathData) { path.subpaths.reverse(); for sp in &mut path.subpaths { sp.reverse(); if sp.closed && !sp.anchors.is_empty() { sp.anchors.rotate_right(1); } } } /// Where ruby (furigana) text sits: above its base text in horizontal type, right of it in /// vertical type (the Japanese convention), or below (left in vertical type). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum RubyPosition { #[default] Above, Below, } /// How ruby text lines up over its base text when the two are different widths. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum RubyAlign { /// Centred over the base text. #[default] Center, /// Starting at the base text's start (left in horizontal type, top in vertical). Start, /// Ending at the base text's end (right in horizontal type, bottom in vertical). End, } /// Ruby (furigana, phonetic guide): a reading set over a range of the text. /// /// `start`/`end` are byte offsets into [`TextObject::plain_text`] (the same space as /// [`crate::TextObject`] commands' `start`/`end`). The base text itself stays the text; /// emptying the reading removes the ruby. New ruby is half the base text's size, centred, and /// allowed to overhang neighbouring characters when the reading is longer than its base. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RubySpan { /// Byte offset of the first base character in the plain text. pub start: usize, /// Byte offset just past the last base character. pub end: usize, /// The reading, set in the base characters' font (its size comes from `size_percent`). pub text: String, #[serde(default)] pub position: RubyPosition, #[serde(default)] pub align: RubyAlign, /// Ruby size as a percentage of the base text's size, 1–100 (50 = half, the usual). #[serde(default = "fifty")] pub size_percent: f64, /// Let a reading wider than its base hang over the neighbouring characters instead of being /// squeezed to the base's width. #[serde(default = "yes")] pub overhang: bool, } impl RubySpan { /// Ruby over `start..end` with reading `text`, at the defaults (half size, centred, overhanging). pub fn new(start: usize, end: usize, text: impl Into) -> Self { Self { start, end, text: text.into(), position: RubyPosition::Above, align: RubyAlign::Center, size_percent: 50.0, overhang: true } } /// `size_percent` made finite and usable: 1–100% (untrusted input). pub fn safe_size_percent(&self) -> f64 { if self.size_percent.is_finite() { self.size_percent.clamp(1.0, 100.0) } else { 50.0 } } } /// A text object. `runs` split into paragraphs at `\n`. /// /// Paragraph attributes: `para` is paragraph 0's (and, while `paras` is empty, every /// paragraph's). Invariant (kept by [`TextObject::normalize_paras`]): `paras` is either empty — /// every paragraph uses `para` — or holds one style per paragraph with `paras[0] == para` and at /// least two different styles. Readers that only know `para` see the first paragraph's style. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct TextObject { /// Top-to-bottom, right-to-left writing. Defaults to horizontal for old documents. #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub vertical: bool, pub kind: TextKind, /// Maps text space (origin = first baseline start for point type) to the document. pub xf: Affine, pub runs: Vec, /// Ruby (furigana / phonetic guide) over byte ranges of the plain text; empty = none. Kept on /// the object, so the runs (and their styles) stay the text itself. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub ruby: Vec, #[serde(default)] pub para: ParaStyle, /// Per-paragraph attributes (see the type's docs); empty = every paragraph uses `para`. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub paras: Vec, /// 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, /// Type on a Path Options › Align to Path (type on a path only). #[serde(default, rename = "pathAlign", skip_serializing_if = "crate::skip::is_default")] pub path_align: PathAlign, /// Type on a Path Options › Spacing in points (type on a path only): glyphs are spaced as if /// set this far above the path, which closes them up round the outside of a curve and opens /// them up round the inside. #[serde(default, rename = "pathSpacing", skip_serializing_if = "crate::skip::is_default")] pub path_spacing: f64, /// 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, /// Type › Optical Margin Alignment: in every paragraph, Latin punctuation and the slanted /// sides of letters such as A and W at a line's start or end hang outside the margin there /// (point and area type, and type on a path at its brackets; see the layout's `optical_hangs`). /// Off for new type and for documents from before it, and saved only when on. #[serde(default, rename = "opticalMarginAlignment", skip_serializing_if = "crate::skip::is_default")] pub optical_margin: bool, /// Character panel menu › System Layout, copied from the document: when on, each character /// that takes space advances by a whole number of points. Off by default, and saved only when /// on. The document setting is [`crate::DocSetup::system_layout`]. #[serde(default, rename = "systemLayout", skip_serializing_if = "crate::skip::is_default")] pub system_layout: bool, /// Character panel menu › Fractional Widths: spacing between characters uses fractions of a /// point. On for new type and for documents from before it, and saved only when off. Off, each /// character that takes any space advances by a whole number of points, at least one, so small /// type does not run together. The setting is the whole type object, not a range of characters. /// #[serde(default = "yes", rename = "fractionalWidths", skip_serializing_if = "crate::skip::is_true")] pub fractional_widths: bool, /// Cached layout bounds in text space, filled in by the layout engine (not serialized). #[serde(skip)] pub cached_bounds: Option, /// Cached baselines in text space, start to end, one per line that holds characters (a /// vertical column's centre line), filled in with `cached_bounds` (not serialized). #[serde(skip)] pub cached_baselines: Vec<(Point, Point)>, } /// Conversion from stored character attributes to document-space type controls. /// Point size follows the transformed em-height axis; horizontal scale is the ratio of /// the two transformed em axes. Rotation, reflection and shear remain in the affine. #[derive(Clone, Copy, Debug)] pub struct TextStyleScale { pub points: f64, pub horizontal: f64, } impl TextObject { /// None for collapsed or unrepresentable transforms, whose type dimensions cannot /// be edited by compensating the stored attributes. pub fn style_scale(&self) -> Option { let [a, b, c, d, e, f] = self.xf.as_coeffs(); if ![a, b, c, d, e, f].iter().all(|v| v.is_finite()) { return None; } let (sx, sy) = (a.hypot(b), c.hypot(d)); let determinant = (a / sx) * (d / sy) - (b / sx) * (c / sy); let horizontal = sx / sy; if !sy.is_finite() || sy <= 0.0 || !horizontal.is_finite() || horizontal <= 0.0 || !determinant.is_finite() || determinant.abs() <= 1e-12 { return None; } // Avoid a pure rotation turning otherwise identical fields into mixed values. let unit = |v: f64| if (v - 1.0).abs() <= 1e-12 { 1.0 } else { v }; Some(TextStyleScale { points: unit(sy), horizontal: unit(horizontal) }) } /// Character attributes as presented by controls (stored runs stay local for layout /// and rich-text clipboard operations). Explicit vertical character scale stays separate. pub fn effective_char_style(&self, style: &CharStyle) -> Option { let scale = self.style_scale()?; let mut style = style.clone(); style.size *= scale.points; style.leading = style.leading.map(|v| v * scale.points); style.baseline_shift *= scale.points; style.h_scale *= scale.horizontal; if ![style.size, style.leading.unwrap_or(0.0), style.baseline_shift, style.h_scale].iter().all(|v| v.is_finite()) { return None; } Some(style) } /// Bakes the object's scale ([`style_scale`]) into character attributes and removes that /// scale from `xf`, so panels show document-point sizes without a separate transform factor. /// Returns `false` when there is nothing to bake (identity scale or an unrepresentable /// transform). pub fn scale_text_attributes(&mut self) -> bool { let Some(scale) = self.style_scale() else { return false }; if (scale.points - 1.0).abs() <= 1e-12 && (scale.horizontal - 1.0).abs() <= 1e-12 { return false; } let [a, b, c, d, _, _] = self.xf.as_coeffs(); let (sx, sy) = (a.hypot(b), c.hypot(d)); if sx <= 1e-12 || sy <= 1e-12 { return false; } let mut baked = Vec::with_capacity(self.runs.len()); for run in &self.runs { let Some(eff) = self.effective_char_style(&run.style) else { return false }; baked.push(eff); } for (run, style) in self.runs.iter_mut().zip(baked) { run.style = style; } // Character strokes are drawn in text space: widen them by the vertical scale we remove. self.scale_char_strokes(1.0 / sy); self.xf *= Affine::scale_non_uniform(1.0 / sx, 1.0 / sy); self.cached_bounds = None; self.cached_baselines.clear(); true } pub fn point(origin: Point, text: &str, style: CharStyle) -> Self { Self { vertical: false, kind: TextKind::Point, xf: Affine::translate(origin.to_vec2()), runs: vec![TextRun::new(text, style)], ruby: Vec::new(), para: ParaStyle::default(), paras: Vec::new(), area: AreaOptions::default(), path_effect: PathEffect::default(), path_align: PathAlign::default(), path_spacing: 0.0, wrap: Vec::new(), optical_margin: false, system_layout: false, fractional_widths: true, cached_bounds: None, cached_baselines: Vec::new(), } } pub fn plain_text(&self) -> String { self.runs.iter().map(|r| r.text.as_str()).collect() } /// Clamp the ruby spans to the plain text, drop empty/empty-reading ones and sort them. /// /// Called after loading and editing text: ranges from a file or an edit are untrusted, and a /// range past the text or an inverted one would lay out nothing (or panic in a silly place). pub fn normalize_ruby(&mut self) { let len: usize = self.runs.iter().map(|r| r.text.len()).sum(); for r in &mut self.ruby { r.start = r.start.min(len); r.end = r.end.min(len); if r.start > r.end { std::mem::swap(&mut r.start, &mut r.end); } r.text = r.text.trim().to_string(); } self.ruby.retain(|r| r.start < r.end && !r.text.is_empty()); self.ruby.sort_by_key(|r| (r.start, r.end)); } /// The ruby span covering byte `byte` of the plain text, if any. pub fn ruby_at(&self, byte: usize) -> Option<&RubySpan> { self.ruby.iter().find(|r| r.start <= byte && byte < r.end) } /// Ruby spans touching the plain-text byte range `start..end`, with their ranges ordered. pub fn ruby_in(&self, start: usize, end: usize) -> impl Iterator { let (a, b) = (start.min(end), start.max(end)); self.ruby.iter().filter(move |r| r.start < b && a < r.end) } /// Keep the ruby spans over the text after replacing plain-text bytes `start..end` with /// `inserted` bytes of text: spans after the edit move with the text, ones the edit runs into /// keep their start and end up where the replaced part ended. Call it after every edit of the /// runs (`vectorcraft_text::edit`), then [`Self::normalize_ruby`] has the last word. pub fn ruby_after_replace(&mut self, start: usize, end: usize, inserted: usize) { let (start, end) = (start.min(end), start.max(end)); let delta = inserted as i64 - (end.saturating_sub(start)) as i64; let shift = |v: usize| -> usize { (v as i64 + delta).max(0) as usize }; for r in &mut self.ruby { if r.end <= start { continue; } if r.start >= end { r.start = shift(r.start); r.end = shift(r.end); } else { r.start = r.start.min(start); r.end = shift(r.end).max(r.start); } } self.normalize_ruby(); } /// Number of paragraphs (`\n`-separated; empty text has one). pub fn paragraph_count(&self) -> usize { 1 + self.runs.iter().map(|r| r.text.bytes().filter(|&b| b == b'\n').count()).sum::() } /// Attributes of paragraph `i` (the last paragraph's past the end). pub fn para_at(&self, i: usize) -> &ParaStyle { self.paras.get(i).or(self.paras.last()).unwrap_or(&self.para) } /// Every paragraph's attributes, one per paragraph. pub fn paragraph_styles(&self) -> Vec { (0..self.paragraph_count()).map(|i| self.para_at(i).clone()).collect() } /// Set every paragraph's attributes from `styles` (one per paragraph; a short list repeats its /// last entry, a long one is cut). pub fn set_paragraph_styles(&mut self, styles: Vec) { if let Some(first) = styles.first() { self.para = first.clone(); } self.paras = styles; self.normalize_paras(); } /// The same attributes for every paragraph. pub fn set_all_paras(&mut self, style: ParaStyle) { self.para = style; self.paras.clear(); } /// Re-establish the `paras` invariant: one entry per paragraph (cut, or extended with the /// last), `paras[0] == para`, and empty when every paragraph is alike. pub fn normalize_paras(&mut self) { if self.paras.is_empty() { return; } let n = self.paragraph_count(); let last = self.paras.last().cloned().unwrap_or_default(); self.paras.resize(n, last); if let Some(first) = self.paras.first_mut() { first.clone_from(&self.para); } if self.paras.iter().all(|p| *p == self.para) { self.paras.clear(); } } /// Indices of the paragraphs that byte range `start..end` of the plain text touches (a caret /// touches its paragraph). pub fn paragraphs_in(&self, start: usize, end: usize) -> std::ops::Range { let text = self.plain_text(); let (a, b) = (start.min(end).min(text.len()), start.max(end).min(text.len())); let index = |byte: usize| text.as_bytes().get(..byte).map_or(0, |s| s.iter().filter(|&&c| c == b'\n').count()); index(a)..index(b) + 1 } /// Apply `f` to the attributes of paragraphs `range` (indices; None = every paragraph). pub fn edit_paras(&mut self, range: Option>, mut f: impl FnMut(&mut ParaStyle)) { match range { None => { f(&mut self.para); for p in &mut self.paras { f(p); } } Some(r) => { let mut v = self.paragraph_styles(); for p in v.iter_mut().take(r.end).skip(r.start) { f(p); } self.set_paragraph_styles(v); } } self.normalize_paras(); } /// Every stored paragraph style (`para` and the per-paragraph ones), for scans and renames. pub fn para_styles_mut(&mut self) -> impl Iterator { std::iter::once(&mut self.para).chain(self.paras.iter_mut()) } /// Every stored paragraph style (`para` and the per-paragraph ones). pub fn para_styles(&self) -> impl Iterator { std::iter::once(&self.para).chain(self.paras.iter()) } /// Update the paragraph styles for replacing bytes `start..end` of the current plain text by /// `insert` (call before changing the runs). The paragraph the range starts in keeps its /// style (merging paragraphs: the first one's wins); paragraphs the insertion starts take that /// style too (Return continues the paragraph it splits); later paragraphs keep theirs. pub fn splice_paras(&mut self, start: usize, end: usize, insert: &str) { if self.paras.is_empty() { return; } let r = self.paragraphs_in(start, end); let v = self.paragraph_styles(); let Some(keep) = v.get(r.start).cloned() else { return }; let added = insert.bytes().filter(|&b| b == b'\n').count(); let mut out: Vec = v.iter().take(r.start + 1).cloned().collect(); out.extend(std::iter::repeat_n(keep, added)); out.extend(v.iter().skip(r.end).cloned()); self.paras = out; if let Some(first) = self.paras.first() { self.para = first.clone(); } } 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()))), } } /// How far the characters' strokes paint past their glyphs, in document space: they scale /// with the type. pub fn char_stroke_outset(&self) -> f64 { let k = crate::node::Scaling::factor(self.xf).unwrap_or(1.0); self.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k } /// How far the characters' strokes can paint past their glyphs, their miter spikes and square /// caps included ([`StrokeLayer::reach`]), in document space: what a change of the type must /// redraw. They stretch with the type, so this is as far as its transform takes them on either /// axis. The visual bounds take [`Self::char_stroke_outset`]. pub fn char_stroke_reach(&self) -> f64 { let reach = self.runs.iter().filter(|r| r.style.has_stroke()).map(|r| r.style.stroke_layer().reach()).fold(0.0, f64::max); let [a, b, c, d, ..] = self.xf.as_coeffs(); reach * (a.abs() + c.abs()).max(b.abs() + d.abs()) } pub fn transform(&mut self, a: Affine) { self.xf = a * self.xf; } /// Type on a path's path in document space; None for other type. pub fn type_path(&self) -> Option { match &self.kind { TextKind::OnPath { path, .. } => Some(path.transformed(self.xf)), _ => None, } } /// Flip type on a path to the other side of its path (Type on a Path Options › Flip, or its /// centre bracket dragged across the path): the path runs the other way and the brackets swap /// ends, so the type keeps its stretch of the path. False, changing nothing, for other type. pub fn flip_on_path(&mut self) -> bool { let Some((s, e)) = self.kind.path_span() else { return false }; let TextKind::OnPath { path, start, end } = &mut self.kind else { return false }; reverse_from_end(path); if path.is_closed() { *start = (1.0 - e).rem_euclid(1.0); *end = (e - s < 1.0 - 1e-9).then(|| (1.0 - s).rem_euclid(1.0)); } else { *start = 1.0 - e; *end = (s > 1e-9).then_some(1.0 - s); } true } /// Area type's frame (the type area) in document space; None for other type. pub fn area_frame(&self) -> Option { match &self.kind { TextKind::Area { frame } => Some(frame.transformed(self.xf)), _ => None, } } /// Reshape area type's frame by `a`, a document-space transform, keeping the type at its size /// (a bounding-box resize: the text reflows, it isn't scaled). False, changing nothing, for /// other type, a degenerate `xf` or a frame that would not be finite. pub fn transform_area(&mut self, a: Affine) -> bool { let xf = self.xf; self.reshape_area(|frame, to_text| { frame.transform(to_text * a * xf); true }) } /// Move anchors `refs` (subpath, anchor) of area type's frame by `d` in document space, with /// their handles (a Direct Selection drag of a frame corner or edge). False, changing /// nothing, when an anchor doesn't exist, or as [`Self::transform_area`]. pub fn move_area_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool { self.reshape_area(|frame, to_text| { // A distance: only the linear part of the document → text map applies. let local = to_text * d.to_point() - to_text * Point::ZERO; refs.iter().all(|&(si, ai)| frame.anchor_mut(si, ai).map(|a| a.translate(local)).is_some()) }) } /// Edit a copy of the area frame with `f` (given the document → text space map) and keep it /// when `f` succeeds and the result is finite. fn reshape_area(&mut self, f: impl FnOnce(&mut PathData, Affine) -> bool) -> bool { let det = self.xf.determinant(); if !det.is_finite() || det.abs() < 1e-12 { return false; } let to_text = self.xf.inverse(); let TextKind::Area { frame } = &mut self.kind else { return false }; let mut next = frame.clone(); if !f(&mut next, to_text) || !next.anchors().all(|(_, _, a)| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) { return false; } *frame = next; true } /// Scale the character strokes' weights and dashes by `s` (they are drawn in text space, so /// `1 / scale` keeps their weight through a transform that scales the type). pub fn scale_char_strokes(&mut self, s: f64) { for r in &mut self.runs { r.style.stroke_width *= s; if let Some(d) = &mut r.style.stroke_dash { d.scale(s); } } } /// Layout bounds in text space (the layout cache, else the estimate): the box an unplaced run /// gradient fits, as the renderers lay it out. pub fn local_bounds(&self) -> Rect { self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()) } /// The baselines in text space (the layout cache): for point type with none cached, the first /// one across [`Self::local_bounds`] (down the first column's centre line for vertical type). pub fn baselines(&self) -> impl Iterator + '_ { let first = (self.cached_baselines.is_empty() && matches!(self.kind, TextKind::Point)).then(|| { let b = self.local_bounds(); if self.vertical { (Point::new(0.0, b.y0), Point::new(0.0, b.y1)) } else { (Point::new(b.x0, 0.0), Point::new(b.x1, 0.0)) } }); self.cached_baselines.iter().copied().chain(first) } /// Keeps every run's size, leading, baseline shift and scales in range /// ([`CharStyle::keep_dimensions_in_range`]) and every paragraph's indents, spacing and tab /// stops ([`ParaStyle::keep_spacing_in_range`]). pub fn keep_type_values_in_range(&mut self) { for r in &mut self.runs { r.style.keep_dimensions_in_range(); } for p in self.para_styles_mut() { p.keep_spacing_in_range(); } } } impl crate::Document { /// Keeps the character dimensions, indents, paragraph spacing and tab stops in range /// ([`TextObject::keep_type_values_in_range`]) in the type of the layers, symbols, graph /// designs, pattern swatches and the pattern definition that pattern editing's Cancel puts /// back (opacity-mask art included), and the same attributes of the character and paragraph /// styles. It recurses through the art: call it on a document no deeper than /// [`crate::depth::MAX_DEPTH`]. pub fn keep_type_values_in_range(&mut self) { let mut fix = |n: &mut crate::Node| { if let crate::NodeKind::Text(t) = &mut n.kind { t.keep_type_values_in_range(); } }; let roots = self .layers .iter_mut() .chain(self.symbols.iter_mut().map(|s| &mut s.art)) .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())); for root in roots { crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix); } // Styles store the attributes they set by their serialized names (a number, or null for // Auto leading); a number is rewritten only when it changes. for def in self.char_styles.iter_mut() { let num = |k: &str| def.attrs.get(k).and_then(serde_json::Value::as_f64); let mut st = CharStyle { leading: num("leading"), ..CharStyle::default() }; st.size = num("size").unwrap_or(st.size); st.h_scale = num("h_scale").unwrap_or(st.h_scale); st.v_scale = num("v_scale").unwrap_or(st.v_scale); st.baseline_shift = num("baseline_shift").unwrap_or(st.baseline_shift); st.keep_dimensions_in_range(); let kept = [ ("size", Some(st.size)), ("h_scale", Some(st.h_scale)), ("v_scale", Some(st.v_scale)), ("baseline_shift", Some(st.baseline_shift)), ("leading", st.leading), ]; for (k, v) in kept { if let Some(old) = def.attrs.get_mut(k) && old.is_number() && old.as_f64() != v { *old = v.into(); } } } for def in self.para_styles.iter_mut() { for k in ["left_indent", "right_indent", "first_line_indent", "space_before", "space_after"] { if let Some(v) = def.attrs.get_mut(k) && let Some(x) = v.as_f64() && x.abs() > crate::MAX_COORD { *v = x.clamp(-crate::MAX_COORD, crate::MAX_COORD).into(); } } for t in def.attrs.get_mut("tabs").and_then(serde_json::Value::as_array_mut).into_iter().flatten() { if let Some(v) = t.get_mut("position") && let Some(x) = v.as_f64() && !(0.0..=100_000.0).contains(&x) { *v = x.clamp(0.0, 100_000.0).into(); } } } } /// Clamps the ruby spans of the type in the layers, symbols, graph designs, pattern swatches /// and the pattern definition that pattern editing's Cancel puts back (opacity-mask art /// included) to their text ([`TextObject::normalize_ruby`]). Loaded files and edits carry /// ranges from outside: a range past the text, an inverted one or an empty reading would lay /// out nothing. It recurses through the art: call it on a document no deeper than /// [`crate::depth::MAX_DEPTH`]. pub fn fix_ruby(&mut self) { let mut fix = |n: &mut crate::Node| { if let crate::NodeKind::Text(t) = &mut n.kind { t.normalize_ruby(); } }; let roots = self .layers .iter_mut() .chain(self.symbols.iter_mut().map(|s| &mut s.art)) .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())); for root in roots { crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix); } } } #[cfg(test)] mod tests { use super::*; /// Character Direction is saved only when set, and styles written before it existed read as /// Default. #[test] fn character_direction_is_saved_only_when_set() { let st = CharStyle::default(); assert_eq!(st.char_direction, None); assert!(serde_json::to_value(&st).unwrap().get("charDirection").is_none()); for (d, key) in [(ParaDirection::LeftToRight, "leftToRight"), (ParaDirection::RightToLeft, "rightToLeft")] { let set = CharStyle { char_direction: Some(d), ..CharStyle::default() }; assert_eq!(serde_json::to_value(&set).unwrap()["charDirection"], key); assert_eq!(serde_json::from_value::(serde_json::to_value(&set).unwrap()).unwrap(), set); } let old = serde_json::to_value(&st).unwrap(); assert_eq!(serde_json::from_value::(old).unwrap().char_direction, None); } /// Ruby saves with the type and a file saved before it (no `ruby` key) reads with none. #[test] fn ruby_is_saved_and_missing_ruby_reads_as_none() { let mut t = TextObject::point(Point::ZERO, "漢字", CharStyle::default()); t.ruby = vec![RubySpan { size_percent: 40.0, overhang: false, ..RubySpan::new(0, 6, "かんじ") }]; let json = serde_json::to_string(&t).unwrap(); assert!(json.contains("かんじ")); let back: TextObject = serde_json::from_str(&json).unwrap(); assert_eq!(back.ruby, t.ruby); assert!((back.ruby[0].size_percent - 40.0).abs() < 1e-9 && !back.ruby[0].overhang); // Old files have no `ruby` key: none. let mut old: serde_json::Value = serde_json::from_str(&json).unwrap(); old.as_object_mut().unwrap().remove("ruby"); assert!(serde_json::from_value::(old).unwrap().ruby.is_empty()); // A damaged file's range past the text and its empty reading are clamped away. let mut damaged = t.clone(); damaged.ruby = vec![RubySpan::new(0, 10_000, "かんじ"), RubySpan::new(3, 2, " "), RubySpan::new(0, 3, "")]; damaged.normalize_ruby(); assert_eq!((damaged.ruby[0].start, damaged.ruby[0].end), (0, 6)); } /// Digits map every digit of the three sets into the chosen one, leave the rest alone, and /// are saved only when set. #[test] fn digits_map_between_the_numeral_systems() { let map = |d: Digits, s: &str| -> String { s.chars().map(|c| d.map(c)).collect() }; for s in ["0123456789", "٠١٢٣٤٥٦٧٨٩", "۰۱۲۳۴۵۶۷۸۹"] { assert_eq!(map(Digits::Default, s), s); assert_eq!(map(Digits::Arabic, s), "0123456789"); assert_eq!(map(Digits::Hindi, s), "٠١٢٣٤٥٦٧٨٩"); assert_eq!(map(Digits::Farsi, s), "۰۱۲۳۴۵۶۷۸۹"); } // Letters, punctuation and other scripts' digits (Devanagari ३) are not digits of these sets. assert_eq!(map(Digits::Hindi, "a-Z ३ ٪ ۔"), "a-Z ३ ٪ ۔"); let st = CharStyle::default(); assert!(serde_json::to_value(&st).unwrap().get("digits").is_none()); let farsi = CharStyle { digits: Digits::Farsi, ..CharStyle::default() }; assert_eq!(serde_json::to_value(&farsi).unwrap()["digits"], "farsi"); // Styles written before the attribute existed (no `digits` key) read as Default. let old = serde_json::to_value(&st).unwrap(); assert_eq!(serde_json::from_value::(old).unwrap().digits, Digits::Default); assert_eq!(serde_json::from_value::(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi); } /// A file can't feed layout justification values `text.setFormat` would refuse: each is /// clamped to its range, one that isn't finite takes its default, and a Minimum, Desired and /// Maximum out of order go back to their defaults. #[test] fn justification_options_read_from_a_file_stay_in_range() { let d = JustificationOptions::default(); let read: ParaStyle = serde_json::from_value(serde_json::json!({ "justification": { "word_spacing": [-5.0, 1e300, 1e308], "letter_spacing": [10.0, 5.0, 20.0], "glyph_scaling": [1.0, 100.0, 9e9], "auto_leading": -1e12 } })) .unwrap(); let j = read.justification; assert_eq!(j.word_spacing, [0.0, 1000.0, 1000.0]); assert_eq!(j.letter_spacing, d.letter_spacing, "out of order"); assert_eq!(j.glyph_scaling, [50.0, 100.0, 200.0]); assert_eq!(j.auto_leading, 0.0); let mut j = JustificationOptions { word_spacing: [f64::NAN, 100.0, f64::INFINITY], letter_spacing: [-1.0, 0.0, f64::NEG_INFINITY], auto_leading: f64::NAN, ..d.clone() }; j.keep_in_range(); assert_eq!((j.word_spacing, j.letter_spacing, j.auto_leading), (d.word_spacing, [-1.0, 0.0, 0.0], d.auto_leading)); let mut same = d.clone(); same.keep_in_range(); assert_eq!(same, d); } #[test] fn character_controls_reject_collapsed_and_nonfinite_transforms() { let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default()); for matrix in [[0.0; 6], [1.0, 1.0, 2.0, 2.0, 0.0, 0.0], [f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0], [1.0, 0.0, 0.0, 1.0, f64::NAN, 0.0]] { t.xf = Affine::new(matrix); assert!(t.effective_char_style(&t.first_style()).is_none()); } t.xf = Affine::rotate(0.83); assert_eq!(t.effective_char_style(&t.first_style()).unwrap().size, 12.0); t.xf = Affine::scale(1e308); assert!(t.effective_char_style(&t.first_style()).is_none(), "overflowing point size"); } #[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); } fn justified(j: Justify) -> ParaStyle { ParaStyle { justify: j, ..ParaStyle::default() } } #[test] fn paragraph_styles_split_merge_and_normalize() { let mut t = TextObject::point(Point::ZERO, "one\ntwo\nthree", CharStyle::default()); assert_eq!((t.paragraph_count(), t.paras.len()), (3, 0), "one style for all: nothing stored"); assert_eq!(t.paragraphs_in(0, 0), 0..1); assert_eq!(t.paragraphs_in(4, 4), 1..2); assert_eq!(t.paragraphs_in(2, 9), 0..3); assert_eq!(t.paragraphs_in(99, 99), 2..3, "clamped to the text"); t.edit_paras(Some(1..2), |p| p.justify = Justify::Center); let js = |t: &TextObject| t.paragraph_styles().iter().map(|p| p.justify).collect::>(); assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left]); // Return inside "two": the new paragraph continues its style. t.splice_paras(5, 5, "\n"); t.runs[0].text.insert(5, '\n'); assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Center, Justify::Left]); // Deleting the break between "one" and "t": the first paragraph's style wins. t.splice_paras(3, 4, ""); t.runs[0].text.remove(3); assert_eq!(t.plain_text(), "onet\nwo\nthree"); assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left]); // An edit that skips the splice is repaired by normalizing (the last style continues). t.runs[0].text.push_str("\nfour"); t.normalize_paras(); assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Left, Justify::Left]); // `para` is paragraph 0's; all alike collapses to `para` alone. t.edit_paras(Some(0..1), |p| p.justify = Justify::Right); assert_eq!(t.para.justify, Justify::Right); t.edit_paras(None, |p| p.justify = Justify::Right); assert!(t.paras.is_empty() && t.para.justify == Justify::Right); // A short list repeats its last entry, a long one is cut. t.set_paragraph_styles(vec![justified(Justify::Left), justified(Justify::Center)]); assert_eq!(js(&t), [Justify::Left, Justify::Center, Justify::Center, Justify::Center]); t.set_paragraph_styles(vec![justified(Justify::Center); 9]); assert!(t.paras.is_empty() && t.para.justify == Justify::Center); // Old documents (no `paras`) load; new ones keep paragraph 0 in `para`. t.edit_paras(Some(3..4), |p| p.space_before = 4.0); let json = serde_json::to_value(&t).unwrap(); assert_eq!(json["para"]["justify"], json["paras"][0]["justify"]); let mut old = json.clone(); old.as_object_mut().unwrap().remove("paras"); let back: TextObject = serde_json::from_value(old).unwrap(); assert!(back.paras.is_empty() && back.para_at(3).justify == Justify::Center); let back: TextObject = serde_json::from_value(json).unwrap(); assert_eq!(back.para_at(3).space_before, 4.0); } /// 120 × 40 area type at (40, 40), its text drawn at twice its size. fn area_type() -> TextObject { let mut t = TextObject::point(Point::ZERO, "Some text", CharStyle::default()); t.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 20.0)) }; t.xf = Affine::translate((40.0, 40.0)) * Affine::scale(2.0); t } #[test] fn area_resizes_its_frame_and_keeps_its_type() { let mut t = area_type(); assert_eq!(t.bounds(), Some(Rect::new(40.0, 40.0, 160.0, 80.0))); // Bottom-right handle to (200, 140), about the top-left corner. let o = Affine::translate((40.0, 40.0)); assert!(t.transform_area(o * Affine::scale_non_uniform(160.0 / 120.0, 100.0 / 40.0) * o.inverse())); assert_eq!(t.xf, Affine::translate((40.0, 40.0)) * Affine::scale(2.0), "the type keeps its size"); let b = t.bounds().unwrap(); assert!((b.x1 - 200.0).abs() < 1e-9 && (b.y1 - 140.0).abs() < 1e-9 && b.x0 == 40.0, "{b:?}"); assert_eq!(t.area_frame().unwrap().bounds(), Some(b)); // Point type has no area; a degenerate transform or a non-finite frame changes nothing. let mut p = TextObject::point(Point::ZERO, "x", CharStyle::default()); assert!(!p.transform_area(Affine::scale(2.0)) && p.area_frame().is_none()); let before = t.clone(); assert!(!t.transform_area(Affine::scale(f64::INFINITY))); t.xf = Affine::scale(0.0); assert!(!t.transform_area(Affine::scale(2.0))); t.xf = before.xf; assert_eq!(t, before); } #[test] fn area_anchors_move_in_document_space() { let mut t = area_type(); // The bottom-right corner (anchor 2) 20 pt right: 10 pt in text space at 2×. assert!(t.move_area_anchors(&[(0, 2)], Vec2::new(20.0, 0.0))); let TextKind::Area { frame } = &t.kind else { panic!() }; assert_eq!(frame.subpaths[0].anchors[2].p, Point::new(70.0, 20.0)); assert_eq!(frame.subpaths[0].anchors[1].p, Point::new(60.0, 0.0), "the others stay"); // A missing anchor changes nothing. let before = t.clone(); assert!(!t.move_area_anchors(&[(0, 1), (0, 9)], Vec2::new(5.0, 5.0))); assert!(!t.move_area_anchors(&[(3, 0)], Vec2::new(5.0, 5.0))); assert_eq!(t, before); } /// Builds from before [`Justify::Auto`] read new type's alignment as Left or Right (and ignore /// `justify_auto`); this build reads Auto back. #[test] fn auto_alignment_saves_readable_by_older_builds() { #[derive(Deserialize, Debug, PartialEq)] enum OldJustify { Left, Center, Right, JustifyLeft, JustifyCenter, JustifyRight, JustifyAll, } #[derive(Deserialize)] struct OldPara { justify: OldJustify, } #[derive(Deserialize)] struct OldText { para: OldPara, } for (direction, physical) in [(Some(ParaDirection::RightToLeft), OldJustify::Right), (Some(ParaDirection::LeftToRight), OldJustify::Left), (None, OldJustify::Left)] { let mut t = TextObject::point(Point::ZERO, "שלום", CharStyle::default()); t.para = ParaStyle { justify: Justify::Auto, direction, ..Default::default() }; let json = serde_json::to_string(&t).unwrap(); let old: OldText = serde_json::from_str(&json).unwrap(); assert_eq!(old.para.justify, physical, "{json}"); assert_eq!(serde_json::from_str::(&json).unwrap(), t); } // Other alignments save as before, without the flag. let p = ParaStyle { justify: Justify::Right, ..Default::default() }; let json = serde_json::to_string(&p).unwrap(); assert!(!json.contains("justify_auto"), "{json}"); assert_eq!(serde_json::from_str::(&json).unwrap(), p); // Files from before the flag keep their alignment. assert_eq!(serde_json::from_str::(r#"{"justify":"Center"}"#).unwrap().justify, Justify::Center); } }