391 lines
15 KiB
Rust
391 lines
15 KiB
Rust
//! VectorCraft text: font database, shaping, layout, glyph outlines.
|
||
//!
|
||
//! API contract used by `vectorcraft-render`, `vectorcraft-tools` and the UI:
|
||
//! - [`FontDb::global`]: process-wide database preloaded with the bundled OFL fonts.
|
||
//! - [`layout`]: lays out a [`TextObject`] into glyph outlines in *text space* (apply `t.xf` to
|
||
//! get document coordinates), plus line and caret information.
|
||
//! - [`caret_position`] / [`hit_byte`]: caret geometry and hit testing for the Type tool.
|
||
//!
|
||
//! Frames (`TextKind::Area`) and paths (`TextKind::OnPath`) are interpreted in text space.
|
||
#![forbid(unsafe_code)]
|
||
|
||
mod composer;
|
||
pub mod craft_fonts;
|
||
pub mod edit;
|
||
pub mod embed;
|
||
mod features;
|
||
mod fontdb;
|
||
pub mod hyphen;
|
||
mod layout;
|
||
mod shape;
|
||
pub mod thread;
|
||
|
||
pub use craft_fonts::{CRAFT_FONTS, CraftFont};
|
||
pub use features::OtFeatures;
|
||
pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, FontMatch, style_weight, system_font_dirs};
|
||
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
|
||
pub use layout::{layout, layout_with};
|
||
pub use vectorcraft_doc::TextObject;
|
||
|
||
pub use vectorcraft_doc::FirstBaseline;
|
||
|
||
/// Paragraph composer (Paragraph panel menu).
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||
pub enum Composer {
|
||
/// Break each line as soon as it is full.
|
||
SingleLine,
|
||
/// Knuth–Plass total fit over the paragraph (justified area text only).
|
||
#[default]
|
||
EveryLine,
|
||
}
|
||
|
||
/// Layout parameters that the document model doesn't store per object (Area Type Options,
|
||
/// composer, OpenType features). [`layout`] takes rows/columns/inset/first baseline from the object.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct LayoutOptions {
|
||
/// Area type rows and columns (text flows down each column, then across).
|
||
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,
|
||
pub composer: Composer,
|
||
pub features: OtFeatures,
|
||
}
|
||
|
||
impl Default for LayoutOptions {
|
||
fn default() -> Self {
|
||
Self {
|
||
rows: 1,
|
||
columns: 1,
|
||
gutter: 18.0,
|
||
inset: 0.0,
|
||
first_baseline: FirstBaseline::Ascent,
|
||
first_baseline_min: 0.0,
|
||
composer: Composer::EveryLine,
|
||
features: OtFeatures::default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One laid-out glyph.
|
||
#[derive(Clone, Debug)]
|
||
pub struct PositionedGlyph {
|
||
/// Outline in text space.
|
||
pub outline: BezPath,
|
||
/// Index of the run (`TextObject::runs`) the glyph came from.
|
||
pub run: usize,
|
||
/// Byte offset of the source character (cluster start) in the plain text.
|
||
pub byte: usize,
|
||
/// Pen position on the baseline (leading edge of the glyph).
|
||
pub origin: Point,
|
||
/// Advance along the baseline (includes tracking and justification space).
|
||
pub advance: f64,
|
||
/// Number of source bytes covered by the glyph's cluster.
|
||
pub len: usize,
|
||
/// Baseline direction in radians (0 = horizontal; non-zero for type on a path).
|
||
pub angle: f64,
|
||
/// Index into [`TextLayout::lines`].
|
||
pub line: usize,
|
||
/// [`FontFace::id`] of the face that supplied the glyph.
|
||
pub font_id: u32,
|
||
/// The glyph's id in that face.
|
||
pub gid: u32,
|
||
/// Font units (y down, as [`FontDb::outline`] gives them) → text space: where the glyph is
|
||
/// drawn, also for glyphs without an outline (spaces).
|
||
pub xf: Affine,
|
||
}
|
||
|
||
/// One line of laid-out text.
|
||
///
|
||
/// For type on a path there is a single line; `x0, baseline` is the start point of the text on the
|
||
/// path and `x1` the x of its end point.
|
||
#[derive(Clone, Debug)]
|
||
pub struct LineInfo {
|
||
pub baseline: f64,
|
||
/// Left edge of the line content (after alignment).
|
||
pub x0: f64,
|
||
/// Right edge of the line content, trailing spaces excluded.
|
||
pub x1: f64,
|
||
pub ascent: f64,
|
||
pub descent: f64,
|
||
/// Byte range of the line in the plain text (excludes the paragraph's `\n`).
|
||
pub start: usize,
|
||
pub end: usize,
|
||
/// Range of the line's glyphs in [`TextLayout::glyphs`].
|
||
pub glyph_start: usize,
|
||
pub glyph_end: usize,
|
||
/// Horizontal span available to the line (frame span minus indents; the content extent for
|
||
/// point type). Used for hit testing across columns.
|
||
pub avail: (f64, f64),
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct TextLayout {
|
||
/// Lines retain inline/block coordinates; glyph geometry is in physical text space.
|
||
pub vertical: bool,
|
||
/// Inline/block (line) space → physical text space: identity for horizontal type, a quarter
|
||
/// turn clockwise for vertical type (point type also centres its first column on the anchor).
|
||
pub line_xf: Affine,
|
||
pub glyphs: Vec<PositionedGlyph>,
|
||
pub lines: Vec<LineInfo>,
|
||
/// Ink/advance bounds in text space.
|
||
pub bounds: Rect,
|
||
/// True if area text did not fit its frame (the red overflow "+" marker), or on-path text ran
|
||
/// past the end of the path.
|
||
pub overflow: bool,
|
||
/// True for type on a path (glyphs have individual `angle`s).
|
||
pub on_path: bool,
|
||
/// Area type: the frame cells text flowed into (one per row/column).
|
||
pub frames: Vec<Rect>,
|
||
}
|
||
|
||
impl TextLayout {
|
||
/// Convert physical text coordinates to inline/block coordinates.
|
||
pub fn logical_point(&self, p: Point) -> Point {
|
||
if self.vertical { self.line_xf.inverse() * p } else { p }
|
||
}
|
||
pub fn physical_point(&self, p: Point) -> Point {
|
||
if self.vertical { self.line_xf * p } else { p }
|
||
}
|
||
/// All glyph outlines combined (e.g. for Create Outlines).
|
||
pub fn to_bezpath(&self) -> BezPath {
|
||
let mut p = BezPath::new();
|
||
for g in &self.glyphs {
|
||
p.extend(g.outline.iter());
|
||
}
|
||
p
|
||
}
|
||
/// Index of the line containing byte offset `byte`.
|
||
pub fn line_of(&self, byte: usize) -> usize {
|
||
self.lines.iter().rposition(|l| l.start <= byte).unwrap_or(0)
|
||
}
|
||
/// Snap type to the pixel grid (type-optimized anti-aliasing): move each glyph so its pen
|
||
/// position on the baseline lands on a whole device pixel, given `to_device` (text space →
|
||
/// device pixels). Whole baselines and glyph starts keep small type crisp and evenly spaced.
|
||
/// Only text that is neither rotated nor skewed nor on a path is snapped; returns whether
|
||
/// the layout changed.
|
||
pub fn snap_to_pixels(&mut self, to_device: Affine) -> bool {
|
||
let [a, b, c, d, _, _] = to_device.as_coeffs();
|
||
if self.on_path || self.vertical || b.abs() > 1e-9 || c.abs() > 1e-9 || a.abs() < 1e-12 || d.abs() < 1e-12 {
|
||
return false;
|
||
}
|
||
let mut moved = false;
|
||
for g in self.glyphs.iter_mut().filter(|g| g.angle == 0.0) {
|
||
let p = to_device * g.origin;
|
||
let shift = Vec2::new((p.x.round() - p.x) / a, (p.y.round() - p.y) / d);
|
||
if shift == Vec2::ZERO {
|
||
continue;
|
||
}
|
||
g.origin += shift;
|
||
g.outline.apply_affine(Affine::translate(shift));
|
||
g.xf = Affine::translate(shift) * g.xf;
|
||
moved = true;
|
||
}
|
||
moved
|
||
}
|
||
}
|
||
|
||
fn dir(angle: f64) -> Vec2 {
|
||
Vec2::new(angle.cos(), angle.sin())
|
||
}
|
||
|
||
/// Caret for byte offset `byte`, as a (top, bottom) segment in text space.
|
||
pub fn caret_position(layout: &TextLayout, byte: usize) -> (Point, Point) {
|
||
let Some(line) = layout.lines.get(layout.line_of(byte)) else {
|
||
return (Point::ZERO, Point::ZERO);
|
||
};
|
||
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
|
||
let (pos, angle) = if let Some(g) = glyphs.iter().find(|g| g.byte + g.len > byte) {
|
||
let frac = if byte <= g.byte { 0.0 } else { (byte - g.byte) as f64 / g.len.max(1) as f64 };
|
||
(g.origin + dir(g.angle) * (g.advance * frac), g.angle)
|
||
} else if let Some(g) = glyphs.last() {
|
||
(g.origin + dir(g.angle) * g.advance, g.angle)
|
||
} else {
|
||
(layout.physical_point(Point::new(line.x0, line.baseline)), if layout.vertical { std::f64::consts::FRAC_PI_2 } else { 0.0 })
|
||
};
|
||
// Up vector in y-down space, rotated with the baseline.
|
||
let d = dir(angle);
|
||
let up = Vec2::new(d.y, -d.x);
|
||
(pos + up * line.ascent, pos - up * line.descent)
|
||
}
|
||
|
||
/// Byte offset nearest to the text-space point `p` (for clicking/dragging with the Type tool).
|
||
pub fn hit_byte(layout: &TextLayout, p: Point) -> usize {
|
||
if layout.on_path {
|
||
let best = layout.glyphs.iter().min_by(|a, b| {
|
||
let ca = a.origin + dir(a.angle) * (a.advance * 0.5);
|
||
let cb = b.origin + dir(b.angle) * (b.advance * 0.5);
|
||
(p - ca).hypot2().total_cmp(&(p - cb).hypot2())
|
||
});
|
||
return match best {
|
||
Some(g) if (p - g.origin).dot(dir(g.angle)) < g.advance * 0.5 => g.byte,
|
||
Some(g) => g.byte + g.len,
|
||
None => layout.lines.first().map_or(0, |l| l.start),
|
||
};
|
||
}
|
||
let p = layout.logical_point(p);
|
||
// Nearest line band (vertical distance first, then distance to the line's column).
|
||
let dist = |l: &LineInfo| {
|
||
let dy = if p.y < l.baseline - l.ascent {
|
||
l.baseline - l.ascent - p.y
|
||
} else if p.y > l.baseline + l.descent {
|
||
p.y - l.baseline - l.descent
|
||
} else {
|
||
0.0
|
||
};
|
||
let (a, b) = (l.avail.0.min(l.x0), l.avail.1.max(l.x1));
|
||
let dx = if p.x < a {
|
||
a - p.x
|
||
} else if p.x > b {
|
||
p.x - b
|
||
} else {
|
||
0.0
|
||
};
|
||
// Vertical distance first (to 1/100 pt), then the nearest column.
|
||
((dy * 100.0).round(), dx)
|
||
};
|
||
let Some(li) = (0..layout.lines.len()).min_by(|&a, &b| {
|
||
let (da, db) = (dist(&layout.lines[a]), dist(&layout.lines[b]));
|
||
da.0.total_cmp(&db.0).then(da.1.total_cmp(&db.1))
|
||
}) else {
|
||
return 0;
|
||
};
|
||
byte_in_line(layout, li, p.x)
|
||
}
|
||
|
||
/// Byte offset nearest to `x` on line `li`.
|
||
pub fn byte_in_line(layout: &TextLayout, li: usize, x: f64) -> usize {
|
||
let Some(line) = layout.lines.get(li) else { return 0 };
|
||
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
|
||
if let Some(g) = glyphs.iter().find(|g| x < layout.logical_point(g.origin).x + g.advance * 0.5) {
|
||
return g.byte;
|
||
}
|
||
line_end(layout, li)
|
||
}
|
||
|
||
/// Caret byte at the end of line `li`: a soft-wrapped line ends before its trailing space so the
|
||
/// caret stays on the line.
|
||
pub fn line_end(layout: &TextLayout, li: usize) -> usize {
|
||
let Some(line) = layout.lines.get(li) else { return 0 };
|
||
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
|
||
let soft_wrap = layout.lines.get(li + 1).is_some_and(|n| n.start == line.end);
|
||
match glyphs.iter().rev().find(|g| g.len > 0) {
|
||
Some(g) if soft_wrap && g.byte + g.len == line.end => g.byte,
|
||
_ => line.end,
|
||
}
|
||
}
|
||
|
||
/// Caret byte at the start of the line containing `byte` (Home).
|
||
pub fn line_home(layout: &TextLayout, byte: usize) -> usize {
|
||
layout.lines.get(layout.line_of(byte)).map_or(0, |l| l.start)
|
||
}
|
||
|
||
/// Caret byte at the end of the line containing `byte` (End).
|
||
pub fn line_end_of(layout: &TextLayout, byte: usize) -> usize {
|
||
line_end(layout, layout.line_of(byte))
|
||
}
|
||
|
||
/// Caret x position (text space) of `byte` on line `li`.
|
||
fn x_in_line(layout: &TextLayout, li: usize, byte: usize) -> f64 {
|
||
let line = &layout.lines[li];
|
||
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
|
||
if let Some(g) = glyphs.iter().find(|g| g.byte + g.len > byte) {
|
||
let frac = if byte <= g.byte { 0.0 } else { (byte - g.byte) as f64 / g.len.max(1) as f64 };
|
||
return layout.logical_point(g.origin).x + g.advance * frac;
|
||
}
|
||
glyphs.last().map_or(line.x0, |g| layout.logical_point(g.origin).x + g.advance)
|
||
}
|
||
|
||
/// Move the caret `delta` lines up (negative) or down, keeping horizontal position `goal_x`
|
||
/// (text space). Moving past the first/last line goes to the start/end of the text.
|
||
pub fn caret_vertical(layout: &TextLayout, byte: usize, delta: i32, goal_x: f64) -> usize {
|
||
if layout.lines.is_empty() {
|
||
return byte;
|
||
}
|
||
let li = layout.line_of(byte) as i64 + delta as i64;
|
||
if li < 0 {
|
||
return layout.lines[0].start;
|
||
}
|
||
if li as usize >= layout.lines.len() {
|
||
return layout.lines.last().map_or(byte, |l| l.end);
|
||
}
|
||
byte_in_line(layout, li as usize, goal_x)
|
||
}
|
||
|
||
/// Nearest point on `path` to `p`: (fraction of the path's arc length 0..1, distance). Used to
|
||
/// start type on a path where the user clicked.
|
||
pub fn path_fraction_at(path: &BezPath, p: Point) -> (f64, f64) {
|
||
use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest};
|
||
let mut total = 0.0;
|
||
let mut best = (0.0, f64::INFINITY);
|
||
for seg in path.segments() {
|
||
let len = seg.arclen(1e-3);
|
||
let n = seg.nearest(p, 1e-4);
|
||
let d = n.distance_sq.sqrt();
|
||
if d < best.1 {
|
||
best = (total + seg.subsegment(0.0..n.t).arclen(1e-3), d);
|
||
}
|
||
total += len;
|
||
}
|
||
if total <= 0.0 { (0.0, best.1) } else { ((best.0 / total).clamp(0.0, 1.0), best.1) }
|
||
}
|
||
|
||
/// Highlight quads (text space, clockwise from top-left) covering the selected bytes `a..b`.
|
||
pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4]> {
|
||
let (a, b) = (a.min(b), a.max(b));
|
||
let mut out = vec![];
|
||
if a == b {
|
||
return out;
|
||
}
|
||
if layout.on_path {
|
||
for g in layout.glyphs.iter().filter(|g| g.byte >= a && g.byte < b) {
|
||
let li = &layout.lines[0];
|
||
let d = dir(g.angle);
|
||
let up = Vec2::new(d.y, -d.x);
|
||
let (p0, p1) = (g.origin, g.origin + d * g.advance);
|
||
out.push([p0 + up * li.ascent, p1 + up * li.ascent, p1 - up * li.descent, p0 - up * li.descent]);
|
||
}
|
||
return out;
|
||
}
|
||
for (li, l) in layout.lines.iter().enumerate() {
|
||
if l.start > b || l.end < a || (l.end == a && l.start < a && layout.lines.get(li + 1).is_some_and(|n| n.start == l.end)) {
|
||
continue;
|
||
}
|
||
let xa = if a <= l.start { l.x0.min(x_in_line(layout, li, l.start)) } else { x_in_line(layout, li, a) };
|
||
let mut xb = x_in_line(layout, li, b.min(l.end));
|
||
if b > l.end {
|
||
// The selection continues past the line: include the line break / trailing space.
|
||
let last = layout.glyphs[l.glyph_start..l.glyph_end].last().map_or(l.x1, |g| layout.logical_point(g.origin).x + g.advance);
|
||
xb = last.max(l.x1) + (l.ascent + l.descent) * 0.25;
|
||
}
|
||
if xb - xa <= 1e-9 {
|
||
continue;
|
||
}
|
||
let (t, bt) = (l.baseline - l.ascent, l.baseline + l.descent);
|
||
out.push([Point::new(xa, t), Point::new(xb, t), Point::new(xb, bt), Point::new(xa, bt)].map(|p| layout.physical_point(p)));
|
||
}
|
||
out
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|
||
#[cfg(test)]
|
||
mod tests_cjk;
|
||
#[cfg(test)]
|
||
mod tests_embed;
|
||
#[cfg(test)]
|
||
mod tests_scripts;
|
||
#[cfg(test)]
|
||
mod tests_snap;
|
||
#[cfg(test)]
|
||
mod tests_sysfonts;
|
||
#[cfg(test)]
|
||
mod tests_typo;
|
||
#[cfg(test)]
|
||
mod tests_vertical;
|