mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 03:39:50 +00:00
The word gap is now the Desired word spacing the layout uses for area type with Block on (TextObject::layout_justification), with Minimum and Maximum moved out to it when it lies outside them. The paragraphs keep their own Justification values, so a wide gap no longer leaves a 1,000 % Maximum behind, and turning Block off brings back the paragraph's own Desired value. The SVG writer reads the same values. - The Contextual Task Bar offers Dynamic Text for area type outside a thread, beside Convert To Point Type. - type.dynamicText with wordGap or lineGap and no area type in the selection is an error instead of being ignored. - Docs: docs/mcp.md, params text, docs/target-app-parity.md.
286 lines
12 KiB
Rust
286 lines
12 KiB
Rust
use super::para_style::FirstBaseline;
|
||
use super::{Deserialize, PathData, Serialize};
|
||
|
||
/// 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",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Area Type Options › Text Flow: the order text fills the cells of area type with several rows
|
||
/// and columns.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum TextFlow {
|
||
/// Down each column's rows, then on to the next column (the default, and what a document
|
||
/// from before the option reads as).
|
||
#[default]
|
||
Columns,
|
||
/// Across each row's columns, then on to the next row.
|
||
Rows,
|
||
}
|
||
|
||
/// 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))
|
||
}
|
||
}
|
||
|
||
/// Type on a Path Options › Align to Path: which height of the type runs along the path. Each
|
||
/// character is measured by its own font, size and vertical scale, and in vertical type by its own
|
||
/// size and horizontal scale. An inline graphic moves as a character of its run would, by its
|
||
/// run's vertical scale in vertical type too. These distances are VectorCraft's own values.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum PathAlign {
|
||
/// The top of the font's em box: the type hangs below the path. The em box is the font's
|
||
/// ideographic em box (from its `BASE` table, its typographic or `hhea` box, or its
|
||
/// ideographs), else an em box centered on the cap height, else a box whose top is 0.88 em
|
||
/// above the baseline. In vertical type the characters move half an em.
|
||
Ascender,
|
||
/// The bottom of the font's bounding box, else the font's descent: the type stands above the
|
||
/// path. In vertical type the characters move half an em the other way.
|
||
Descender,
|
||
/// A line at half the height of the top of the font's H above the baseline, else at half its
|
||
/// cap height, else at the middle of its em box. In vertical type the characters stay on the
|
||
/// path.
|
||
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> {
|
||
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,
|
||
/// Gutter between rows in points when it differs from [`Self::gutter`], which is then the
|
||
/// gutter between columns (left out when saved while unset).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub row_gutter: Option<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,
|
||
/// Text Flow: by columns (the default, left out when saved) or by rows.
|
||
#[serde(skip_serializing_if = "crate::skip::is_default")]
|
||
pub flow: TextFlow,
|
||
/// 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,
|
||
/// Dynamic Text's Block ([`super::TextObject::dynamic_text`] on area type): the Desired word
|
||
/// spacing the layout sets every paragraph with ([`super::TextObject::layout_justification`]), in
|
||
/// percent of a word space (100, the default, is left out when saved).
|
||
#[serde(skip_serializing_if = "AreaOptions::is_default_word_gap")]
|
||
pub word_gap: f64,
|
||
/// Dynamic Text's Block: the points added to each character's size to give its leading (0, the
|
||
/// default, is left out when saved).
|
||
#[serde(skip_serializing_if = "crate::skip::is_default")]
|
||
pub line_gap: f64,
|
||
}
|
||
|
||
impl Default for AreaOptions {
|
||
fn default() -> Self {
|
||
Self {
|
||
rows: 1,
|
||
columns: 1,
|
||
gutter: 18.0,
|
||
row_gutter: None,
|
||
inset: 0.0,
|
||
first_baseline: FirstBaseline::Ascent,
|
||
first_baseline_min: 0.0,
|
||
vertical_align: VerticalAlign::Top,
|
||
flow: TextFlow::Columns,
|
||
fit: AreaFit::None,
|
||
word_gap: Self::DEFAULT_WORD_GAP,
|
||
line_gap: 0.0,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl AreaOptions {
|
||
/// The most rows, and the most columns, area type can have.
|
||
pub const MAX_ROWS_COLUMNS: usize = 100;
|
||
/// Block's word gap range (percent of a word space), the Justification word spacing range.
|
||
pub const WORD_GAP: (f64, f64) = (0.0, 1000.0);
|
||
/// Block's word gap new type starts with: the font's own word space.
|
||
pub const DEFAULT_WORD_GAP: f64 = 100.0;
|
||
/// Block's line gap range in points.
|
||
pub const LINE_GAP: (f64, f64) = (-1296.0, 1296.0);
|
||
fn is_default_word_gap(v: &f64) -> bool {
|
||
*v == Self::DEFAULT_WORD_GAP
|
||
}
|
||
/// Block's gaps kept within their ranges; a gap that is not a number gives its default.
|
||
pub fn block_gaps(&self) -> (f64, f64) {
|
||
let word = if self.word_gap.is_finite() { self.word_gap.clamp(Self::WORD_GAP.0, Self::WORD_GAP.1) } else { Self::DEFAULT_WORD_GAP };
|
||
let line = if self.line_gap.is_finite() { self.line_gap.clamp(Self::LINE_GAP.0, Self::LINE_GAP.1) } else { 0.0 };
|
||
(word, line)
|
||
}
|
||
}
|
||
|
||
/// [`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<usize, D::Error> {
|
||
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<f64>) -> Option<Self> {
|
||
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<f64> {
|
||
match self {
|
||
AreaFit::ShrinkText { min_percent } => Some(Self::clamp_percent(min_percent) / 100.0),
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|