vectorcraft/crates/text/src/lib.rs

391 lines
15 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.

//! 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;