Files
vectorcraft/crates/text/src/lib.rs
T
dotCooCoo 0ad4d4f06c Fix #5816: CJK fallback faces in the interface sit on the UI font's baseline
An installed face added for characters the interface fonts lack (Microsoft
YaHei for Simplified Chinese on Windows, for example) now gets a FontTweak
y_offset_factor computed from its ascent, descent and line gap against the
first face of the Proportional family, so its glyphs share that face's
baseline instead of centering the difference in row height. The bundled
faces and Latin text do not move.

This follows designcraft#394 (baseline_tweak from line metrics) and
effectcraft#1395. vectorcraft-text gains line_metrics, and a test face with
a CJK system face's vertical proportions checks the baseline at 13, 24 and
40 pt in the interface families.
2026-11-08 12:21:20 +07:00

852 lines
42 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 (feature
//! `bundled-fonts`, on by default; without it the embedder adds fonts with [`FontDb::add_font`]).
//! - [`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 bitmap;
mod colr;
mod composer;
pub mod craft_fonts;
pub mod edit;
pub mod embed;
mod features;
mod fontconfig;
mod fontdb;
pub mod hyphen;
mod justify;
mod kashida;
mod kept;
mod layout;
mod shape;
#[cfg(not(target_arch = "wasm32"))]
mod suitcase;
mod svg_glyph;
#[cfg(any(test, feature = "test-fonts"))]
pub mod test_fonts;
pub mod thread;
pub use bitmap::GlyphImage;
pub use craft_fonts::{CRAFT_FONTS, CraftFont, WEB_FONTS, WebFont};
pub use features::{EXTRA_FEATURES, LIGATURE_TRACKING_LIMITS, MAX_STYLE_FEATURES, OtFeatures, explicit_ligatures, ligatures_suppressed_by};
pub use fontdb::{
FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, GlyphAlternate, IcfMargins, PlatformFontFiles, StylisticSet, WantedFont,
WantedFonts, app_font_dir, is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, set_user_font_dirs, set_user_font_dirs_confined,
style_weight, system_font_dirs, user_font_dirs,
};
pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions};
use std::sync::Arc;
pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader};
pub use kept::{KeptLayoutsFrame, LayoutStamp, keep_layout, take_kept_layout};
use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2};
pub use layout::{decorations, fit_first_line, layout, layout_with, scaled};
pub use vectorcraft_doc::TextObject;
pub use vectorcraft_doc::{AreaFit, FirstBaseline, TextFlow, VerticalAlign};
/// Paragraph composer; stored per text object in [`vectorcraft_doc::ParaStyle::composer`].
pub use vectorcraft_doc::Composer;
/// 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 fills them in [`LayoutOptions::flow`] order). Layout clamps each
/// count to 1..=[`vectorcraft_doc::AreaOptions::MAX_ROWS_COLUMNS`].
pub rows: usize,
pub columns: usize,
/// Gutter between rows/columns in points.
pub gutter: f64,
/// Gutter between rows in points when it differs from [`LayoutOptions::gutter`], which is then
/// the gutter between columns.
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 (Area Type Options "Align").
pub vertical_align: VerticalAlign,
/// Area Type Options "Text Flow": by columns (down each column, then across) or by rows.
pub flow: TextFlow,
/// Area type only: Shrink Text to Fit scales overflowing text down at layout time (see
/// [`TextLayout::fit_scale`]); the other fits are the engine's business and lay out as `None`.
pub fit: AreaFit,
/// Overrides the object's paragraph composer (`None` = use `ParaStyle::composer`).
pub composer: Option<Composer>,
pub features: OtFeatures,
}
impl Default for LayoutOptions {
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,
composer: None,
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,
/// True when the logical leading edge is the glyph’s right edge.
pub rtl: bool,
/// How much wider than its font draws it the glyph is drawn by its paragraph's Glyph Scaling
/// (Justification…), Desired and justification together: 1 at 100%. Horizontal Scale isn't
/// included.
pub scale: f64,
/// A colour bitmap glyph (emoji), drawn through `xf` instead of an outline: its `outline` is
/// empty. Also a COLR glyph whose paints documents have no form for, drawn as an image.
pub image: Option<Arc<GlyphImage>>,
/// A colour glyph's art (COLR layers, an SVG document's art), drawn through `xf` instead of an
/// outline: a group in font units (y down). Its `outline` is empty.
pub colour: Option<Arc<vectorcraft_doc::Node>>,
/// Type on a curved path in a script whose letters join: the glyph's `outline` is bent along
/// the path ([`GlyphBend`]) so it meets its neighbours, and `xf` is only the straight glyph
/// nearest it (for the caret, hit testing and tools). `None`: the glyph is drawn through `xf`.
pub bend: Option<GlyphBend>,
/// Font units → font units: `xf * unturned` places the glyph as it would be placed without
/// its Character Rotation. The identity for a glyph without one.
pub unturned: Affine,
/// Font units: `xf * pivot` is the point its Character Rotation turns it about, its pen
/// position after composition spacing adjustments, moved by its baseline shift.
pub pivot: Point,
/// A ligature (one glyph that alone stands for several characters, grapheme clusters, of the
/// text): the caret stop between each two of them, in logical order, as the byte offset of the
/// later character and how far the caret stands along the glyph's own width ([`Self::width`])
/// from its logical leading edge, 0 to 1. The font's ligature carets
/// ([`FontFace::ligature_carets`], or [`FontFace::vertical_ligature_carets`] for a glyph
/// standing upright in vertical type) when it lists one for each stop, else the width divided
/// equally among the characters. Empty for any other glyph.
pub carets: Vec<(usize, f64)>,
/// The glyph's own width along its advance in text space: its font advance at its size,
/// Horizontal Scale and glyph scaling, without the tracking, kerning, justification space and
/// kashida that [`Self::advance`] includes after it (to its right in horizontal type). The
/// advance itself in vertical type.
pub width: f64,
/// How far right of the left end of its advance the glyph's own width ([`Self::width`]) starts,
/// in text space: the font's placement of the glyph (`GPOS` x placement), less a leading space
/// taken off, plus the manual kerning of right-to-left text, which moves the glyph right so that
/// the space it adds lies on the glyph's left. 0 for a glyph standing upright or set across the
/// column in vertical type, and for an inline graphic. An upright glyph in vertical type on a
/// path turns about its pen, and its cell starts one cell length before the cell's top: 0.12 em
/// before the pen without the font's vertical metrics.
pub width_start: f64,
}
/// How a glyph on a path is bent along it: a point of its pen space (x along its advance from its
/// left end, y down from the baseline) goes to `P(s + x · step / advance) + (y + rise) · N` there,
/// `P` the path's point at a distance along it and `N` its normal on the glyph's lower side.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GlyphBend {
/// Where the glyph's left end is along the path.
pub s: f64,
/// How far along the path the glyph runs.
pub step: f64,
/// The glyph's advance.
pub advance: f64,
/// How far down the type moves off the path (Align to Path).
pub rise: f64,
}
impl PositionedGlyph {
/// The glyph's caret stops in logical order: its first byte, its ligature carets
/// ([`Self::carets`]) and its end, each with how far along its own width ([`Self::width`]) from
/// its logical leading edge the caret stands, 0 to 1.
fn width_stops(&self) -> impl Iterator<Item = (usize, f64)> + '_ {
std::iter::once((self.byte, 0.0)).chain(self.carets.iter().copied()).chain(std::iter::once((self.byte + self.len, 1.0)))
}
/// The glyph's caret stops in logical order, each with its distance in text space from the
/// logical leading edge of a span `extent` long that holds the glyph (its advance, or its
/// cluster's): 0 at its first byte and `extent` at its end. A ligature caret stands at its place
/// in the glyph's own width ([`Self::width`]), which starts [`Self::width_start`] right of the
/// glyph's left edge; the rest of the space the advance adds lies to the right of that width,
/// after the last ligature caret left to right, and before the first one right to left.
/// Negative kerning or tracking makes the advance shorter than the width, or zero or negative,
/// and a ligature caret then stands past the end of the advance.
fn stops_along(&self, extent: f64) -> impl Iterator<Item = (usize, f64)> + '_ {
let (width, start) = if self.width > 0.0 && self.width.is_finite() {
(self.width, if self.width_start.is_finite() { self.width_start } else { 0.0 })
} else {
(extent, 0.0)
};
let ends = (self.byte, self.byte + self.len);
self.width_stops().map(move |(b, f)| {
if b == ends.0 {
(b, 0.0)
} else if b == ends.1 {
(b, extent)
} else if self.rtl {
(b, extent - start - (1.0 - f) * width)
} else {
(b, start + f * width)
}
})
}
/// The distance in text space from the logical leading edge of a span `extent` long that holds
/// the glyph (its advance, or its cluster's) to the caret for byte `byte`: 0 at its first byte,
/// `extent` at its end, a ligature caret's place at that caret's byte ([`Self::stops_along`],
/// which can lie outside 0 to `extent`), and in proportion to the bytes between two stops.
pub fn caret_offset(&self, byte: usize, extent: f64) -> f64 {
if byte <= self.byte || self.len == 0 {
return 0.0;
}
let mut from = (self.byte, 0.0);
for (b, d) in self.stops_along(extent).skip(1) {
if byte <= b {
let span = b.saturating_sub(from.0).max(1) as f64;
return from.1 + (d - from.1) * (byte - from.0) as f64 / span;
}
from = (b, d);
}
extent
}
/// The glyph's colour bitmap, and the transform from its pixels to text space.
pub fn placed_image(&self) -> Option<(&Arc<GlyphImage>, Affine)> {
self.image.as_ref().map(|i| (i, self.xf * i.xf))
}
/// The glyph's colour bitmap as an image object placed through `to_doc` (text space → where it
/// goes), holding its pixels ([`vectorcraft_doc::ImageObject::pixels`]), without an id.
pub fn placed_image_node(&self, to_doc: Affine) -> Option<vectorcraft_doc::Node> {
let (image, to_text) = self.placed_image()?;
let im = vectorcraft_doc::ImageObject {
key: image.blob.content_key(),
width: image.width,
height: image.height,
xf: to_doc * to_text,
link: None,
placement: Default::default(),
pixels: Some(Arc::new(image.blob.clone())),
};
Some(vectorcraft_doc::Node::new(vectorcraft_doc::NodeId(u64::MAX), vectorcraft_doc::NodeKind::Image(Box::new(im))))
}
/// The glyph's colour art placed through `to_doc` (text space → where they go): a copy of
/// [`Self::colour`], transformed, its nodes without ids.
pub fn placed_colour(&self, to_doc: Affine) -> Option<vectorcraft_doc::Node> {
let mut art = (**self.colour.as_ref()?).clone();
art.transform(to_doc * self.xf, true);
Some(art)
}
}
/// 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 {
/// Automatically detected paragraph base direction.
pub rtl: bool,
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,
/// Area type: how far the line's baseline sits below the top of its frame cell (inside the
/// inset, with the lines aligned to the top) when the line starts the cell: the metric of the
/// line that Area Type Options › First Baseline picks (the em box top with top-to-top leading),
/// at least the First Baseline minimum. When layout moves a line on from the cell before and
/// the line's first character has smaller metrics than the rest of it, or moves the line down
/// to where it has room (in a frame that is not a rectangle, or beside wrap objects), the line
/// can sit elsewhere. It is 0 for type on a path.
pub first_baseline: 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),
/// Area type: index of the frame cell ([`TextLayout::frames`]) the line sits in (0 otherwise).
pub region: usize,
}
/// An inline graphic ([`vectorcraft_doc::TextRun::inline`]) placed by the layout: draw the art of
/// the run's symbol through `xf`. Missing symbols reserve their room but get no entry.
#[derive(Clone, Debug, PartialEq)]
pub struct InlineGlyph {
/// Index of the run (`TextObject::runs`).
pub run: usize,
/// Byte offset of its character in the plain text.
pub byte: usize,
/// Index of its (outline-less) glyph in [`TextLayout::glyphs`].
pub glyph: usize,
/// The symbol's art at its natural size (`Document::symbol_natural_xf`) → text space.
pub xf: Affine,
/// The art's bounds in text space.
pub bounds: Rect,
}
/// A list marker (Paragraph panel › Bullets) the layout drew before the first line of a list
/// paragraph: glyphs of [`TextLayout::glyphs`] that stand for no characters of the text (`len`
/// 0, at the paragraph's first byte, in the run of its first character).
#[derive(Clone, Debug, PartialEq)]
pub struct ListMarker {
/// The marker's glyphs, in the order they are drawn: those of its characters and the tab that
/// takes the text to the next tab stop.
pub glyphs: std::ops::Range<usize>,
/// The tab's glyph, which draws nothing: the last of [`Self::glyphs`], or the first in a
/// right-to-left paragraph.
pub tab: usize,
/// The marker's characters ("•").
pub text: String,
}
#[derive(Clone, Debug)]
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>,
/// Ruby (furigana) glyphs, laid out over their base text: geometry like [`Self::glyphs`], but
/// they are not characters of the text (their `len` is 0 and their `run` is the base text's
/// run, for the fill and stroke). Empty when the type has no ruby.
pub ruby: 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>,
/// Shrink Text to Fit: the factor the text's sizes, leading and baseline shifts were scaled
/// by to fit its frame (1.0 when the text is not shrunk).
pub fit_scale: f64,
/// Inline graphics, in text order.
pub inlines: Vec<InlineGlyph>,
/// List markers, in text order.
pub markers: Vec<ListMarker>,
}
impl Default for TextLayout {
fn default() -> Self {
Self {
vertical: false,
line_xf: Affine::IDENTITY,
glyphs: Vec::new(),
ruby: Vec::new(),
lines: Vec::new(),
bounds: Rect::ZERO,
overflow: false,
on_path: false,
frames: Vec::new(),
fit_scale: 1.0,
inlines: Vec::new(),
markers: Vec::new(),
}
}
}
impl TextLayout {
/// The list marker that glyph `i` of [`Self::glyphs`] belongs to, if any.
pub fn marker_of(&self, i: usize) -> Option<&ListMarker> {
let at = self.markers.partition_point(|m| m.glyphs.end <= i);
self.markers.get(at).filter(|m| m.glyphs.contains(&i))
}
/// The text glyph `i` of [`Self::glyphs`] stands for when it belongs to a list marker: a tab
/// for its tab, the marker's characters for the first of its other glyphs, nothing for the
/// rest.
pub fn marker_text(&self, i: usize) -> Option<&str> {
let m = self.marker_of(i)?;
let first = if m.tab == m.glyphs.start { m.glyphs.start + 1 } else { m.glyphs.start };
Some(if i == m.tab {
"\t"
} else if i == first {
&m.text
} else {
""
})
}
/// 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 }
}
/// Each line's baseline in text space, start to end (a vertical column's centre line), for
/// the lines that hold characters; none for type on a path, which its path stands for.
pub fn baselines(&self) -> Vec<(Point, Point)> {
if self.on_path {
return vec![];
}
let line = |l: &LineInfo| {
let y = if self.vertical { l.baseline + (l.descent - l.ascent) / 2.0 } else { l.baseline };
(self.physical_point(Point::new(l.x0, y)), self.physical_point(Point::new(l.x1, y)))
};
self.lines.iter().filter(|l| l.x1 > l.x0).map(line).collect()
}
/// The ink of the type in text space: its glyphs (and ruby) as drawn, colour glyphs, images and
/// inline graphics included, without the lines' em boxes that [`Self::bounds`] also covers.
/// What Align to Glyph Bounds aligns. `None` when nothing draws (only spaces, or no text).
pub fn ink_bounds(&self) -> Option<Rect> {
let outlined = |g: &PositionedGlyph| (!g.outline.elements().is_empty()).then(|| g.outline.bounding_box());
let glyphs = self.glyphs.iter().flat_map(|g| {
let image = g.placed_image().map(|(image, xf)| xf.transform_rect_bbox(image.rect()));
let colour = g.colour.as_ref().and_then(|c| c.geometric_bounds()).map(|b| g.xf.transform_rect_bbox(b));
[outlined(g), image, colour]
});
// Ruby is covered by its line's ascent, but its ink can overhang sideways past the line.
let ruby = self.ruby.iter().map(outlined);
let inlines = self.inlines.iter().map(|i| Some(i.bounds));
glyphs.chain(ruby).chain(inlines).flatten().reduce(|a, b| a.union(b))
}
/// 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.iter().chain(&self.ruby) {
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;
let mut shifts = vec![];
for (gi, g) in self.glyphs.iter_mut().enumerate().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;
if !self.inlines.is_empty() {
shifts.push((gi, shift));
}
moved = true;
}
// Inline graphics move with their glyphs.
for i in &mut self.inlines {
if let Some(&(_, s)) = shifts.iter().find(|(gi, _)| *gi == i.glyph) {
i.xf = Affine::translate(s) * i.xf;
i.bounds = i.bounds + s;
}
}
moved
}
}
fn dir(angle: f64) -> Vec2 {
Vec2::new(angle.cos(), angle.sin())
}
/// The left and right x of the cluster of `glyphs[i]` (glyphs of one line of horizontal type
/// that is not on a path): the glyphs next to it with its byte offset and length, and the
/// tatweels of a kashida after it (length 0 and no advance, the same byte offset). A list marker
/// next to it (length 0 and an advance, the same byte offset) is not part of it. None for
/// vertical type and type on a path.
fn cluster_extent(layout: &TextLayout, glyphs: &[PositionedGlyph], i: usize) -> Option<(f64, f64)> {
let g = glyphs.get(i).filter(|_| !layout.vertical && !layout.on_path)?;
let same = |o: &PositionedGlyph| o.byte == g.byte && (o.len == g.len || (o.len == 0 && o.advance == 0.0));
let start = glyphs.get(..i)?.iter().rposition(|o| !(o.byte == g.byte && o.len == g.len)).map_or(0, |p| p + 1);
let end = glyphs.get(i..)?.iter().position(|o| !same(o)).map_or(glyphs.len(), |p| i + p);
let span = glyphs.get(start..end)?;
let left = span.iter().map(|o| o.origin.x).fold(f64::INFINITY, f64::min);
let right = span.iter().map(|o| o.origin.x + o.advance).fold(f64::NEG_INFINITY, f64::max);
(left.is_finite() && right.is_finite()).then_some((left, right))
}
/// Caret for byte offset `byte`, as a (top, bottom) segment in text space. In horizontal type, a
/// cluster drawn with several glyphs is measured whole ([`cluster_extent`]). Inside a ligature the
/// caret stands at its caret stops ([`PositionedGlyph::carets`]).
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(i) = glyphs.iter().position(|g| g.byte <= byte && g.byte + g.len > byte) {
let g = &glyphs[i];
let (origin, advance) = match cluster_extent(layout, glyphs, i) {
Some((left, right)) => (Point::new(left, g.origin.y), right - left),
None => (g.origin, g.advance),
};
let off = g.caret_offset(byte, advance);
(origin + dir(g.angle) * if g.rtl { advance - off } else { off }, g.angle)
} else if let Some(g) = glyphs.iter().max_by_key(|g| g.byte + g.len) {
(g.origin + dir(g.angle) * if g.rtl { 0.0 } else { 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 {
// The distance from `p` to a glyph's middle, or to one of its inner ligature caret stops
// when that is nearer (a stop can lie past the glyph's advance).
let near = |g: &PositionedGlyph| {
let along = dir(g.angle);
let mid = (p - (g.origin + along * (g.advance * 0.5))).hypot2();
(g.stops_along(g.advance).filter(|(b, _)| *b != g.byte && *b != g.byte + g.len))
.map(|(_, d)| (p - (g.origin + along * if g.rtl { g.advance - d } else { d })).hypot2())
.fold(mid, f64::min)
};
let best = layout.glyphs.iter().enumerate().min_by(|(_, a), (_, b)| near(a).total_cmp(&near(b)));
let Some((i, g)) = best else { return layout.lines.first().map_or(0, |l| l.start) };
// `first` and `last` are the first and last glyphs of the cluster. A click before the middle of
// the cluster, measured along the first glyph's direction, goes to the cluster's start.
let same = |o: &&PositionedGlyph| o.byte == g.byte && o.len == g.len;
let first = layout.glyphs[..=i].iter().rev().take_while(same).last().unwrap_or(g);
let last = layout.glyphs[i..].iter().take_while(same).last().unwrap_or(g);
let along = dir(first.angle);
if !g.carets.is_empty() {
// A ligature: the nearest of its caret stops, measured along it from its logical
// leading edge in text space, within the span its stops cover.
let (lo, hi) = g.stops_along(g.advance).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), (_, d)| (lo.min(d), hi.max(d)));
let t = (p - g.origin).dot(along);
let at = (if g.rtl { g.advance - t } else { t }).max(lo).min(hi);
return g.stops_along(g.advance).min_by(|a, b| (a.1 - at).abs().total_cmp(&(b.1 - at).abs())).map_or(g.byte, |s| s.0);
}
let span = (last.origin + dir(last.angle) * last.advance - first.origin).dot(along);
return if ((p - first.origin).dot(along) < span * 0.5) != g.rtl { g.byte } else { g.byte + g.len };
}
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];
// A cluster drawn with several adjacent glyphs (a Devanagari pre-base matra and its consonant,
// a consonant and its reph) has one caret stop at each end of its glyphs taken together. The
// tatweels of a kashida have no source length or advance and come after the glyphs of the
// letter after the kashida in the text; they belong to that letter's cluster. A list marker's
// glyphs (no source length, an advance) are a cluster of their own. A ligature also has a stop
// at each of its caret stops.
let mut clusters: Vec<(f64, f64, &PositionedGlyph)> = Vec::with_capacity(glyphs.len());
for g in glyphs {
let left = layout.logical_point(g.origin).x;
let right = left + g.advance;
match clusters.last_mut() {
Some((l, r, c)) if c.byte == g.byte && (c.len == g.len || (g.len == 0 && g.advance == 0.0)) => (*l, *r) = (l.min(left), r.max(right)),
_ => clusters.push((left, right, g)),
}
}
let nearest = clusters
.iter()
.flat_map(|&(left, right, g)| g.stops_along(right - left).map(move |(b, d)| (if g.rtl { right - d } else { left + d }, b)))
.min_by(|a, b| (a.0 - x).abs().total_cmp(&(b.0 - x).abs()));
nearest.map_or(line.start, |(_, byte)| byte)
}
/// 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().filter(|g| g.len > 0).max_by_key(|g| g.byte) {
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(i) = glyphs.iter().position(|g| g.byte <= byte && g.byte + g.len > byte) {
let g = &glyphs[i];
let (left, advance) = cluster_extent(layout, glyphs, i).map_or((layout.logical_point(g.origin).x, g.advance), |(l, r)| (l, r - l));
let off = g.caret_offset(byte, advance);
return left + if g.rtl { advance - off } else { off };
}
glyphs.iter().max_by_key(|g| g.byte + g.len).map_or(line.x0, |g| layout.logical_point(g.origin).x + if g.rtl { 0.0 } else { 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)
}
/// 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;
}
// The part of glyph `g` that the selection covers, as distances in text space from its left
// (its start along a path), the smaller first: all of its advance, or the characters of a
// ligature that are selected. A ligature caret past a shortened advance lies outside it.
let covered = |g: &PositionedGlyph| {
if g.len == 0 {
return (g.advance.min(0.0), g.advance.max(0.0));
}
let (fa, fb) = (g.caret_offset(a.max(g.byte), g.advance), g.caret_offset(b.min(g.byte + g.len), g.advance));
let (f0, f1) = if g.rtl { (g.advance - fb, g.advance - fa) } else { (fa, fb) };
(f0.min(f1), f0.max(f1))
};
if layout.on_path {
// A list marker's glyphs stand for no characters.
for g in layout.glyphs.iter().filter(|g| g.byte < b && g.byte + g.len > a && g.len > 0) {
let li = &layout.lines[0];
let d = dir(g.angle);
let up = Vec2::new(d.y, -d.x);
let (f0, f1) = covered(g);
let (p0, p1) = (g.origin + d * f0, g.origin + d * f1);
out.push([p0 + up * li.ascent, p1 + up * li.ascent, p1 - up * li.descent, p0 - up * li.descent]);
}
return out;
}
if layout.glyphs.iter().any(|g| g.rtl) {
// A list marker's glyphs stand for no characters.
let glyphs = layout.glyphs.iter().enumerate().filter(|(i, g)| g.byte < b && g.byte + g.len > a && layout.marker_of(*i).is_none());
for (_, g) in glyphs {
let Some(l) = layout.lines.get(g.line) else { continue };
let p = layout.logical_point(g.origin);
let (f0, f1) = covered(g);
let (x0, x1) = (p.x + f0, p.x + f1);
out.push(
[
Point::new(x0, l.baseline - l.ascent),
Point::new(x1, l.baseline - l.ascent),
Point::new(x1, l.baseline + l.descent),
Point::new(x0, l.baseline + l.descent),
]
.map(|p| layout.physical_point(p)),
);
}
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;
}
// A list marker before the line's first character is not part of the selection.
let next = layout.markers.partition_point(|m| m.glyphs.start < l.glyph_start);
let marked = layout.markers.get(next).is_some_and(|m| m.glyphs.start < l.glyph_end);
let xa = if a <= l.start && !marked { l.x0.min(x_in_line(layout, li, l.start)) } else { x_in_line(layout, li, a.max(l.start)) };
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;
}
// A ligature caret past the ligature's shortened advance stands right of the glyph's end.
let (xa, xb) = (xa.min(xb), xa.max(xb));
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
}
/// Move horizontally through visual caret stops; source offsets stay in logical order. Stops at
/// one x (both ends of a cluster drawn with no advance) are visited one at a time in byte order.
/// Going right, the caret takes them in increasing order on left-to-right glyphs and in
/// decreasing order on right-to-left glyphs; going left, it takes them in the reverse order.
pub fn caret_horizontal(layout: &TextLayout, byte: usize, right: bool) -> usize {
caret_horizontal_filtered(layout, byte, right, |_| true)
}
/// Like [`caret_horizontal`], but exclude glyph stops and line edges inside a grapheme of `text`.
pub fn caret_horizontal_cluster(layout: &TextLayout, text: &str, byte: usize, right: bool) -> usize {
use unicode_segmentation::GraphemeCursor;
let valid = |b| text.is_char_boundary(b) && GraphemeCursor::new(b, text.len(), true).is_boundary(text, 0).unwrap_or(false);
let to = caret_horizontal_filtered(layout, byte, right, valid);
if to == byte || valid(to) {
return to;
}
// Wrapping can split a grapheme; normalize only the fallback in its logical direction.
let Some(line) = layout.lines.get(layout.line_of(byte)) else { return byte };
if right != line.rtl { edit::next_cluster(text, to) } else { edit::prev_cluster(text, to) }
}
fn caret_horizontal_filtered(layout: &TextLayout, byte: usize, right: bool, valid: impl Fn(usize) -> bool) -> usize {
let li = layout.line_of(byte);
let Some(line) = layout.lines.get(li) else { return byte };
let glyphs = layout.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or_default();
// Returns a stop's x and its place among the stops at that x (larger is further right),
// taken from the glyph that `caret_position` puts it on.
let stop = |b: usize| {
let rtl =
glyphs.iter().find(|g| g.byte <= b && g.byte + g.len > b).or_else(|| glyphs.iter().max_by_key(|g| g.byte + g.len)).is_some_and(|g| g.rtl);
(layout.logical_point(caret_position(layout, b).0).x, if rtl { -(b as f64) } else { b as f64 })
};
let (x, order) = stop(byte);
let mut stops: Vec<_> = glyphs.iter().flat_map(|g| g.width_stops().map(|(b, _)| b)).collect();
stops.sort_unstable();
stops.dedup();
stops
.into_iter()
.filter(|&b| valid(b))
.filter_map(|b| {
// Both the distance and the place among stops at one x are counted in the step's direction.
let (bx, bo) = stop(b);
let (d, o) = if right { (bx - x, bo - order) } else { (x - bx, order - bo) };
// The end of a soft-wrapped line is measured on the next line: it never wins a tie.
let o = if layout.line_of(b) == li { o } else { f64::INFINITY };
if d.abs() <= 1e-6 { (o > 0.0 && o.is_finite()).then_some((0.0, o, b)) } else { (d > 1e-6).then_some((d, o, b)) }
})
.min_by(|a, b| if (a.0 - b.0).abs() <= 1e-6 { a.1.total_cmp(&b.1) } else { a.0.total_cmp(&b.0) })
.map_or_else(
|| {
if right != line.rtl {
layout.lines.get(li + 1).map_or(byte, |next| next.start)
} else {
li.checked_sub(1).and_then(|previous| layout.lines.get(previous)).map_or(byte, |previous| previous.end)
}
},
|(_, _, b)| b,
)
}
/// Does paragraph `text` run right to left: its `direction`, else (None) from its first strong
/// character (numbers and punctuation don't count)?
pub fn paragraph_is_rtl(text: &str, direction: Option<vectorcraft_doc::ParaDirection>) -> bool {
layout::is_rtl(layout::para_bidi(text, direction).as_ref())
}
/// Is the character at byte `at` of paragraph `text` set right to left: its bidi level is odd, with
/// the paragraph's `direction` (None: from its first strong character)?
pub fn char_is_rtl(text: &str, direction: Option<vectorcraft_doc::ParaDirection>, at: usize) -> bool {
layout::para_bidi(text, direction).is_some_and(|info| info.levels.get(at).is_some_and(|level| level.is_rtl()))
}
/// Text stored in visual order (as PDF content draws it, each glyph where it is drawn), back in
/// logical order: the order of its characters (indices into `visual.chars()`) and whether its
/// paragraph runs right to left; laid out that way, it reads as drawn. `None` when nothing in it is
/// right to left (the order stands).
pub fn logical_order(visual: &str) -> Option<(Vec<usize>, bool)> {
let bidi = layout::para_bidi(visual, None)?;
let para = bidi.paragraphs.first()?;
// Reordering is its own inverse for runs of one direction (the usual case): the visual order
// of the visual text is its logical order.
let levels = bidi.reordered_levels_per_char(para, para.range.clone());
Some((unicode_bidi::BidiInfo::reorder_visual(&levels), para.level.is_rtl()))
}
/// Line metrics of the font at `index` of `data`, at its default axis settings, in font units:
/// (units per em, ascent, descent (negative below the baseline), line gap). egui builds its text
/// rows from these values. `None` if the font can't be read.
pub fn line_metrics(data: &[u8], index: u32) -> Option<(f32, f32, f32, f32)> {
use skrifa::MetadataProvider;
let f = skrifa::FontRef::from_index(data, index).ok()?;
let m = f.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default());
Some((f32::from(m.units_per_em.max(1)), m.ascent, m.descent, m.leading))
}
#[cfg(test)]
mod tests;
#[cfg(test)]
#[path = "../build/web_fonts.rs"]
mod web_fonts_build;