Type on a path could only be repositioned by Type on a Path Options, whose menu item sent
`{start: 0}`, so OK threw the text to the start of its path; nothing showed or dragged its
brackets. Now, as in Illustrator:
- Type on a path flows between a start and an end bracket (`TextKind::OnPath { end }`, None:
the end of the path or once round a closed one; `TextKind::path_span`). Flipping
(`TextObject::flip_on_path`) reverses the path keeping a closed path's first anchor and swaps
the brackets, so the type keeps its stretch of the path.
- Type on a Path Options gains Align to Path (ascender, descender, center, baseline) and
Spacing (glyphs spaced as if set that far above the path: closed up round the outside of
curves, opened up round the inside), laid out by the text engine.
- `type.pathOptions {start?, end?, flip?, effect?, alignToPath?, spacing?, ids?}` sets them in
one undo step; with none of them it queries. The menu item opens the dialog with the selected
type's Effect, Flip, Align to Path and Spacing (dropdowns for the choices) and leaves the
brackets alone, so OK as opened changes nothing.
- The Selection and Direct Selection tools show selected type on a path's path and its start,
centre and end brackets (`tools::pathtype`, with a bracket cursor): dragging the start or end
bracket sets where the type runs, dragging the centre bracket slides it along the path and,
across the path, flips it (Cmd/Ctrl held: it only slides). Each drag is one undo step.
- Arc-length measuring moved to `vectorcraft_geom::ArcPath` (layout, the tools and
`text.createInPath` share it). SVG export outlines path type whose end bracket, alignment or
spacing `<textPath>` can't express.
- docs/mcp.md documents the command and the drags; ROADMAP updated; dialog strings translated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
393 lines
17 KiB
Rust
393 lines
17 KiB
Rust
//! Text as editable point type: glyphs drawn one after another on the same baseline make one
|
|
//! point text object of their Unicode text, a run per font, size and paint (a gap wider than a
|
|
//! fifth of the size reads as a space). Fonts are named from the file's base font name (its
|
|
//! subset prefix dropped, the family matched against the fonts available); a font that isn't
|
|
//! available keeps its name, and the text shows in the fallback font until it is.
|
|
//!
|
|
//! Glyphs each turned a little further along a curve (type set on a path: apps write each glyph
|
|
//! with its own placement) make one type-on-a-path object, its path through the glyphs' baseline.
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use kurbo::{Affine, BezPath, Point, Vec2};
|
|
use vectorcraft_color::Paint;
|
|
use vectorcraft_doc::{CharStyle, ParaDirection, TextKind, TextObject, TextRun};
|
|
|
|
/// One glyph's placement: its baseline origin, advance direction, size and horizontal scale.
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub(crate) struct Placement {
|
|
pub origin: Point,
|
|
/// Unit vector along the baseline.
|
|
pub dir: Vec2,
|
|
/// Font size in points (the em's height).
|
|
pub size: f64,
|
|
/// Width of the em ÷ its height, in percent.
|
|
pub h_scale: f64,
|
|
}
|
|
|
|
impl Placement {
|
|
/// Where a glyph drawn with `m` (glyph space, 1000 units per em, y up → document) sits;
|
|
/// `None` when it is mirrored, degenerate or not finite (those keep their outlines).
|
|
pub fn of(m: Affine) -> Option<Self> {
|
|
let origin = m * Point::ORIGIN;
|
|
let ex = m * Point::new(1000.0, 0.0) - origin;
|
|
let ey = m * Point::new(0.0, 1000.0) - origin;
|
|
let (w, size) = (ex.hypot(), ey.hypot());
|
|
// Upright in y-down document space: x to the right of up.
|
|
let upright = ex.cross(ey) < 0.0;
|
|
(origin.is_finite() && w.is_finite() && size.is_finite() && w > 0.01 && size > 0.01 && size < 1e5 && upright).then(|| Self {
|
|
origin,
|
|
dir: ex / w,
|
|
size,
|
|
h_scale: w / size * 100.0,
|
|
})
|
|
}
|
|
|
|
fn up(&self) -> Vec2 {
|
|
Vec2::new(self.dir.y, -self.dir.x)
|
|
}
|
|
}
|
|
|
|
/// A glyph set vertically (WMode 1: no horizontal advance) and the top centre of its em box: a
|
|
/// column of them is vertical type.
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub(crate) struct Upright {
|
|
pub top: Point,
|
|
}
|
|
|
|
/// What a run of type is drawn with: its font (cache key, family and style), size, horizontal
|
|
/// scale, fill and stroke (paint and width).
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(crate) struct Look {
|
|
pub font: u128,
|
|
pub family: String,
|
|
pub style: String,
|
|
pub size: f64,
|
|
pub h_scale: f64,
|
|
pub fill: Option<Paint>,
|
|
pub stroke: Option<(Paint, f64)>,
|
|
}
|
|
|
|
impl Look {
|
|
/// Can a glyph drawn with `other` join a run drawn with this? A fill joins a run whose
|
|
/// glyphs were also stroked over (fill and stroke rendering draws each glyph twice).
|
|
fn takes(&self, other: &Look) -> bool {
|
|
self.font == other.font
|
|
&& (self.size - other.size).abs() <= self.size * 0.01
|
|
&& (self.h_scale - other.h_scale).abs() < 0.5
|
|
&& self.fill == other.fill
|
|
&& (other.stroke.is_none() || self.stroke == other.stroke)
|
|
}
|
|
|
|
fn style(&self) -> CharStyle {
|
|
let (stroke, stroke_width) = self.stroke.clone().unwrap_or((Paint::None, 0.0));
|
|
CharStyle {
|
|
font_family: self.family.clone(),
|
|
font_style: self.style.clone(),
|
|
size: round(self.size),
|
|
h_scale: round(self.h_scale),
|
|
fill: self.fill.clone().unwrap_or(Paint::None),
|
|
stroke,
|
|
stroke_width,
|
|
..CharStyle::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A line of type being gathered: runs of glyphs on one baseline.
|
|
pub(crate) struct TextLine {
|
|
/// The first glyph's placement.
|
|
at: Placement,
|
|
opacity: f32,
|
|
/// Where the next glyph would start without spacing.
|
|
next: Point,
|
|
/// The last glyph's origin.
|
|
last: Point,
|
|
/// The last glyph's baseline direction.
|
|
last_dir: Vec2,
|
|
/// Each glyph's baseline origin, then where the last one ends.
|
|
baseline: Vec<Point>,
|
|
runs: Vec<(Look, String)>,
|
|
/// The first glyph was set vertically ([`Upright`]).
|
|
upright: Option<Upright>,
|
|
/// The glyphs make a column (vertical type), each below the last.
|
|
column: bool,
|
|
}
|
|
|
|
/// The most a glyph on a curve turns from the one before it (about 20°).
|
|
const CURVE_TURN_COS: f64 = 0.94;
|
|
|
|
impl TextLine {
|
|
pub fn new(at: Placement, opacity: f32) -> Self {
|
|
Self { at, opacity, next: at.origin, last: at.origin, last_dir: at.dir, baseline: vec![], runs: vec![], upright: None, column: false }
|
|
}
|
|
|
|
/// Add glyph `text` set vertically (`upright`) if it continues this column (or makes this
|
|
/// line's single glyph a column): below the last one, in the same column. `false`: it starts
|
|
/// another line.
|
|
pub fn push_upright(&mut self, look: &Look, at: Placement, opacity: f32, upright: Upright, text: &str) -> bool {
|
|
let down = -self.at.up();
|
|
let size = self.at.size.max(at.size);
|
|
let Some((last, run)) = self.runs.last_mut() else {
|
|
self.runs.push((look.clone(), text.to_string()));
|
|
(self.upright, self.next, self.last) = (Some(upright), at.origin + down * at.size, at.origin);
|
|
return true;
|
|
};
|
|
let across = (at.origin - self.at.origin).dot(self.at.dir);
|
|
let step = (at.origin - self.next).dot(down);
|
|
// Only glyphs set vertically (WMode 1) come here, so a second one below the first, one em
|
|
// on or letter-spaced up to another em, makes a column.
|
|
let first_step = (at.origin - self.last).dot(down);
|
|
let starts_column = !self.column && self.upright.is_some() && first_step > size * 0.95 && first_step < size * 2.0;
|
|
let in_column = (self.column || starts_column)
|
|
&& opacity == self.opacity
|
|
&& at.dir.dot(self.at.dir) > 0.9995
|
|
&& across.abs() < size * 0.6
|
|
&& step > -size * 0.3
|
|
&& step < size * 1.2;
|
|
if !in_column {
|
|
return false;
|
|
}
|
|
self.column = true;
|
|
// Letter-spaced Japanese is common: a gap reads as a space only when a whole em is left
|
|
// out (the tracking set in `finish` keeps narrower gaps).
|
|
if step > size * 0.9 && !run.ends_with(' ') && !text.starts_with(' ') {
|
|
run.push(' ');
|
|
}
|
|
if last.takes(look) {
|
|
run.push_str(text);
|
|
} else {
|
|
self.runs.push((look.clone(), text.to_string()));
|
|
}
|
|
self.next = at.origin + down * at.size;
|
|
self.last = at.origin;
|
|
true
|
|
}
|
|
|
|
/// Add glyph `text` drawn with `look` at `at` (advancing `advance` points) at `opacity` if
|
|
/// it continues this line; `false`: it starts another.
|
|
pub fn push(&mut self, look: &Look, at: Placement, opacity: f32, advance: f64, text: &str) -> bool {
|
|
// A vertical line (even a single upright glyph) takes no horizontal glyphs.
|
|
if self.column || self.upright.is_some() {
|
|
return false;
|
|
}
|
|
if let Some((last, run)) = self.runs.last_mut() {
|
|
let size = self.at.size.max(at.size);
|
|
let gap = (at.origin - self.next).dot(self.last_dir);
|
|
let on_line = opacity == self.opacity
|
|
&& at.dir.dot(self.at.dir) > 0.9995
|
|
&& (at.origin - self.at.origin).dot(self.at.up()).abs() < size * 0.15
|
|
&& gap > -size * 0.3
|
|
&& gap < size * 3.0;
|
|
// Or on a curve: turned a little from the glyph before it, and starting about where
|
|
// that one ends.
|
|
let on_curve = !on_line
|
|
&& opacity == self.opacity
|
|
&& at.dir.dot(self.last_dir) > CURVE_TURN_COS
|
|
&& at.dir.dot(self.last_dir) < 0.99999
|
|
&& (at.origin - self.next).hypot() < size * 0.6;
|
|
if !on_line && !on_curve {
|
|
return false;
|
|
}
|
|
// A gap wider than a fifth of an em reads as a space.
|
|
if gap > size * 0.2 && !run.ends_with(' ') && !text.starts_with(' ') {
|
|
run.push(' ');
|
|
}
|
|
if last.takes(look) {
|
|
run.push_str(text);
|
|
} else {
|
|
self.runs.push((look.clone(), text.to_string()));
|
|
}
|
|
} else {
|
|
self.runs.push((look.clone(), text.to_string()));
|
|
}
|
|
self.next = at.origin + at.dir * advance;
|
|
self.last = at.origin;
|
|
self.last_dir = at.dir;
|
|
self.baseline.push(at.origin);
|
|
true
|
|
}
|
|
|
|
/// Has the baseline turned (more than about 3° from the first glyph to the last)?
|
|
fn curved(&self) -> bool {
|
|
self.last_dir.dot(self.at.dir) < 0.9986
|
|
}
|
|
|
|
/// A smooth path through the glyphs' baseline origins and the end of the last glyph, an em
|
|
/// further (Catmull-Rom through the points, as cubic Béziers).
|
|
fn baseline_path(&self) -> Option<BezPath> {
|
|
let mut pts = self.baseline.clone();
|
|
// On past the end of the last glyph by an em, so rounding in the spacing doesn't push it
|
|
// off the end of the path (where type on a path stops).
|
|
pts.push(self.next);
|
|
pts.push(self.next + self.last_dir * self.at.size);
|
|
let first = *pts.first()?;
|
|
// Finite placements far out can still overflow once an em is added: no path then.
|
|
if pts.len() < 3 || !pts.iter().all(|p| p.is_finite()) {
|
|
return None;
|
|
}
|
|
let mut bp = BezPath::new();
|
|
bp.move_to(first);
|
|
for (i, seg) in pts.windows(2).enumerate() {
|
|
let &[p1, p2] = seg else { continue };
|
|
let p0 = *pts.get(i.wrapping_sub(1)).unwrap_or(&p1);
|
|
let p3 = *pts.get(i + 2).unwrap_or(&p2);
|
|
bp.curve_to(p1 + (p2 - p0) / 6.0, p2 - (p3 - p1) / 6.0, p2);
|
|
}
|
|
Some(bp)
|
|
}
|
|
|
|
/// A stroke (`stroke`: paint and width) over the glyph just drawn at `at` in `font` (fill
|
|
/// and stroke rendering): the run is stroked. `false`: it isn't that glyph.
|
|
pub fn stroke_last(&mut self, font: u128, at: Placement, stroke: (Paint, f64)) -> bool {
|
|
let Some((look, _)) = self.runs.last_mut().filter(|(l, _)| l.font == font && (at.origin - self.last).hypot() < self.at.size * 1e-3) else {
|
|
return false;
|
|
};
|
|
look.stroke = Some(stroke);
|
|
true
|
|
}
|
|
|
|
/// The point type object and its opacity.
|
|
pub fn finish(mut self) -> Option<(TextObject, f32)> {
|
|
// Turned along a curve: type on a path through the glyphs.
|
|
let path = if self.curved() { self.baseline_path() } else { None };
|
|
if let Some((_, last)) = self.runs.last_mut() {
|
|
last.truncate(last.trim_end().len());
|
|
}
|
|
self.runs.retain(|(_, t)| !t.is_empty());
|
|
if self.runs.iter().all(|(_, t)| t.trim().is_empty()) {
|
|
return None;
|
|
}
|
|
let mut runs = self.runs.into_iter().map(|(look, text)| TextRun { text, style: look.style() });
|
|
let first = runs.next()?;
|
|
let mut t = TextObject::point(Point::ORIGIN, &first.text, first.style);
|
|
t.runs.extend(runs);
|
|
if self.upright.is_none() {
|
|
to_logical(&mut t);
|
|
}
|
|
let angle = self.at.dir.atan2();
|
|
t.xf = Affine::translate(self.at.origin.to_vec2()) * Affine::rotate(angle);
|
|
let db = vectorcraft_text::FontDb::global();
|
|
if let Some(path) = path {
|
|
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0, end: None };
|
|
t.xf = Affine::IDENTITY;
|
|
t.cached_bounds = Some(vectorcraft_text::layout(db, &t).bounds);
|
|
return Some((t, self.opacity));
|
|
}
|
|
let (start, along) = match self.upright {
|
|
// Vertical point type (a column, or a single upright glyph) is anchored at the top
|
|
// centre of its first em box.
|
|
Some(u) => {
|
|
t.vertical = true;
|
|
t.xf = Affine::translate(u.top.to_vec2()) * Affine::rotate(angle);
|
|
(self.at.origin, -self.at.up())
|
|
}
|
|
_ => (self.at.origin, self.at.dir),
|
|
};
|
|
// Tracking that makes the line as long as in the file: the PDF placed each glyph, the
|
|
// layout sets them by their advances. Spread over the gaps between characters (tracking
|
|
// follows each character, the last one's past the end of the line).
|
|
let length = (self.next - start).dot(along);
|
|
let laid = vectorcraft_text::layout(db, &t);
|
|
let natural: f64 = laid.glyphs.iter().map(|g| g.advance).sum();
|
|
let chars: usize = t.runs.iter().map(|r| r.text.chars().count()).sum();
|
|
let size = self.at.size;
|
|
let mut bounds = laid.bounds;
|
|
if chars > 1 && length.is_finite() && (length - natural).abs() > size * 0.01 {
|
|
let tracking = ((length - natural) / (chars - 1) as f64 / size * 1000.0).clamp(-1000.0, 1000.0).round();
|
|
for r in &mut t.runs {
|
|
r.style.tracking = tracking;
|
|
}
|
|
// Tracked: laid out again for its bounds.
|
|
bounds = vectorcraft_text::layout(db, &t).bounds;
|
|
}
|
|
t.cached_bounds = Some(bounds);
|
|
Some((t, self.opacity))
|
|
}
|
|
}
|
|
|
|
fn round(v: f64) -> f64 {
|
|
(v * 1000.0).round() / 1000.0
|
|
}
|
|
|
|
/// Lower-case letters and digits only (`"Source Sans 3"` → `"sourcesans3"`).
|
|
fn norm(s: &str) -> String {
|
|
s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect()
|
|
}
|
|
|
|
/// `"TimesNewRoman"` → `"Times New Roman"`, `"SourceSans3"` → `"Source Sans 3"`.
|
|
fn spaced(s: &str) -> String {
|
|
let mut out = String::with_capacity(s.len() + 4);
|
|
let mut prev: Option<char> = None;
|
|
for c in s.chars() {
|
|
if let Some(p) = prev
|
|
&& ((p.is_ascii_lowercase() && c.is_ascii_uppercase()) || (p.is_ascii_alphabetic() && c.is_ascii_digit()))
|
|
{
|
|
out.push(' ');
|
|
}
|
|
out.push(c);
|
|
prev = Some(c);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// `name` without a PostScript name's trailing `PSMT`, `MT` or `PS`.
|
|
fn strip_ps(name: &str) -> &str {
|
|
["PSMT", "MT", "PS"].iter().find_map(|s| name.strip_suffix(s).filter(|r| !r.is_empty())).unwrap_or(name)
|
|
}
|
|
|
|
/// The families available, by [`norm`]ed name.
|
|
pub(crate) struct Families(HashMap<String, String>);
|
|
|
|
impl Families {
|
|
pub fn available() -> Self {
|
|
Self(vectorcraft_text::FontDb::global().families().into_iter().map(|f| (norm(&f), f)).collect())
|
|
}
|
|
|
|
/// The family and style of a font with base (PostScript) name `name`, or of weight `weight`
|
|
/// and slant `italic` when the name has no style; whether the family is available.
|
|
pub fn resolve(&self, name: &str, weight: Option<u32>, italic: bool) -> (String, String, bool) {
|
|
// A subset's six-letter tag.
|
|
let name = match name.split_once('+') {
|
|
Some((tag, rest)) if tag.len() == 6 && tag.chars().all(|c| c.is_ascii_uppercase()) => rest,
|
|
_ => name,
|
|
};
|
|
// An installed face of that exact PostScript name: its own family and style.
|
|
if let Some((family, style)) = vectorcraft_text::FontDb::global().by_postscript_name(name) {
|
|
return (family, style, true);
|
|
}
|
|
let (fam, style) = name.split_once(['-', ',']).unwrap_or((name, ""));
|
|
let found = [fam, strip_ps(fam)].iter().find_map(|f| self.0.get(&norm(f)).cloned());
|
|
let style = match spaced(strip_ps(style)).as_str() {
|
|
"" | "Roman" | "Book" | "Normal" | "Plain" => match (weight.is_some_and(|w| w >= 600) || style.contains("Bold"), italic) {
|
|
(true, true) => "Bold Italic".to_string(),
|
|
(true, false) => "Bold".to_string(),
|
|
(false, true) => "Italic".to_string(),
|
|
(false, false) => "Regular".to_string(),
|
|
},
|
|
s => s.to_string(),
|
|
};
|
|
match found {
|
|
Some(f) => (f, style, true),
|
|
None => (spaced(strip_ps(fam)).trim().to_string(), style, false),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Hebrew or Arabic comes from a PDF in visual order (each glyph where it is drawn): put `t`'s text
|
|
/// back in logical order, with the paragraph direction that shows it as drawn.
|
|
fn to_logical(t: &mut TextObject) {
|
|
let Some((order, rtl)) = vectorcraft_text::logical_order(&t.plain_text()) else { return };
|
|
let chars: Vec<(char, usize)> = t.runs.iter().enumerate().flat_map(|(i, r)| r.text.chars().map(move |c| (c, i))).collect();
|
|
let mut runs: Vec<(usize, String)> = Vec::with_capacity(t.runs.len());
|
|
for &(c, i) in order.iter().filter_map(|&k| chars.get(k)) {
|
|
match runs.last_mut() {
|
|
Some((run, text)) if *run == i => text.push(c),
|
|
_ => runs.push((i, c.to_string())),
|
|
}
|
|
}
|
|
let runs = runs.into_iter().filter_map(|(i, text)| Some(TextRun { text, style: t.runs.get(i)?.style.clone() })).collect();
|
|
t.runs = runs;
|
|
t.para.direction = Some(if rtl { ParaDirection::RightToLeft } else { ParaDirection::LeftToRight });
|
|
}
|