Files
vectorcraft/crates/text/src/lib.rs
T

726 lines
32 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 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, OtFeatures, explicit_ligatures, ligatures_suppressed_by};
pub use fontdb::{
FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, IcfMargins, PlatformFontFiles, WantedFont, WantedFonts, app_font_dir,
is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, set_user_font_dirs, 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};
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
pub use layout::{decorations, fit_first_line, layout, layout_with, scaled};
pub use vectorcraft_doc::TextObject;
pub use vectorcraft_doc::{AreaFit, FirstBaseline, 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 flows down each column, then across). 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,
/// 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 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,
inset: 0.0,
first_baseline: FirstBaseline::Ascent,
first_baseline_min: 0.0,
vertical_align: VerticalAlign::Top,
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>>,
}
impl PositionedGlyph {
/// 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()
}
/// 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`]).
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 frac = if byte <= g.byte { 0.0 } else { (byte - g.byte) as f64 / g.len.max(1) as f64 };
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),
};
(origin + dir(g.angle) * (advance * if g.rtl { 1.0 - frac } else { frac }), 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 {
let best = layout.glyphs.iter().enumerate().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())
});
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);
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.
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)| if g.rtl { [(right, g.byte), (left, g.byte + g.len)] } else { [(left, g.byte), (right, g.byte + g.len)] })
.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 frac = if byte <= g.byte { 0.0 } else { (byte - g.byte) as f64 / g.len.max(1) as f64 };
let (left, advance) = cluster_extent(layout, glyphs, i).map_or((layout.logical_point(g.origin).x, g.advance), |(l, r)| (l, r - l));
return left + advance * if g.rtl { 1.0 - frac } else { frac };
}
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;
}
if layout.on_path {
// A list marker's glyphs stand for no characters.
for g in layout.glyphs.iter().filter(|g| g.byte >= a && g.byte < b && g.len > 0) {
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;
}
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 (x0, x1) = (p.x, p.x + g.advance);
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;
}
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.byte, g.byte + g.len]).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())
}
/// 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()))
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_axes;
#[cfg(test)]
mod tests_bidi;
#[cfg(test)]
mod tests_bitmaps;
#[cfg(test)]
mod tests_colr;
#[cfg(test)]
mod tests_combos;
#[cfg(test)]
mod tests_devanagari;
#[cfg(test)]
mod tests_embed;
#[cfg(test)]
mod tests_fit;
#[cfg(test)]
mod tests_hang;
#[cfg(test)]
mod tests_inline;
#[cfg(test)]
mod tests_joiners;
#[cfg(test)]
mod tests_justify;
#[cfg(test)]
mod tests_ruby;
#[cfg(test)]
mod tests_scripts;
#[cfg(test)]
mod tests_snap;
#[cfg(test)]
mod tests_svg_glyphs;
#[cfg(test)]
mod tests_sysfonts;
#[cfg(test)]
mod tests_typo;
#[cfg(test)]
mod tests_variable;
#[cfg(test)]
mod tests_versions;
#[cfg(test)]
mod tests_vertical;
#[cfg(test)]
#[path = "../build/web_fonts.rs"]
mod web_fonts_build;