Files
vectorcraft/crates/doc/src/text.rs
T
bflatasticandClaude Opus 5.5 5865e4cb51 Type: Top-to-Top Leading follow-ups (review of #385)
- The em box top that Top-to-Top Leading asks of every glyph's line metrics parsed the font's
  cmap and vertical metrics per glyph on every layout: each face now reads it once.
- The Paragraph panel menu shows Top-to-Top / Bottom-to-Bottom Leading with the East Asian
  options (Preferences › Type), as the Mojikumi Set does, and Reset Panel goes back to
  Bottom-to-Bottom Leading, the default.
- Saved as `leading_model`, like the other paragraph attributes in files.
- Simplified Chinese (landed since) translates the two menu items.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:25:18 +07:00

796 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Text objects (model only; layout and glyph outlines live in `vectorcraft-text`).
use serde::{Deserialize, Serialize};
use vectorcraft_color::{Color, Paint};
use vectorcraft_geom::{Affine, PathData, Point, Rect, Vec2};
use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, 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,
/// 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<f64>,
/// 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<f64>,
#[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<String>,
/// Character style (Character Styles panel) these attributes come from; None = Normal.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub style_name: Option<String>,
/// 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<Dash>,
/// 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<f64>,
}
/// 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 };
}
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",
}
}
}
fn ten() -> f64 {
10.0
}
fn is_ten(v: &f64) -> bool {
*v == 10.0
}
fn regular() -> String {
"Regular".into()
}
fn hundred() -> f64 {
100.0
}
impl Default for CharStyle {
fn default() -> Self {
Self {
font_family: "Source Sans 3".into(),
font_style: "Regular".into(),
size: 12.0,
leading: None,
tracking: 0.0,
kerning: None,
baseline_shift: 0.0,
h_scale: 100.0,
v_scale: 100.0,
rotation: 0.0,
fill: Paint::solid(Color::BLACK),
stroke: Paint::None,
stroke_width: 0.0,
underline: false,
strikethrough: false,
all_caps: false,
features: vec![],
style_name: None,
overprint_fill: false,
overprint_stroke: false,
stroke_cap: LineCap::Butt,
stroke_join: LineJoin::Miter,
stroke_miter_limit: 10.0,
stroke_dash: None,
position: CharPosition::Normal,
small_caps: None,
}
}
}
impl CharStyle {
pub fn effective_leading(&self) -> f64 {
self.leading.unwrap_or(self.size * 1.2)
}
/// 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 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(),
..StrokeLayer::new(self.stroke.clone(), self.stroke_width)
}
}
/// Take the paint, weight, cap, join, miter limit and dashes of `st` as the character stroke
/// (the options characters have no use for, such as alignment and arrowheads, 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();
}
/// 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() }
}
}
/// Tab stop alignment (Tabs panel).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TabAlign {
#[default]
Left,
Center,
Right,
/// Aligns on the first `align_on` character (a decimal point by default).
Decimal,
}
/// A tab stop, measured from the left edge of the text (area type: the frame's left edge).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TabStop {
pub position: f64,
#[serde(default)]
pub align: TabAlign,
/// Leader characters repeated across the tab's gap (e.g. ". ").
#[serde(default, skip_serializing_if = "String::is_empty")]
pub leader: String,
/// Decimal tabs align on this character.
#[serde(default = "default_align_on")]
pub align_on: char,
}
fn default_align_on() -> char {
'.'
}
/// Distance between default tab stops when no explicit stop applies (½ inch).
pub const DEFAULT_TAB_INTERVAL: f64 = 36.0;
/// Paragraph attributes (the Paragraph panel).
///
/// 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,
/// Tab stops (Tabs panel), sorted by position.
pub tabs: Vec<TabStop>,
/// Paragraph style (Paragraph Styles panel) these attributes come from; None = Normal.
pub style_name: Option<String>,
/// 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<ParaDirection>,
/// 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,
}
/// [`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 = "Vec::is_empty")]
tabs: Vec<TabStop>,
#[serde(default, skip_serializing_if = "Option::is_none")]
style_name: Option<String>,
#[serde(default, skip_serializing_if = "Mojikumi::is_none")]
mojikumi: Mojikumi,
#[serde(default, skip_serializing_if = "Option::is_none")]
direction: Option<ParaDirection>,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
leading_model: LeadingModel,
}
impl From<ParaStyle> 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,
tabs,
style_name,
mojikumi,
direction,
leading_model,
..
} = p;
Self {
justify,
justify_auto,
left_indent,
right_indent,
first_line_indent,
space_before,
space_after,
hyphenate,
tabs,
style_name,
mojikumi,
direction,
leading_model,
}
}
}
impl From<ParaStyleFile> for ParaStyle {
fn from(f: ParaStyleFile) -> Self {
let justify = if f.justify_auto { Justify::Auto } else { f.justify };
let ParaStyleFile {
left_indent,
right_indent,
first_line_indent,
space_before,
space_after,
hyphenate,
tabs,
style_name,
mojikumi,
direction,
leading_model,
..
} = f;
Self {
justify,
left_indent,
right_indent,
first_line_indent,
space_before,
space_after,
hyphenate,
tabs,
style_name,
mojikumi,
direction,
leading_model,
}
}
}
/// A paragraph's base direction ([`ParaStyle::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
}
}
/// Area Type Options "First Baseline" offset.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FirstBaseline {
/// The tallest glyph ascent touches the frame top (Illustrator's default).
#[default]
Ascent,
CapHeight,
XHeight,
/// The first line's leading.
Leading,
/// Exactly `first_baseline_min` below the top.
Fixed,
}
/// Text Wrap Options of a wrap object (Object → Text Wrap).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct TextWrap {
/// Distance kept between the text and the object, in points (Illustrator's default 6 pt).
pub offset: f64,
/// Invert Wrap: text flows inside the object instead of around it.
pub invert: bool,
}
impl Default for TextWrap {
fn default() -> Self {
Self { offset: 6.0, invert: false }
}
}
/// A resolved wrap shape on an area text object: the outline of a wrap object in text space.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct WrapShape {
pub path: PathData,
#[serde(flatten)]
pub wrap: TextWrap,
}
/// Type on a Path effect: how each glyph is oriented on the path (Type → Type on a Path).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PathEffect {
/// Glyphs rotate with the path (the default).
#[default]
Rainbow,
/// Vertical edges stay vertical; the baseline follows the path.
Skew,
/// Horizontal edges stay horizontal; vertical edges are perpendicular to the path.
#[serde(rename = "3dRibbon")]
Ribbon3d,
/// No rotation: the left end of each glyph's baseline sits on the path.
StairStep,
/// The baseline centre sits on the path and glyphs point away from the path's centre.
Gravity,
}
impl PathEffect {
pub const ALL: [PathEffect; 5] = [PathEffect::Rainbow, PathEffect::Skew, PathEffect::Ribbon3d, PathEffect::StairStep, PathEffect::Gravity];
pub fn id(self) -> &'static str {
match self {
PathEffect::Rainbow => "rainbow",
PathEffect::Skew => "skew",
PathEffect::Ribbon3d => "3dRibbon",
PathEffect::StairStep => "stairStep",
PathEffect::Gravity => "gravity",
}
}
pub fn parse(s: &str) -> Option<Self> {
let k = s.to_ascii_lowercase().replace([' ', '-', '_'], "");
Self::ALL.into_iter().find(|e| e.id().to_ascii_lowercase() == k || (k == "ribbon3d" && *e == PathEffect::Ribbon3d))
}
}
/// Area Type Options: rows and columns, gutters, inset and first baseline of area type.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct AreaOptions {
pub rows: usize,
pub columns: usize,
/// Gutter between rows/columns in points.
pub gutter: f64,
/// Inset from the frame edges in points.
pub inset: f64,
pub first_baseline: FirstBaseline,
/// Minimum first-baseline offset in points.
pub first_baseline_min: f64,
}
impl Default for AreaOptions {
fn default() -> Self {
Self { rows: 1, columns: 1, gutter: 18.0, inset: 0.0, first_baseline: FirstBaseline::Ascent, first_baseline_min: 0.0 }
}
}
/// A named character or paragraph style: the attributes it sets (a subset of [`CharStyle`] or
/// [`ParaStyle`] fields, by their serialized names). Text using it records the name.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TextStyleDef {
pub name: String,
#[serde(default)]
pub attrs: serde_json::Map<String, serde_json::Value>,
}
/// A run of text sharing one character style.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TextRun {
pub text: String,
pub style: CharStyle,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum TextKind {
/// Point type: anchored at the baseline origin of the first line.
Point,
/// Area type flowed inside `frame` (document coordinates, untransformed by `xf`).
Area { frame: PathData },
/// Type on a path, starting at `start` (0..1 of the path length).
OnPath { path: PathData, start: f64 },
}
/// A text object. `runs` split into paragraphs at `\n`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TextObject {
/// Top-to-bottom, right-to-left writing. Defaults to horizontal for old documents.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub vertical: bool,
pub kind: TextKind,
/// Maps text space (origin = first baseline start for point type) to the document.
pub xf: Affine,
pub runs: Vec<TextRun>,
#[serde(default)]
pub para: ParaStyle,
/// Area Type Options (area type only).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub area: AreaOptions,
/// Type on a Path effect (type on a path only).
#[serde(default, rename = "pathEffect", skip_serializing_if = "crate::skip::is_default")]
pub path_effect: PathEffect,
/// Wrap objects above this area type, resolved by the engine after each edit (text space).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub wrap: Vec<WrapShape>,
/// Cached layout bounds in text space, filled in by the layout engine (not serialized).
#[serde(skip)]
pub cached_bounds: Option<Rect>,
}
impl TextObject {
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 { text: text.into(), style }],
para: ParaStyle::default(),
area: AreaOptions::default(),
path_effect: PathEffect::default(),
wrap: Vec::new(),
cached_bounds: None,
}
}
pub fn plain_text(&self) -> String {
self.runs.iter().map(|r| r.text.as_str()).collect()
}
pub fn first_style(&self) -> CharStyle {
self.runs.first().map(|r| r.style.clone()).unwrap_or_default()
}
/// Approximate bounds when no layout cache is available (0.55 em average advance).
pub fn estimate_bounds(&self) -> Rect {
let st = self.first_style();
let text = self.plain_text();
let lines: Vec<&str> = text.split('\n').collect();
let w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as f64 * st.size * 0.55;
let lead = st.effective_leading();
Rect::new(0.0, -st.size * 0.8, w.max(1.0), -st.size * 0.8 + lead * lines.len().max(1) as f64)
}
pub fn bounds(&self) -> Option<Rect> {
match &self.kind {
TextKind::Area { frame } => frame.bounds().map(|b| self.xf.transform_rect_bbox(b)),
TextKind::OnPath { path, .. } => path.bounds().map(|b| self.xf.transform_rect_bbox(b)),
TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))),
}
}
pub fn transform(&mut self, a: Affine) {
self.xf = a * self.xf;
}
/// Area type's frame (the type area) in document space; None for other type.
pub fn area_frame(&self) -> Option<PathData> {
match &self.kind {
TextKind::Area { frame } => Some(frame.transformed(self.xf)),
_ => None,
}
}
/// Reshape area type's frame by `a`, a document-space transform, keeping the type at its size
/// (a bounding-box resize: the text reflows, it isn't scaled). False, changing nothing, for
/// other type, a degenerate `xf` or a frame that would not be finite.
pub fn transform_area(&mut self, a: Affine) -> bool {
let xf = self.xf;
self.reshape_area(|frame, to_text| {
frame.transform(to_text * a * xf);
true
})
}
/// Move anchors `refs` (subpath, anchor) of area type's frame by `d` in document space, with
/// their handles (a Direct Selection drag of a frame corner or edge). False, changing
/// nothing, when an anchor doesn't exist, or as [`Self::transform_area`].
pub fn move_area_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool {
self.reshape_area(|frame, to_text| {
// A distance: only the linear part of the document → text map applies.
let local = to_text * d.to_point() - to_text * Point::ZERO;
refs.iter().all(|&(si, ai)| frame.anchor_mut(si, ai).map(|a| a.translate(local)).is_some())
})
}
/// Edit a copy of the area frame with `f` (given the document → text space map) and keep it
/// when `f` succeeds and the result is finite.
fn reshape_area(&mut self, f: impl FnOnce(&mut PathData, Affine) -> bool) -> bool {
let det = self.xf.determinant();
if !det.is_finite() || det.abs() < 1e-12 {
return false;
}
let to_text = self.xf.inverse();
let TextKind::Area { frame } = &mut self.kind else { return false };
let mut next = frame.clone();
if !f(&mut next, to_text) || !next.anchors().all(|(_, _, a)| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) {
return false;
}
*frame = next;
true
}
/// Scale the character strokes' weights and dashes by `s` (they are drawn in text space, so
/// `1 / scale` keeps their weight through a transform that scales the type).
pub fn scale_char_strokes(&mut self, s: f64) {
for r in &mut self.runs {
r.style.stroke_width *= s;
if let Some(d) = &mut r.style.stroke_dash {
d.scale(s);
}
}
}
/// Layout bounds in text space (the layout cache, else the estimate): the box an unplaced run
/// gradient fits, as the renderers lay it out.
pub fn local_bounds(&self) -> Rect {
self.cached_bounds.unwrap_or_else(|| self.estimate_bounds())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn point_text_basics() {
let t = TextObject::point(Point::new(10.0, 20.0), "Hello", CharStyle::default());
assert_eq!(t.plain_text(), "Hello");
let b = t.bounds().unwrap();
assert!(b.x0 >= 10.0 - 1e-9 && b.y1 > 20.0);
assert_eq!(CharStyle::default().effective_leading(), 14.399999999999999);
}
/// 120 × 40 area type at (40, 40), its text drawn at twice its size.
fn area_type() -> TextObject {
let mut t = TextObject::point(Point::ZERO, "Some text", CharStyle::default());
t.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 20.0)) };
t.xf = Affine::translate((40.0, 40.0)) * Affine::scale(2.0);
t
}
#[test]
fn area_resizes_its_frame_and_keeps_its_type() {
let mut t = area_type();
assert_eq!(t.bounds(), Some(Rect::new(40.0, 40.0, 160.0, 80.0)));
// Bottom-right handle to (200, 140), about the top-left corner.
let o = Affine::translate((40.0, 40.0));
assert!(t.transform_area(o * Affine::scale_non_uniform(160.0 / 120.0, 100.0 / 40.0) * o.inverse()));
assert_eq!(t.xf, Affine::translate((40.0, 40.0)) * Affine::scale(2.0), "the type keeps its size");
let b = t.bounds().unwrap();
assert!((b.x1 - 200.0).abs() < 1e-9 && (b.y1 - 140.0).abs() < 1e-9 && b.x0 == 40.0, "{b:?}");
assert_eq!(t.area_frame().unwrap().bounds(), Some(b));
// Point type has no area; a degenerate transform or a non-finite frame changes nothing.
let mut p = TextObject::point(Point::ZERO, "x", CharStyle::default());
assert!(!p.transform_area(Affine::scale(2.0)) && p.area_frame().is_none());
let before = t.clone();
assert!(!t.transform_area(Affine::scale(f64::INFINITY)));
t.xf = Affine::scale(0.0);
assert!(!t.transform_area(Affine::scale(2.0)));
t.xf = before.xf;
assert_eq!(t, before);
}
#[test]
fn area_anchors_move_in_document_space() {
let mut t = area_type();
// The bottom-right corner (anchor 2) 20 pt right: 10 pt in text space at 2×.
assert!(t.move_area_anchors(&[(0, 2)], Vec2::new(20.0, 0.0)));
let TextKind::Area { frame } = &t.kind else { panic!() };
assert_eq!(frame.subpaths[0].anchors[2].p, Point::new(70.0, 20.0));
assert_eq!(frame.subpaths[0].anchors[1].p, Point::new(60.0, 0.0), "the others stay");
// A missing anchor changes nothing.
let before = t.clone();
assert!(!t.move_area_anchors(&[(0, 1), (0, 9)], Vec2::new(5.0, 5.0)));
assert!(!t.move_area_anchors(&[(3, 0)], Vec2::new(5.0, 5.0)));
assert_eq!(t, before);
}
/// Builds from before [`Justify::Auto`] read new type's alignment as Left or Right (and ignore
/// `justify_auto`); this build reads Auto back.
#[test]
fn auto_alignment_saves_readable_by_older_builds() {
#[derive(Deserialize, Debug, PartialEq)]
enum OldJustify {
Left,
Center,
Right,
JustifyLeft,
JustifyCenter,
JustifyRight,
JustifyAll,
}
#[derive(Deserialize)]
struct OldPara {
justify: OldJustify,
}
#[derive(Deserialize)]
struct OldText {
para: OldPara,
}
for (direction, physical) in
[(Some(ParaDirection::RightToLeft), OldJustify::Right), (Some(ParaDirection::LeftToRight), OldJustify::Left), (None, OldJustify::Left)]
{
let mut t = TextObject::point(Point::ZERO, "שלום", CharStyle::default());
t.para = ParaStyle { justify: Justify::Auto, direction, ..Default::default() };
let json = serde_json::to_string(&t).unwrap();
let old: OldText = serde_json::from_str(&json).unwrap();
assert_eq!(old.para.justify, physical, "{json}");
assert_eq!(serde_json::from_str::<TextObject>(&json).unwrap(), t);
}
// Other alignments save as before, without the flag.
let p = ParaStyle { justify: Justify::Right, ..Default::default() };
let json = serde_json::to_string(&p).unwrap();
assert!(!json.contains("justify_auto"), "{json}");
assert_eq!(serde_json::from_str::<ParaStyle>(&json).unwrap(), p);
// Files from before the flag keep their alignment.
assert_eq!(serde_json::from_str::<ParaStyle>(r#"{"justify":"Center"}"#).unwrap().justify, Justify::Center);
}
}