mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
Illustrator sets a paragraph's direction (Left-to-Right / Right-to-Left
Paragraph Direction in the Paragraph panel, with the Middle Eastern
options) and keeps its alignment buttons physical; it has no automatic
alignment toggle. So:
- ParaStyle.direction (None: from each paragraph's first strong
character, as before; written only when set), the Paragraph panel's two
direction buttons with Show Indic Options (original icons), and
text.setFormat {direction: "auto"|"leftToRight"|"rightToLeft"}. The
layout uses it as the base direction.
- The Automatic alignment check box is gone. Justify::Auto stays as the
start of the paragraph's direction, set on new type only (text.create,
the Type tools, with the mojikumi default in one new_type_para()):
TextObject::point is Left again, so importers (PDF, SVG, DXF, EMF) keep
their own alignment. The alignment buttons show what Auto resolves to.
- Bidi only where there is something right to left: plain paragraphs skip
the algorithm (no per-layout allocation), it runs once per paragraph
(shaping takes the paragraph's levels instead of recomputing them; path
type computed it four times), and the shaper keeps Han, Hiragana,
Katakana and Bopomofo in one segment (splitting at every change of
script cost Japanese text a shaper call per kana-kanji switch).
- Vertical type keeps its logical order, so its glyphs and lines are not
marked right to left (the caret no longer runs backwards down a column).
- PDF import puts Hebrew and Arabic, which PDFs (and VectorCraft's own
export) draw in visual order, back in logical order with the direction
that shows it as drawn; with bidi layout it came back mirrored.
- SVG import pins the direction SVG implies (left to right unless
direction: rtl); SVG export also outlines right-to-left paragraphs.
- Docs (development.md), ROADMAP (folded into the Type entry, Type core
row) and README updated; catalog rows for the new labels.
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 };
|
|
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 });
|
|
}
|