From 901c72b0890de38bb9a317dc90fec9d34b7cd5d4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:48:52 +0000 Subject: [PATCH] refactor(doc): move text.rs items to text/{area,char_style,object,para_style,runs,tabs_lists}.rs --- crates/doc/src/text.rs | 2120 +---------------------------- crates/doc/src/text/area.rs | 228 ++++ crates/doc/src/text/char_style.rs | 432 ++++++ crates/doc/src/text/object.rs | 493 +++++++ crates/doc/src/text/para_style.rs | 517 +++++++ crates/doc/src/text/runs.rs | 207 +++ crates/doc/src/text/tabs_lists.rs | 249 ++++ 7 files changed, 2142 insertions(+), 2104 deletions(-) create mode 100644 crates/doc/src/text/area.rs create mode 100644 crates/doc/src/text/char_style.rs create mode 100644 crates/doc/src/text/object.rs create mode 100644 crates/doc/src/text/para_style.rs create mode 100644 crates/doc/src/text/runs.rs create mode 100644 crates/doc/src/text/tabs_lists.rs diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index f95c9ee28..a3ab2ccc5 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -6,1623 +6,22 @@ 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; - -/// The bullet of a bulleted list ([`ParaList::bullet`]): the Paragraph panel's Bullets presets. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Bullet { - /// A filled dot (•). - #[default] - Dot, - /// A dash (–). - Dash, - /// A hollow circle (◦). - Circle, - /// A filled square (▪). - Square, -} - -impl Bullet { - /// The character the layout draws for the bullet. - pub fn character(self) -> char { - match self { - Bullet::Dot => '\u{2022}', - Bullet::Dash => '\u{2013}', - Bullet::Circle => '\u{25E6}', - Bullet::Square => '\u{25AA}', - } - } -} - -/// How a numbered list writes its numbers ([`ParaList::number`]): the Paragraph panel's Numbered -/// List presets. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum NumberStyle { - /// 1, 2, 3… - #[default] - Arabic, - /// A, B, C… Z, AA, BB… - UpperLetters, - /// a, b, c… z, aa, bb… - LowerLetters, - /// I, II, III, IV… - UpperRoman, - /// i, ii, iii, iv… - LowerRoman, - /// 01, 02… 99, 100… - TwoDigits, - /// 001, 002… 999, 1000… - ThreeDigits, -} - -/// Past this number, roman numerals and letters are written in Arabic digits. -const MAX_LETTERS_OR_ROMAN: u32 = 3999; - -impl NumberStyle { - /// Every style, in the order the Numbered List presets list them. - pub const ALL: [NumberStyle; 7] = [ - NumberStyle::Arabic, - NumberStyle::UpperLetters, - NumberStyle::LowerLetters, - NumberStyle::UpperRoman, - NumberStyle::LowerRoman, - NumberStyle::TwoDigits, - NumberStyle::ThreeDigits, - ]; - - /// Number `n` (1 or more) written in this style, without a separator. Letters run A to Z, then - /// AA to ZZ, then AAA and so on; roman numerals use the subtractive forms (IV, IX, XL, XC, CD, - /// CM); both are written in Arabic digits past 3999. Two and three digits pad with zeros and - /// keep every digit of a longer number. - pub fn format(self, n: u32) -> String { - let letters_or_roman = - matches!(self, NumberStyle::UpperLetters | NumberStyle::LowerLetters | NumberStyle::UpperRoman | NumberStyle::LowerRoman); - if n == 0 || (letters_or_roman && n > MAX_LETTERS_OR_ROMAN) { - return n.to_string(); - } - match self { - NumberStyle::Arabic => n.to_string(), - NumberStyle::TwoDigits => format!("{n:02}"), - NumberStyle::ThreeDigits => format!("{n:03}"), - NumberStyle::UpperLetters => letters(n), - NumberStyle::LowerLetters => letters(n).to_lowercase(), - NumberStyle::UpperRoman => roman(n), - NumberStyle::LowerRoman => roman(n).to_lowercase(), - } - } -} - -/// `n` (1 or more) in capital letters: A to Z, then each letter twice, three times and so on. -fn letters(n: u32) -> String { - let n = n - 1; - let letter = char::from(b'A' + (n % 26) as u8); - std::iter::repeat_n(letter, (n / 26 + 1) as usize).collect() -} - -/// `n` (1 to 3999) in capital roman numerals. -fn roman(mut n: u32) -> String { - const DIGITS: [(u32, &str); 13] = [ - (1000, "M"), - (900, "CM"), - (500, "D"), - (400, "CD"), - (100, "C"), - (90, "XC"), - (50, "L"), - (40, "XL"), - (10, "X"), - (9, "IX"), - (5, "V"), - (4, "IV"), - (1, "I"), - ]; - let mut s = String::new(); - for (value, digits) in DIGITS { - while n >= value { - s.push_str(digits); - n -= value; - } - } - s -} - -/// The list a paragraph is in (Paragraph panel › Bullets and Numbered List). The layout draws the -/// list's marker before the paragraph's first line, in the font, size and color of the paragraph's -/// first character without its baseline shift, rotation, superscript or subscript, tracking, manual -/// kerning, tate-chu-yoko, underline and strikethrough ([`Self::marker_style`]), and the text after -/// it starts at the next tab stop, the left indent counting as a stop. The marker is not a -/// character of the text, and an empty paragraph has none. A saved list reads the fields it leaves -/// out as their defaults, and a build ignores the fields it doesn't know. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(default)] -pub struct ParaList { - /// The bullet each paragraph of a bulleted list starts with. A numbered list keeps it for - /// when the list is made bulleted again. - pub bullet: Bullet, - /// A numbered list's number style. A bulleted list has none, and the file saves it only for a - /// numbered list. - #[serde(skip_serializing_if = "Option::is_none")] - pub number: Option, -} - -impl ParaList { - /// A bulleted list with `bullet`. - pub fn bulleted(bullet: Bullet) -> Self { - Self { bullet, number: None } - } - - /// A numbered list with `style`. - pub fn numbered(style: NumberStyle) -> Self { - Self { bullet: Bullet::default(), number: Some(style) } - } - - /// The characters of the marker the layout draws before the paragraph: the bullet, or for a - /// numbered list number `n` in its style followed by a period. - pub fn marker(&self, n: u32) -> String { - match self.number { - Some(style) => format!("{}.", style.format(n)), - None => self.bullet.character().to_string(), - } - } - - /// The marker of the next paragraph of a story, whose list is `list` (None: in no list) and - /// which holds no text when `empty`, with `count` the numbered paragraphs counted before it. - /// A paragraph in no list sets the count back to 0 and has no marker; an empty paragraph has - /// none and leaves the count as it is; a bulleted paragraph has its bullet and leaves the - /// count as it is; a numbered paragraph adds one to the count and is numbered with it. - pub fn next_marker(list: Option, empty: bool, count: &mut u32) -> Option { - let Some(list) = list else { - *count = 0; - return None; - }; - if empty { - return None; - } - if list.number.is_some() { - *count = count.saturating_add(1); - } - Some(list.marker(*count)) - } - - /// The style the marker is set in: `first` (the style of the paragraph's first character) - /// without its baseline shift, rotation, superscript or subscript, tracking, manual kerning, - /// tate-chu-yoko, underline and strikethrough. - pub fn marker_style(first: &CharStyle) -> CharStyle { - CharStyle { - baseline_shift: 0.0, - rotation: 0.0, - position: CharPosition::Normal, - tracking: 0.0, - kerning: None, - tate_chu_yoko: false, - underline: false, - strikethrough: false, - ..first.clone() - } - } - - /// The list that the Type tool starts when `typed` and a space are typed at the start of a - /// paragraph: "1." or "1)" starts a numbered list in 1, 2, 3, "A." or "A)" starts one in A, B, - /// C, and "*", "#", ">" or "+" starts a bulleted list with the filled dot. Any other text starts - /// none. - pub fn typed(typed: &str) -> Option { - match typed { - "1." | "1)" => Some(Self::numbered(NumberStyle::Arabic)), - "A." | "A)" => Some(Self::numbered(NumberStyle::UpperLetters)), - "*" | "#" | ">" | "+" => Some(Self::bulleted(Bullet::Dot)), - _ => None, - } - } -} - -/// The left and first-line indents (pt) a list gives a paragraph whose indents are both 0: the -/// marker hangs in the first line's 18 pt before the text. -pub const LIST_HANGING_INDENT: f64 = 18.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, - /// Bullets (Paragraph panel): the list the paragraph is in; None for a paragraph that is not - /// in a list. - pub list: Option, - /// 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); - } - } - - /// The leading of characters styled `st` in this paragraph, as the layout sets it: their own - /// leading, or for Auto leading the paragraph's Justification › Auto Leading share of their - /// size (exactly [`CharStyle::effective_leading`] at the default 120%, or when the share is - /// not a finite number). - pub fn effective_leading(&self, st: &CharStyle) -> f64 { - let auto = self.justification.auto_leading; - if st.leading.is_some() || auto == JustificationOptions::default().auto_leading { - return st.effective_leading(); - } - let l = self.justification.auto_leading_for(st.size); - if l.is_finite() { l } else { st.effective_leading() } - } -} - -/// [`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")] - list: Option, - #[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, - list, - 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, - list, - 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, - list, - 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, - list, - 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))) - }) - } -} - -/// Move anchors `refs` of `path`, in text space, by `d` in document space (`to_text`: the -/// document → text map). False when an anchor doesn't exist. -fn move_anchors(path: &mut PathData, to_text: Affine, refs: &[(usize, usize)], d: Vec2) -> bool { - // 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)| path.anchor_mut(si, ai).map(|a| a.translate(local)).is_some()) -} - -/// `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 } - } -} +mod area; +mod char_style; +mod object; +mod para_style; +mod runs; +mod tabs_lists; + +pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextWrap, VerticalAlign, WrapShape}; +use char_style::yes; +pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, ScriptMetrics}; +pub use para_style::{ + Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection, + ParaStyle, SingleWordJustify, +}; +pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef}; +pub use tabs_lists::{Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, NumberStyle, ParaList, TabAlign, TabStop}; /// A text object. `runs` split into paragraphs at `\n`. /// @@ -1713,492 +112,5 @@ pub struct TextStyleScale { 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(), - first_para_continues: false, - 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, - list_count: 0, - 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 - } - /// The list that the characters before byte `at` start ([`ParaList::typed`]) with the byte - /// where their paragraph starts: when the plain text from the start of the paragraph up to - /// `at` is a list's start followed by a space, and the paragraph is in no list. None otherwise. - pub fn typed_list_at(&self, at: usize) -> Option<(usize, ParaList)> { - let text = self.plain_text(); - let before = text.get(..at)?; - let start = before.rfind('\n').map_or(0, |i| i + 1); - let list = ParaList::typed(before[start..].strip_suffix(' ')?)?; - let index = before.bytes().filter(|&b| b == b'\n').count(); - self.para_at(index).list.is_none().then_some((start, list)) - } - /// 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| move_anchors(frame, to_text, refs, d)) - } - /// Move anchors `refs` (subpath, anchor) of type on a path's path by `d` in document space, - /// with their handles (a Direct Selection drag of an anchor); the brackets keep their - /// fractions of the path. False, changing nothing, for other type, when an anchor doesn't - /// exist, a degenerate `xf` or a path that would not be finite. - pub fn move_path_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool { - matches!(self.kind, TextKind::OnPath { .. }) && self.reshape_local(|path, to_text| move_anchors(path, to_text, refs, d)) - } - /// 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 { - matches!(self.kind, TextKind::Area { .. }) && self.reshape_local(f) - } - /// [`Self::reshape_area`] for the area frame or type on a path's path. - fn reshape_local(&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 } | TextKind::OnPath { path: 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; diff --git a/crates/doc/src/text/area.rs b/crates/doc/src/text/area.rs new file mode 100644 index 000000000..5897d957a --- /dev/null +++ b/crates/doc/src/text/area.rs @@ -0,0 +1,228 @@ +use super::para_style::FirstBaseline; +use super::*; + +/// 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, + } + } +} diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs new file mode 100644 index 000000000..a19b5f71c --- /dev/null +++ b/crates/doc/src/text/char_style.rs @@ -0,0 +1,432 @@ +use super::para_style::ParaDirection; +use super::*; + +/// 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 +} + +pub(super) 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(); + } + } + } +} diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs new file mode 100644 index 000000000..58caa1602 --- /dev/null +++ b/crates/doc/src/text/object.rs @@ -0,0 +1,493 @@ +use super::area::{AreaOptions, PathAlign, PathEffect}; +use super::char_style::CharStyle; +use super::para_style::ParaStyle; +use super::runs::{RubySpan, TextKind, TextRun, move_anchors, reverse_from_end}; +use super::tabs_lists::ParaList; +use super::*; + +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(), + first_para_continues: false, + 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, + list_count: 0, + 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 + } + /// The list that the characters before byte `at` start ([`ParaList::typed`]) with the byte + /// where their paragraph starts: when the plain text from the start of the paragraph up to + /// `at` is a list's start followed by a space, and the paragraph is in no list. None otherwise. + pub fn typed_list_at(&self, at: usize) -> Option<(usize, ParaList)> { + let text = self.plain_text(); + let before = text.get(..at)?; + let start = before.rfind('\n').map_or(0, |i| i + 1); + let list = ParaList::typed(before[start..].strip_suffix(' ')?)?; + let index = before.bytes().filter(|&b| b == b'\n').count(); + self.para_at(index).list.is_none().then_some((start, list)) + } + /// 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| move_anchors(frame, to_text, refs, d)) + } + /// Move anchors `refs` (subpath, anchor) of type on a path's path by `d` in document space, + /// with their handles (a Direct Selection drag of an anchor); the brackets keep their + /// fractions of the path. False, changing nothing, for other type, when an anchor doesn't + /// exist, a degenerate `xf` or a path that would not be finite. + pub fn move_path_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool { + matches!(self.kind, TextKind::OnPath { .. }) && self.reshape_local(|path, to_text| move_anchors(path, to_text, refs, d)) + } + /// 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 { + matches!(self.kind, TextKind::Area { .. }) && self.reshape_local(f) + } + /// [`Self::reshape_area`] for the area frame or type on a path's path. + fn reshape_local(&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 } | TextKind::OnPath { path: 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); + } + } +} diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs new file mode 100644 index 000000000..6a01a1815 --- /dev/null +++ b/crates/doc/src/text/para_style.rs @@ -0,0 +1,517 @@ +use super::char_style::CharStyle; +use super::tabs_lists::{ParaList, TabStop}; +use super::*; + +#[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, +} + +/// 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, + /// Bullets (Paragraph panel): the list the paragraph is in; None for a paragraph that is not + /// in a list. + pub list: Option, + /// 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); + } + } + + /// The leading of characters styled `st` in this paragraph, as the layout sets it: their own + /// leading, or for Auto leading the paragraph's Justification › Auto Leading share of their + /// size (exactly [`CharStyle::effective_leading`] at the default 120%, or when the share is + /// not a finite number). + pub fn effective_leading(&self, st: &CharStyle) -> f64 { + let auto = self.justification.auto_leading; + if st.leading.is_some() || auto == JustificationOptions::default().auto_leading { + return st.effective_leading(); + } + let l = self.justification.auto_leading_for(st.size); + if l.is_finite() { l } else { st.effective_leading() } + } +} + +/// [`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")] + list: Option, + #[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, + list, + 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, + list, + 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, + list, + 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, + list, + 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, +} diff --git a/crates/doc/src/text/runs.rs b/crates/doc/src/text/runs.rs new file mode 100644 index 000000000..4e3de9338 --- /dev/null +++ b/crates/doc/src/text/runs.rs @@ -0,0 +1,207 @@ +use super::char_style::CharStyle; +use super::*; + +/// 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))) + }) + } +} + +/// Move anchors `refs` of `path`, in text space, by `d` in document space (`to_text`: the +/// document → text map). False when an anchor doesn't exist. +pub(super) fn move_anchors(path: &mut PathData, to_text: Affine, refs: &[(usize, usize)], d: Vec2) -> bool { + // 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)| path.anchor_mut(si, ai).map(|a| a.translate(local)).is_some()) +} + +/// `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). +pub(super) 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 } + } +} diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs new file mode 100644 index 000000000..763a82c69 --- /dev/null +++ b/crates/doc/src/text/tabs_lists.rs @@ -0,0 +1,249 @@ +use super::char_style::{CharPosition, CharStyle}; +use super::*; + +/// 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; + +/// The bullet of a bulleted list ([`ParaList::bullet`]): the Paragraph panel's Bullets presets. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Bullet { + /// A filled dot (•). + #[default] + Dot, + /// A dash (–). + Dash, + /// A hollow circle (◦). + Circle, + /// A filled square (▪). + Square, +} + +impl Bullet { + /// The character the layout draws for the bullet. + pub fn character(self) -> char { + match self { + Bullet::Dot => '\u{2022}', + Bullet::Dash => '\u{2013}', + Bullet::Circle => '\u{25E6}', + Bullet::Square => '\u{25AA}', + } + } +} + +/// How a numbered list writes its numbers ([`ParaList::number`]): the Paragraph panel's Numbered +/// List presets. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum NumberStyle { + /// 1, 2, 3… + #[default] + Arabic, + /// A, B, C… Z, AA, BB… + UpperLetters, + /// a, b, c… z, aa, bb… + LowerLetters, + /// I, II, III, IV… + UpperRoman, + /// i, ii, iii, iv… + LowerRoman, + /// 01, 02… 99, 100… + TwoDigits, + /// 001, 002… 999, 1000… + ThreeDigits, +} + +/// Past this number, roman numerals and letters are written in Arabic digits. +const MAX_LETTERS_OR_ROMAN: u32 = 3999; + +impl NumberStyle { + /// Every style, in the order the Numbered List presets list them. + pub const ALL: [NumberStyle; 7] = [ + NumberStyle::Arabic, + NumberStyle::UpperLetters, + NumberStyle::LowerLetters, + NumberStyle::UpperRoman, + NumberStyle::LowerRoman, + NumberStyle::TwoDigits, + NumberStyle::ThreeDigits, + ]; + + /// Number `n` (1 or more) written in this style, without a separator. Letters run A to Z, then + /// AA to ZZ, then AAA and so on; roman numerals use the subtractive forms (IV, IX, XL, XC, CD, + /// CM); both are written in Arabic digits past 3999. Two and three digits pad with zeros and + /// keep every digit of a longer number. + pub fn format(self, n: u32) -> String { + let letters_or_roman = + matches!(self, NumberStyle::UpperLetters | NumberStyle::LowerLetters | NumberStyle::UpperRoman | NumberStyle::LowerRoman); + if n == 0 || (letters_or_roman && n > MAX_LETTERS_OR_ROMAN) { + return n.to_string(); + } + match self { + NumberStyle::Arabic => n.to_string(), + NumberStyle::TwoDigits => format!("{n:02}"), + NumberStyle::ThreeDigits => format!("{n:03}"), + NumberStyle::UpperLetters => letters(n), + NumberStyle::LowerLetters => letters(n).to_lowercase(), + NumberStyle::UpperRoman => roman(n), + NumberStyle::LowerRoman => roman(n).to_lowercase(), + } + } +} + +/// `n` (1 or more) in capital letters: A to Z, then each letter twice, three times and so on. +fn letters(n: u32) -> String { + let n = n - 1; + let letter = char::from(b'A' + (n % 26) as u8); + std::iter::repeat_n(letter, (n / 26 + 1) as usize).collect() +} + +/// `n` (1 to 3999) in capital roman numerals. +fn roman(mut n: u32) -> String { + const DIGITS: [(u32, &str); 13] = [ + (1000, "M"), + (900, "CM"), + (500, "D"), + (400, "CD"), + (100, "C"), + (90, "XC"), + (50, "L"), + (40, "XL"), + (10, "X"), + (9, "IX"), + (5, "V"), + (4, "IV"), + (1, "I"), + ]; + let mut s = String::new(); + for (value, digits) in DIGITS { + while n >= value { + s.push_str(digits); + n -= value; + } + } + s +} + +/// The list a paragraph is in (Paragraph panel › Bullets and Numbered List). The layout draws the +/// list's marker before the paragraph's first line, in the font, size and color of the paragraph's +/// first character without its baseline shift, rotation, superscript or subscript, tracking, manual +/// kerning, tate-chu-yoko, underline and strikethrough ([`Self::marker_style`]), and the text after +/// it starts at the next tab stop, the left indent counting as a stop. The marker is not a +/// character of the text, and an empty paragraph has none. A saved list reads the fields it leaves +/// out as their defaults, and a build ignores the fields it doesn't know. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(default)] +pub struct ParaList { + /// The bullet each paragraph of a bulleted list starts with. A numbered list keeps it for + /// when the list is made bulleted again. + pub bullet: Bullet, + /// A numbered list's number style. A bulleted list has none, and the file saves it only for a + /// numbered list. + #[serde(skip_serializing_if = "Option::is_none")] + pub number: Option, +} + +impl ParaList { + /// A bulleted list with `bullet`. + pub fn bulleted(bullet: Bullet) -> Self { + Self { bullet, number: None } + } + + /// A numbered list with `style`. + pub fn numbered(style: NumberStyle) -> Self { + Self { bullet: Bullet::default(), number: Some(style) } + } + + /// The characters of the marker the layout draws before the paragraph: the bullet, or for a + /// numbered list number `n` in its style followed by a period. + pub fn marker(&self, n: u32) -> String { + match self.number { + Some(style) => format!("{}.", style.format(n)), + None => self.bullet.character().to_string(), + } + } + + /// The marker of the next paragraph of a story, whose list is `list` (None: in no list) and + /// which holds no text when `empty`, with `count` the numbered paragraphs counted before it. + /// A paragraph in no list sets the count back to 0 and has no marker; an empty paragraph has + /// none and leaves the count as it is; a bulleted paragraph has its bullet and leaves the + /// count as it is; a numbered paragraph adds one to the count and is numbered with it. + pub fn next_marker(list: Option, empty: bool, count: &mut u32) -> Option { + let Some(list) = list else { + *count = 0; + return None; + }; + if empty { + return None; + } + if list.number.is_some() { + *count = count.saturating_add(1); + } + Some(list.marker(*count)) + } + + /// The style the marker is set in: `first` (the style of the paragraph's first character) + /// without its baseline shift, rotation, superscript or subscript, tracking, manual kerning, + /// tate-chu-yoko, underline and strikethrough. + pub fn marker_style(first: &CharStyle) -> CharStyle { + CharStyle { + baseline_shift: 0.0, + rotation: 0.0, + position: CharPosition::Normal, + tracking: 0.0, + kerning: None, + tate_chu_yoko: false, + underline: false, + strikethrough: false, + ..first.clone() + } + } + + /// The list that the Type tool starts when `typed` and a space are typed at the start of a + /// paragraph: "1." or "1)" starts a numbered list in 1, 2, 3, "A." or "A)" starts one in A, B, + /// C, and "*", "#", ">" or "+" starts a bulleted list with the filled dot. Any other text starts + /// none. + pub fn typed(typed: &str) -> Option { + match typed { + "1." | "1)" => Some(Self::numbered(NumberStyle::Arabic)), + "A." | "A)" => Some(Self::numbered(NumberStyle::UpperLetters)), + "*" | "#" | ">" | "+" => Some(Self::bulleted(Bullet::Dot)), + _ => None, + } + } +} + +/// The left and first-line indents (pt) a list gives a paragraph whose indents are both 0: the +/// marker hangs in the first line's 18 pt before the text. +pub const LIST_HANGING_INDENT: f64 = 18.0;