mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
726 lines
32 KiB
Rust
726 lines
32 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 (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;
|