mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-29 04:26:55 -06:00
1243 lines
47 KiB
Rust
1243 lines
47 KiB
Rust
//! Paragraph layout: font fallback, bidi (UAX #9), shaping (OpenType via harfrust: kerning,
|
||
//! ligatures, mark positioning, complex scripts), line breaking (UAX #14, greedy or every-line),
|
||
//! alignment and justification, indents, paragraph spacing, leading, tracking, tsume, manual and
|
||
//! optical kerning, baseline shift, superscript / subscript, horizontal / vertical scale, all caps
|
||
//! / small caps, hanging punctuation, underline. Text may mix character styles (runs) and
|
||
//! paragraph settings. Results are cached.
|
||
//!
|
||
//! Coordinates are pixels, y down. For **point text** (`ParagraphStyle::width == None`) the
|
||
//! origin is the alignment point on the first baseline: x = 0 is the left edge (left / justify),
|
||
//! the centre (centre) or the right edge (right). For **area text** (`width == Some(w)`) the
|
||
//! origin is the top-left of the text box and lines wrap at `w`.
|
||
|
||
use std::collections::HashMap;
|
||
use std::hash::{Hash, Hasher};
|
||
use std::ops::Range;
|
||
use std::sync::{Arc, Mutex, OnceLock};
|
||
|
||
use harfrust::{Direction, Feature, Tag, UnicodeBuffer};
|
||
use unicode_bidi::{BidiInfo, Level};
|
||
|
||
use effectcraft_keyframe::{OpenType, text_doc::paragraph_ranges};
|
||
|
||
use crate::fonts::{self, FaceId, Resolved, VMetrics};
|
||
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||
pub enum Caps {
|
||
#[default]
|
||
Normal,
|
||
All,
|
||
Small,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||
pub enum Align {
|
||
#[default]
|
||
Left,
|
||
Center,
|
||
Right,
|
||
Justify,
|
||
}
|
||
|
||
/// Superscript / subscript position.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||
pub enum Script {
|
||
#[default]
|
||
Normal,
|
||
Super,
|
||
Sub,
|
||
}
|
||
|
||
/// Superscript / subscript size and baseline offset (fractions of the font size).
|
||
pub const SCRIPT_SIZE: f32 = 0.583;
|
||
pub const SCRIPT_OFFSET: f32 = 0.333;
|
||
|
||
/// Character formatting.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct TextStyle {
|
||
pub family: String,
|
||
pub style: String,
|
||
/// Font size in pixels.
|
||
pub size: f32,
|
||
/// Tracking in 1/1000 em (added after every cluster).
|
||
pub tracking: f32,
|
||
/// Metric kerning (`kern`).
|
||
pub kerning: bool,
|
||
/// Optical kerning (spacing from the outlines; replaces metric kerning).
|
||
pub optical: bool,
|
||
/// Manual kerning before every character of the run, in 1/1000 em (replaces metrics).
|
||
pub manual_kern: Option<f32>,
|
||
/// Standard ligatures (`liga`, `clig`).
|
||
pub ligatures: bool,
|
||
/// Baseline shift in pixels (positive = up).
|
||
pub baseline_shift: f32,
|
||
pub faux_bold: bool,
|
||
pub faux_italic: bool,
|
||
pub caps: Caps,
|
||
pub underline: bool,
|
||
/// Horizontal / vertical scale (1 = 100%).
|
||
pub h_scale: f32,
|
||
pub v_scale: f32,
|
||
/// Tsume: fraction (0–1) of the space around each glyph removed.
|
||
pub tsume: f32,
|
||
pub script: Script,
|
||
/// Baseline-to-baseline distance in pixels; None = auto (120% of the size).
|
||
pub leading: Option<f32>,
|
||
/// OpenType features (stylistic sets, figures, fractions, all small caps…).
|
||
pub opentype: OpenType,
|
||
/// Variable font axis values (tag, user units): shaping (advances through HVAR / gvar
|
||
/// phantom points) and outlines use this design-space position.
|
||
pub variations: Vec<(String, f32)>,
|
||
/// Use vertical alternates for upright CJK glyphs (runtime layout setting only).
|
||
pub vertical: bool,
|
||
}
|
||
|
||
impl Default for TextStyle {
|
||
fn default() -> Self {
|
||
Self {
|
||
family: fonts::DEFAULT_FAMILY.into(),
|
||
style: "Regular".into(),
|
||
size: 100.0,
|
||
tracking: 0.0,
|
||
kerning: true,
|
||
optical: false,
|
||
manual_kern: None,
|
||
ligatures: true,
|
||
baseline_shift: 0.0,
|
||
faux_bold: false,
|
||
faux_italic: false,
|
||
caps: Caps::Normal,
|
||
underline: false,
|
||
h_scale: 1.0,
|
||
v_scale: 1.0,
|
||
tsume: 0.0,
|
||
script: Script::Normal,
|
||
leading: None,
|
||
opentype: OpenType::default(),
|
||
variations: Vec::new(),
|
||
vertical: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl TextStyle {
|
||
/// The size glyphs are drawn at (superscript / subscript are smaller).
|
||
pub fn glyph_size(&self) -> f32 {
|
||
match self.script {
|
||
Script::Normal => self.size,
|
||
_ => self.size * SCRIPT_SIZE,
|
||
}
|
||
}
|
||
/// Upward baseline offset: baseline shift plus the superscript / subscript offset.
|
||
pub fn rise(&self) -> f32 {
|
||
self.baseline_shift
|
||
+ match self.script {
|
||
Script::Normal => 0.0,
|
||
Script::Super => self.size * SCRIPT_OFFSET,
|
||
Script::Sub => -self.size * SCRIPT_OFFSET,
|
||
}
|
||
}
|
||
/// The line advance this style asks for.
|
||
pub fn line_advance(&self) -> f32 {
|
||
self.leading.unwrap_or(self.size * 1.2)
|
||
}
|
||
}
|
||
|
||
/// Paragraph formatting.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct ParagraphStyle {
|
||
pub align: Align,
|
||
/// How the last line of a justified paragraph is aligned (`Justify` = justify it too).
|
||
pub justify_last: Align,
|
||
/// Extra line spacing in pixels added to every line advance.
|
||
pub leading: f32,
|
||
/// Wrap width (area text); None = point text. Taken from the first paragraph.
|
||
pub width: Option<f32>,
|
||
/// Base direction: None = from the first strong character.
|
||
pub rtl: Option<bool>,
|
||
/// Start / end margins (left / right in left-to-right paragraphs) and the first line's indent.
|
||
pub indent_start: f32,
|
||
pub indent_end: f32,
|
||
pub indent_first: f32,
|
||
pub space_before: f32,
|
||
pub space_after: f32,
|
||
/// Every-line composer (balanced breaks) instead of single-line (greedy).
|
||
pub every_line: bool,
|
||
/// Roman hanging punctuation.
|
||
pub hanging: bool,
|
||
}
|
||
|
||
impl Default for ParagraphStyle {
|
||
fn default() -> Self {
|
||
Self {
|
||
align: Align::Left,
|
||
justify_last: Align::Left,
|
||
leading: 0.0,
|
||
width: None,
|
||
rtl: None,
|
||
indent_start: 0.0,
|
||
indent_end: 0.0,
|
||
indent_first: 0.0,
|
||
space_before: 0.0,
|
||
space_after: 0.0,
|
||
every_line: false,
|
||
hanging: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
fn hf(h: &mut impl Hasher, v: f32) {
|
||
v.to_bits().hash(h);
|
||
}
|
||
|
||
impl Hash for TextStyle {
|
||
fn hash<H: Hasher>(&self, h: &mut H) {
|
||
self.family.hash(h);
|
||
self.style.hash(h);
|
||
for v in [self.size, self.tracking, self.baseline_shift, self.h_scale, self.v_scale, self.tsume] {
|
||
hf(h, v);
|
||
}
|
||
self.manual_kern.map(f32::to_bits).hash(h);
|
||
self.leading.map(f32::to_bits).hash(h);
|
||
(self.kerning, self.optical, self.ligatures, self.faux_bold, self.faux_italic, self.caps, self.underline, self.script).hash(h);
|
||
self.opentype.hash(h);
|
||
self.vertical.hash(h);
|
||
for (t, v) in &self.variations {
|
||
t.hash(h);
|
||
hf(h, *v);
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Hash for ParagraphStyle {
|
||
fn hash<H: Hasher>(&self, h: &mut H) {
|
||
(self.align, self.justify_last).hash(h);
|
||
for v in [self.leading, self.indent_start, self.indent_end, self.indent_first, self.space_before, self.space_after] {
|
||
hf(h, v);
|
||
}
|
||
self.width.map(f32::to_bits).hash(h);
|
||
(self.rtl, self.every_line, self.hanging).hash(h);
|
||
}
|
||
}
|
||
|
||
/// A positioned glyph. `(x, y)` is the glyph origin on its baseline.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct Glyph {
|
||
pub face: FaceId,
|
||
pub id: u32,
|
||
pub x: f32,
|
||
pub y: f32,
|
||
pub size: f32,
|
||
/// Byte offset of the cluster in the source text.
|
||
pub cluster: usize,
|
||
pub synth_bold: bool,
|
||
pub synth_italic: bool,
|
||
/// Horizontal / vertical scale of the outline (1 = 100%).
|
||
pub h_scale: f32,
|
||
pub v_scale: f32,
|
||
/// Index of the style run the glyph belongs to.
|
||
pub run: usize,
|
||
/// The run's variable font axis values, interned ([`crate::variable::coords`]; 0 = the
|
||
/// default instance): outlines are drawn there.
|
||
pub variations: u32,
|
||
}
|
||
|
||
/// One laid-out line.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct Line {
|
||
/// Byte range in the source text (without the terminating newline).
|
||
pub range: Range<usize>,
|
||
pub baseline: f32,
|
||
/// Left edge and width of the line's content (trailing spaces excluded).
|
||
pub x: f32,
|
||
pub width: f32,
|
||
pub ascent: f32,
|
||
pub descent: f32,
|
||
pub glyphs: Range<usize>,
|
||
/// Caret stops `(byte offset, x)` for every character boundary in the line, by byte offset.
|
||
pub carets: Vec<(usize, f32)>,
|
||
/// Paragraph index.
|
||
pub para: usize,
|
||
/// The line ends its paragraph.
|
||
pub para_end: bool,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct Layout {
|
||
pub glyphs: Vec<Glyph>,
|
||
pub lines: Vec<Line>,
|
||
/// Underline rectangles `[x0, y0, x1, y1]`.
|
||
pub underlines: Vec<[f32; 4]>,
|
||
/// Logical bounds `[x0, y0, x1, y1]` (line boxes; area text spans the box width).
|
||
pub bounds: [f32; 4],
|
||
/// The face requested was missing (substituted).
|
||
pub missing_font: bool,
|
||
pub text_len: usize,
|
||
}
|
||
|
||
impl Layout {
|
||
/// Line index for a caret at byte `pos`.
|
||
pub fn line_of(&self, pos: usize) -> usize {
|
||
let mut li = 0;
|
||
for (i, l) in self.lines.iter().enumerate() {
|
||
if l.range.start <= pos {
|
||
li = i;
|
||
}
|
||
}
|
||
li
|
||
}
|
||
/// Caret position `(x, baseline, line)` for byte offset `pos`.
|
||
pub fn caret(&self, pos: usize) -> (f32, f32, usize) {
|
||
let li = self.line_of(pos);
|
||
let Some(l) = self.lines.get(li) else { return (0.0, 0.0, 0) };
|
||
let x = l.carets.iter().min_by_key(|(b, _)| b.abs_diff(pos)).map_or(l.x, |c| c.1);
|
||
(x, l.baseline, li)
|
||
}
|
||
/// Nearest caret byte offset to point `(x, y)`.
|
||
pub fn hit(&self, x: f32, y: f32) -> usize {
|
||
let Some(l) = self.lines.iter().min_by(|a, b| line_dist(a, y).total_cmp(&line_dist(b, y))) else {
|
||
return 0;
|
||
};
|
||
self.hit_line(l, x)
|
||
}
|
||
/// Nearest caret byte offset to `x` on line `l`. A soft-wrapped line's end is the caret
|
||
/// before its trailing space, so clicking past it doesn't land on the next line.
|
||
pub fn hit_line(&self, l: &Line, x: f32) -> usize {
|
||
let end = self.line_end(l);
|
||
l.carets.iter().filter(|c| c.0 <= end).min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs())).map_or(l.range.start, |c| c.0)
|
||
}
|
||
/// The last caret stop of a line (before a wrapped line's trailing space).
|
||
pub fn line_end(&self, l: &Line) -> usize {
|
||
if l.para_end {
|
||
return l.range.end;
|
||
}
|
||
l.carets.iter().rev().nth(1).map(|c| c.0).filter(|b| *b >= l.range.start).unwrap_or(l.range.end)
|
||
}
|
||
/// Selection highlight rectangles `[x0, y0, x1, y1]` for the byte range `a..b`.
|
||
pub fn selection_rects(&self, a: usize, b: usize) -> Vec<[f32; 4]> {
|
||
let (a, b) = (a.min(b), a.max(b));
|
||
let mut out = Vec::new();
|
||
for l in &self.lines {
|
||
let s = a.max(l.range.start);
|
||
let e = b.min(l.range.end);
|
||
if s > e || (s == e && !(a < l.range.start && b > l.range.end)) {
|
||
continue;
|
||
}
|
||
let xs: Vec<f32> = l.carets.iter().filter(|(p, _)| *p >= s && *p <= e).map(|c| c.1).collect();
|
||
if xs.len() < 2 && b <= l.range.end {
|
||
continue;
|
||
}
|
||
let x0 = xs.iter().copied().fold(f32::MAX, f32::min);
|
||
let mut x1 = xs.iter().copied().fold(f32::MIN, f32::max);
|
||
if x0 > x1 {
|
||
x1 = l.x;
|
||
}
|
||
if b > l.range.end && l.para_end {
|
||
x1 += l.ascent * 0.25; // selected newline
|
||
}
|
||
out.push([x0.min(x1), l.baseline - l.ascent, x1, l.baseline + l.descent]);
|
||
}
|
||
out
|
||
}
|
||
}
|
||
|
||
fn line_dist(l: &Line, y: f32) -> f32 {
|
||
let (t, b) = (l.baseline - l.ascent, l.baseline + l.descent);
|
||
if y < t {
|
||
t - y
|
||
} else if y > b {
|
||
y - b
|
||
} else {
|
||
0.0
|
||
}
|
||
}
|
||
|
||
struct ShapedGlyph {
|
||
face: FaceId,
|
||
id: u32,
|
||
cluster: usize,
|
||
adv: f32,
|
||
/// Space added before the glyph (manual / optical kerning).
|
||
pre: f32,
|
||
dx: f32,
|
||
dy: f32,
|
||
size: f32,
|
||
run: usize,
|
||
/// Upward baseline offset (baseline shift, plus a synthesized superscript / subscript's).
|
||
rise: f32,
|
||
}
|
||
|
||
struct Item {
|
||
range: Range<usize>,
|
||
rtl: bool,
|
||
glyphs: Vec<ShapedGlyph>,
|
||
}
|
||
|
||
fn upper_single(c: char) -> char {
|
||
let mut u = c.to_uppercase();
|
||
match (u.next(), u.next()) {
|
||
(Some(x), None) => x,
|
||
_ => c,
|
||
}
|
||
}
|
||
|
||
const SMALL_CAPS_SCALE: f32 = 0.78;
|
||
|
||
/// How a character is set: as itself, as a synthesized small capital (a scaled-down capital),
|
||
/// with the font's small-capital glyph (`smcp` / `c2sc`), or with the font's superior / inferior
|
||
/// glyph (`sups` / `subs`).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||
enum Form {
|
||
Normal,
|
||
FauxSmall,
|
||
TrueSmall,
|
||
TrueScript,
|
||
}
|
||
|
||
/// Whether OpenType feature `tag` gives character `c` its own glyph in `face` (cached): the
|
||
/// true small caps / superscripts / subscripts test, with synthesized forms as the fallback.
|
||
pub fn feature_substitutes(face: FaceId, tag: &[u8; 4], c: char) -> bool {
|
||
static C: OnceLock<Mutex<HashMap<(FaceId, [u8; 4], char), bool>>> = OnceLock::new();
|
||
let cache = C.get_or_init(Default::default);
|
||
if let Some(v) = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(face, *tag, c)) {
|
||
return *v;
|
||
}
|
||
let f = fonts::face(face);
|
||
let v = f.has_feature(tag)
|
||
&& match (f.font(), f.shaper_data()) {
|
||
(Some(font), Some(data)) => {
|
||
let shaper = data.shaper(&font).build();
|
||
let shape = |feats: &[Feature]| -> Vec<u32> {
|
||
let mut buf = UnicodeBuffer::new();
|
||
buf.add(c, 0);
|
||
buf.guess_segment_properties();
|
||
shaper.shape(buf, harfrust::ShapeOptions::new().features(feats)).glyph_infos().iter().map(|i| i.glyph_id).collect()
|
||
};
|
||
let plain = shape(&[]);
|
||
let with = shape(&[Feature::new(Tag::new(tag), 1, ..)]);
|
||
plain != with && !with.contains(&0)
|
||
}
|
||
_ => false,
|
||
};
|
||
let mut m = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||
if m.len() > 100_000 {
|
||
m.clear();
|
||
}
|
||
m.insert((face, *tag, c), v);
|
||
v
|
||
}
|
||
|
||
/// Horizontal ink extent `(x0, x1)` of a glyph in font units.
|
||
fn ink_x(face: FaceId, gid: u32) -> Option<(f32, f32)> {
|
||
let p = crate::outline_units(face, gid)?;
|
||
if p.elements().is_empty() {
|
||
return None;
|
||
}
|
||
let r = kurbo::Shape::bounding_box(&*p);
|
||
Some((r.x0 as f32, r.x1 as f32))
|
||
}
|
||
|
||
fn shape_item(chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle, run: usize, form: Form) -> Vec<ShapedGlyph> {
|
||
let f = fonts::face(face);
|
||
let (Some(font), Some(data)) = (f.font(), f.shaper_data()) else { return Vec::new() };
|
||
let instance = (!style.variations.is_empty()).then(|| {
|
||
harfrust::ShaperInstance::from_variations(
|
||
&font,
|
||
style.variations.iter().map(|(t, v)| harfrust::Variation { tag: crate::variable::tag_of(t), value: *v }),
|
||
)
|
||
});
|
||
let shaper = data.shaper(&font).instance(instance.as_ref()).build();
|
||
let mut buf = UnicodeBuffer::new();
|
||
for &(b, _, sc) in chars {
|
||
buf.add(if sc == '\t' { ' ' } else { sc }, b as u32);
|
||
}
|
||
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||
buf.guess_segment_properties();
|
||
let mut feats = Vec::new();
|
||
if style.vertical && chars.first().is_some_and(|(_, c, _)| crate::is_cjk(*c)) {
|
||
feats.push(Feature::new(Tag::new(b"vert"), 1, ..));
|
||
feats.push(Feature::new(Tag::new(b"vrt2"), 1, ..));
|
||
}
|
||
if !style.kerning || style.optical || style.manual_kern.is_some() {
|
||
feats.push(Feature::new(Tag::new(b"kern"), 0, ..));
|
||
}
|
||
if !style.ligatures {
|
||
feats.push(Feature::new(Tag::new(b"liga"), 0, ..));
|
||
feats.push(Feature::new(Tag::new(b"clig"), 0, ..));
|
||
}
|
||
// OpenType options; small caps go per character (true where the font has them).
|
||
for (tag, v) in style.opentype.feature_settings() {
|
||
if &tag != b"smcp" && &tag != b"c2sc" {
|
||
feats.push(Feature::new(Tag::new(&tag), v, ..));
|
||
}
|
||
}
|
||
if style.caps == Caps::All {
|
||
// Case-sensitive forms (punctuation raised to sit with capitals).
|
||
feats.push(Feature::new(Tag::new(b"case"), 1, ..));
|
||
}
|
||
let rise = match form {
|
||
Form::TrueScript => style.baseline_shift,
|
||
_ => style.rise(),
|
||
};
|
||
match form {
|
||
Form::TrueSmall => {
|
||
feats.push(Feature::new(Tag::new(b"smcp"), 1, ..));
|
||
if style.opentype.all_small_caps {
|
||
feats.push(Feature::new(Tag::new(b"c2sc"), 1, ..));
|
||
}
|
||
}
|
||
Form::TrueScript => feats.push(Feature::new(Tag::new(if style.script == Script::Sub { b"subs" } else { b"sups" }), 1, ..)),
|
||
_ => {}
|
||
}
|
||
let out = shaper.shape(buf, harfrust::ShapeOptions::new().features(&feats));
|
||
let k = size / f.units_per_em();
|
||
let hs = style.h_scale;
|
||
out.glyph_infos()
|
||
.iter()
|
||
.zip(out.glyph_positions())
|
||
.map(|(i, p)| ShapedGlyph {
|
||
face,
|
||
id: i.glyph_id,
|
||
cluster: i.cluster as usize,
|
||
adv: p.x_advance as f32 * k * hs,
|
||
pre: 0.0,
|
||
dx: p.x_offset as f32 * k * hs,
|
||
dy: p.y_offset as f32 * k,
|
||
size,
|
||
run,
|
||
rise,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Tsume, tracking, manual and optical kerning on one shaped item (logical order).
|
||
fn space_item(glyphs: &mut [ShapedGlyph], style: &TextStyle, text: &str, para_start: bool) {
|
||
let side = |g: &ShapedGlyph| -> Option<(f32, f32)> {
|
||
let (x0, x1) = ink_x(g.face, g.id)?;
|
||
let k = g.size / fonts::face(g.face).units_per_em() * style.h_scale;
|
||
Some((x0 * k, g.adv - x1 * k))
|
||
};
|
||
let is_space = |g: &ShapedGlyph| text[g.cluster..].chars().next().is_none_or(char::is_whitespace);
|
||
if style.tsume > 0.0 {
|
||
let t = style.tsume.clamp(0.0, 1.0);
|
||
for g in glyphs.iter_mut() {
|
||
if is_space(g) {
|
||
continue;
|
||
}
|
||
if let Some((lsb, rsb)) = side(g) {
|
||
g.dx -= t * lsb.max(0.0);
|
||
g.adv -= t * (lsb.max(0.0) + rsb.max(0.0));
|
||
}
|
||
}
|
||
}
|
||
if style.optical && glyphs.len() > 1 {
|
||
// Pull every pair's ink gap halfway toward the item's median gap.
|
||
let sides: Vec<Option<(f32, f32)>> = glyphs.iter().map(|g| if is_space(g) { None } else { side(g) }).collect();
|
||
let gaps: Vec<Option<f32>> = (1..glyphs.len()).map(|i| Some(sides[i - 1]?.1 + sides[i]?.0)).collect();
|
||
let mut sorted: Vec<f32> = gaps.iter().flatten().copied().collect();
|
||
sorted.sort_by(f32::total_cmp);
|
||
if let Some(&median) = sorted.get(sorted.len() / 2) {
|
||
for (i, gap) in gaps.iter().enumerate() {
|
||
if let Some(gap) = gap
|
||
&& glyphs[i + 1].cluster != glyphs[i].cluster
|
||
{
|
||
glyphs[i + 1].pre += (median - gap) * 0.5;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if let Some(m) = style.manual_kern {
|
||
let k = m * style.size / 1000.0;
|
||
for i in 0..glyphs.len() {
|
||
let first_of_cluster = i == 0 || glyphs[i - 1].cluster != glyphs[i].cluster;
|
||
if first_of_cluster && !(para_start && glyphs[i].cluster == 0) {
|
||
glyphs[i].pre += k;
|
||
}
|
||
}
|
||
}
|
||
if style.tracking != 0.0 {
|
||
let t = style.tracking * style.size / 1000.0;
|
||
for k in 0..glyphs.len() {
|
||
if k + 1 == glyphs.len() || glyphs[k + 1].cluster != glyphs[k].cluster {
|
||
glyphs[k].adv += t;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
struct ParaLine {
|
||
range: Range<usize>,
|
||
glyphs: Vec<Glyph>,
|
||
carets: Vec<(usize, f32)>,
|
||
content: f32,
|
||
left_trim: f32,
|
||
/// Hanging punctuation widths at the left / right edge.
|
||
hang: (f32, f32),
|
||
ascent: f32,
|
||
descent: f32,
|
||
/// Line advance (largest leading on the line).
|
||
advance: f32,
|
||
align: Align,
|
||
avail: Option<f32>,
|
||
margins: (f32, f32),
|
||
underline: Option<(f32, f32)>,
|
||
}
|
||
|
||
fn is_hanging_close(c: char) -> bool {
|
||
matches!(c, '.' | ',' | ';' | ':' | '!' | '?' | '-' | '\u{2013}' | '\u{2014}' | '\'' | '"' | '\u{2019}' | '\u{201d}' | ')' | '\u{2026}')
|
||
}
|
||
fn is_hanging_open(c: char) -> bool {
|
||
matches!(c, '"' | '\'' | '\u{2018}' | '\u{201c}' | '(' | '\u{ab}')
|
||
}
|
||
|
||
/// The style run index covering global byte `b` (the run before it at a boundary, for empty
|
||
/// paragraphs at the end).
|
||
fn run_at(runs: &[(Range<usize>, TextStyle)], b: usize) -> usize {
|
||
runs.iter().position(|(r, _)| b >= r.start && b < r.end).unwrap_or_else(|| runs.iter().rposition(|(r, _)| r.start <= b).unwrap_or(0))
|
||
}
|
||
|
||
/// Lay out one paragraph (no newlines) whose text starts at byte `base` of the whole string.
|
||
/// Margins (left, right) of a paragraph's first or later lines.
|
||
fn margins_for(para: &ParagraphStyle, first: bool) -> (f32, f32) {
|
||
let fi = if first { para.indent_first } else { 0.0 };
|
||
if para.rtl == Some(true) { (para.indent_end, para.indent_start + fi) } else { (para.indent_start + fi, para.indent_end) }
|
||
}
|
||
|
||
/// The width a line may fill (`None` for point text).
|
||
fn avail_for(para: &ParagraphStyle, width: Option<f32>, first: bool) -> Option<f32> {
|
||
let (l, r) = margins_for(para, first);
|
||
width.map(|w| (w - l - r).max(1.0))
|
||
}
|
||
|
||
/// The alignment of a line, justified lines that cannot stretch falling back to `justify_last`.
|
||
fn align_for(para: &ParagraphStyle, width: Option<f32>, last: bool) -> Align {
|
||
if (last || width.is_none()) && para.align == Align::Justify {
|
||
if width.is_none() && para.justify_last == Align::Justify { Align::Left } else { para.justify_last }
|
||
} else {
|
||
para.align
|
||
}
|
||
}
|
||
|
||
fn style_metrics(primaries: &[Resolved], runs: &[(Range<usize>, TextStyle)], si: usize) -> VMetrics {
|
||
fonts::face(primaries[si].face).metrics(runs[si].1.glyph_size())
|
||
}
|
||
|
||
/// A char's shaping inputs: (byte, char, shaped char, face, rtl, form, run).
|
||
type CharInfo = (usize, char, char, FaceId, bool, Form, usize);
|
||
|
||
/// The glyphs of one line in visual order, where its pen ended, the extent of each cluster by
|
||
/// byte, and the underline span.
|
||
struct Placed {
|
||
glyphs: Vec<Glyph>,
|
||
pen: f32,
|
||
extents: HashMap<usize, (f32, f32, bool)>,
|
||
underline: Option<(f32, f32)>,
|
||
}
|
||
|
||
/// A paragraph shaped and measured, ready to be broken into lines.
|
||
struct Para<'a> {
|
||
text: &'a str,
|
||
base: usize,
|
||
runs: &'a [(Range<usize>, TextStyle)],
|
||
primaries: &'a [Resolved],
|
||
variation_ids: &'a [u32],
|
||
para: &'a ParagraphStyle,
|
||
width: Option<f32>,
|
||
bidi: BidiInfo<'a>,
|
||
base_rtl: bool,
|
||
chars: Vec<CharInfo>,
|
||
items: Vec<Item>,
|
||
/// Advance of the glyphs at each byte, and prefix sums over it.
|
||
char_adv: Vec<f32>,
|
||
prefix_adv: Vec<f64>,
|
||
previous_non_ws: Vec<usize>,
|
||
}
|
||
|
||
impl<'a> Para<'a> {
|
||
fn new(
|
||
text: &'a str,
|
||
base: usize,
|
||
runs: &'a [(Range<usize>, TextStyle)],
|
||
primaries: &'a [Resolved],
|
||
variation_ids: &'a [u32],
|
||
para: &'a ParagraphStyle,
|
||
width: Option<f32>,
|
||
) -> Self {
|
||
let default_level = para.rtl.map(|r| if r { Level::rtl() } else { Level::ltr() });
|
||
let bidi = BidiInfo::new(text, default_level);
|
||
let base_rtl = bidi.paragraphs[0].level.is_rtl();
|
||
let chars = char_forms(text, base, runs, primaries, &bidi);
|
||
let items = build_items(text, &chars, runs);
|
||
// Prefix widths and the preceding non-whitespace boundary make candidate line widths O(1).
|
||
// Every-line composition evaluates O(n²) candidate breaks; summing each candidate's bytes
|
||
// or rescanning its trailing whitespace would otherwise add another factor of text length.
|
||
let mut char_adv = vec![0.0f32; text.len() + 1];
|
||
for it in &items {
|
||
for g in &it.glyphs {
|
||
char_adv[g.cluster.min(text.len())] += g.adv + g.pre;
|
||
}
|
||
}
|
||
let mut prefix_adv = vec![0.0f64; text.len() + 1];
|
||
for i in 0..text.len() {
|
||
prefix_adv[i + 1] = prefix_adv[i] + f64::from(char_adv[i]);
|
||
}
|
||
let mut previous_non_ws = vec![0usize; text.len() + 1];
|
||
let mut non_ws_end = 0usize;
|
||
for (b, c) in text.char_indices() {
|
||
previous_non_ws[b] = non_ws_end;
|
||
if !c.is_whitespace() {
|
||
non_ws_end = b + c.len_utf8();
|
||
}
|
||
previous_non_ws[b + c.len_utf8()] = non_ws_end;
|
||
}
|
||
Para { text, base, runs, primaries, variation_ids, para, width, bidi, base_rtl, chars, items, char_adv, prefix_adv, previous_non_ws }
|
||
}
|
||
|
||
fn range_w(&self, r: Range<usize>) -> f32 {
|
||
(self.prefix_adv[r.end] - self.prefix_adv[r.start]) as f32
|
||
}
|
||
|
||
fn trailing_ws(&self, r: &Range<usize>) -> (usize, f32) {
|
||
let end = self.previous_non_ws[r.end].max(r.start).min(r.end);
|
||
(end, (self.prefix_adv[r.end] - self.prefix_adv[end]) as f32)
|
||
}
|
||
|
||
/// Hanging punctuation widths (left, right) of a line's content.
|
||
fn hang_of(&self, r: &Range<usize>, ce: usize) -> (f32, f32) {
|
||
if !self.para.hanging || self.width.is_none() || r.start >= ce {
|
||
return (0.0, 0.0);
|
||
}
|
||
let first = self.text[r.start..ce].chars().next().filter(|c| is_hanging_open(*c)).map_or(0.0, |_| self.char_adv[r.start]);
|
||
let last = self.text[r.start..ce].char_indices().next_back().filter(|(_, c)| is_hanging_close(*c)).map_or(0.0, |(b, _)| self.char_adv[r.start + b]);
|
||
(first, last)
|
||
}
|
||
|
||
fn fits_w(&self, r: &Range<usize>) -> f32 {
|
||
let (ce, _) = self.trailing_ws(r);
|
||
let (hl, hr) = self.hang_of(r, ce);
|
||
self.range_w(r.start..ce) - hl - hr
|
||
}
|
||
|
||
fn line_avail(&self, line: usize) -> f32 {
|
||
avail_for(self.para, self.width, line == 0).unwrap_or(f32::MAX)
|
||
}
|
||
|
||
/// The byte ranges of the lines, for the wrap width.
|
||
fn break_ranges(&self) -> Vec<Range<usize>> {
|
||
if self.width.is_none() {
|
||
return std::iter::once(0..self.text.len()).collect();
|
||
}
|
||
let opps: Vec<usize> = unicode_linebreak::linebreaks(self.text).map(|(p, _)| p).collect();
|
||
let mut ranges = if self.para.every_line && opps.len() > 1 && opps.len() <= 1500 { self.optimal_breaks(&opps) } else { self.greedy_breaks(opps) };
|
||
ranges.retain(|r| !r.is_empty());
|
||
if ranges.is_empty() {
|
||
ranges.push(0..self.text.len());
|
||
}
|
||
ranges
|
||
}
|
||
|
||
/// Minimises the summed squared slack of every line but the last.
|
||
fn optimal_breaks(&self, opps: &[usize]) -> Vec<Range<usize>> {
|
||
let nodes: Vec<usize> = std::iter::once(0).chain(opps.iter().copied()).collect();
|
||
let m = nodes.len();
|
||
let mut best = vec![(f32::INFINITY, 0usize); m];
|
||
best[0] = (0.0, 0);
|
||
for j in 1..m {
|
||
for i in (0..j).rev() {
|
||
if !best[i].0.is_finite() {
|
||
continue;
|
||
}
|
||
let r = nodes[i]..nodes[j];
|
||
let w = self.fits_w(&r);
|
||
let maxw = self.line_avail(usize::from(i != 0));
|
||
let over = w > maxw;
|
||
if over && i + 1 != j {
|
||
break;
|
||
}
|
||
let slack = maxw - w;
|
||
let cost = if over {
|
||
1e12 + w
|
||
} else if j == m - 1 {
|
||
0.0
|
||
} else {
|
||
slack * slack
|
||
};
|
||
let total = best[i].0 + cost;
|
||
if total < best[j].0 {
|
||
best[j] = (total, i);
|
||
}
|
||
}
|
||
}
|
||
let mut ranges = Vec::new();
|
||
let mut j = m - 1;
|
||
while j > 0 {
|
||
let i = best[j].1;
|
||
ranges.push(nodes[i]..nodes[j]);
|
||
j = i;
|
||
}
|
||
ranges.reverse();
|
||
ranges
|
||
}
|
||
|
||
/// Breaks at each opportunity that still fits, else at the last one that did.
|
||
fn greedy_breaks(&self, opps: Vec<usize>) -> Vec<Range<usize>> {
|
||
let mut ranges = Vec::new();
|
||
let mut start = 0usize;
|
||
let mut last_ok: Option<usize> = None;
|
||
for p in opps {
|
||
loop {
|
||
let w = self.fits_w(&(start..p));
|
||
if w <= self.line_avail(ranges.len()) || last_ok.is_none() {
|
||
last_ok = Some(p);
|
||
break;
|
||
}
|
||
let Some(b) = last_ok.take() else { break };
|
||
ranges.push(start..b);
|
||
start = b;
|
||
}
|
||
}
|
||
ranges.push(start..self.text.len());
|
||
ranges
|
||
}
|
||
|
||
/// The line over byte range `r`: the `li`-th of the paragraph, `last` if it is the final one.
|
||
fn place_line(&self, li: usize, r: Range<usize>, last: bool) -> ParaLine {
|
||
let align = align_for(self.para, self.width, last);
|
||
let avail = avail_for(self.para, self.width, li == 0);
|
||
let (ce, tw) = self.trailing_ws(&r);
|
||
let hang = self.hang_of(&r, ce);
|
||
let content = self.range_w(r.start..ce);
|
||
// justification
|
||
let mut space_extra = 0.0;
|
||
if align == Align::Justify
|
||
&& let Some(maxw) = avail
|
||
{
|
||
let spaces = self.text[r.start..ce].chars().filter(|c| *c == ' ').count();
|
||
if spaces > 0 {
|
||
space_extra = ((maxw - (content - hang.0 - hang.1)) / spaces as f32).max(0.0);
|
||
}
|
||
}
|
||
let placed = self.place_glyphs(&r, space_extra, ce);
|
||
let carets = self.carets(&r, &placed.extents, placed.pen);
|
||
let (asc, desc, adv) = self.line_metrics(&r, &placed.glyphs);
|
||
let content_w = if space_extra > 0.0 { avail.map_or(content, |a| a + hang.0 + hang.1) } else { content };
|
||
ParaLine {
|
||
range: self.base + r.start..self.base + r.end,
|
||
glyphs: placed.glyphs,
|
||
carets,
|
||
content: content_w,
|
||
left_trim: if self.base_rtl { tw } else { 0.0 },
|
||
hang,
|
||
ascent: asc,
|
||
descent: desc,
|
||
advance: adv,
|
||
align,
|
||
avail,
|
||
margins: margins_for(self.para, li == 0),
|
||
underline: placed.underline,
|
||
}
|
||
}
|
||
|
||
fn place_glyphs(&self, r: &Range<usize>, space_extra: f32, ce: usize) -> Placed {
|
||
let (levels, vruns) = self.bidi.visual_runs(&self.bidi.paragraphs[0], r.clone());
|
||
let mut pen = 0.0f32;
|
||
let mut glyphs = Vec::new();
|
||
let mut extents: HashMap<usize, (f32, f32, bool)> = HashMap::new();
|
||
let mut underline: Option<(f32, f32)> = None;
|
||
for run in vruns {
|
||
let rtl = levels[run.start].is_rtl();
|
||
// Items are ordered and disjoint: binary-search both ends of the overlapping slice.
|
||
let run_limit = self.items.partition_point(|item| item.range.start < run.end);
|
||
let run_first = self.items[..run_limit].partition_point(|item| item.range.end <= run.start);
|
||
let mut idx: Vec<usize> = (run_first..run_limit).collect();
|
||
if rtl {
|
||
idx.reverse();
|
||
}
|
||
for k in idx {
|
||
for g in self.items[k].glyphs.iter().filter(|g| run.contains(&g.cluster)) {
|
||
let st = &self.runs[g.run].1;
|
||
let x0 = pen;
|
||
pen += g.pre;
|
||
glyphs.push(Glyph {
|
||
face: g.face,
|
||
id: g.id,
|
||
x: pen + g.dx,
|
||
y: -g.dy - g.rise,
|
||
size: g.size,
|
||
cluster: self.base + g.cluster,
|
||
synth_bold: self.primaries[g.run].synth_bold || st.faux_bold,
|
||
synth_italic: self.primaries[g.run].synth_italic || st.faux_italic,
|
||
h_scale: st.h_scale,
|
||
v_scale: st.v_scale,
|
||
run: g.run,
|
||
variations: self.variation_ids[g.run],
|
||
});
|
||
pen += g.adv;
|
||
if space_extra > 0.0 && self.text[g.cluster..].starts_with(' ') && g.cluster < ce {
|
||
pen += space_extra;
|
||
}
|
||
if st.underline && g.cluster < ce {
|
||
let u = underline.get_or_insert((x0, pen));
|
||
u.0 = u.0.min(x0);
|
||
u.1 = u.1.max(pen);
|
||
}
|
||
let e = extents.entry(g.cluster).or_insert((x0, pen, self.items[k].rtl));
|
||
e.0 = e.0.min(x0);
|
||
e.1 = e.1.max(pen);
|
||
}
|
||
}
|
||
}
|
||
Placed { glyphs, pen, extents, underline }
|
||
}
|
||
|
||
/// Caret stops at each char boundary of the line, and at its end.
|
||
fn carets(&self, r: &Range<usize>, extents: &HashMap<usize, (f32, f32, bool)>, pen: f32) -> Vec<(usize, f32)> {
|
||
let mut carets = Vec::new();
|
||
let mut cluster_starts: Vec<usize> = extents.keys().copied().collect();
|
||
cluster_starts.sort_unstable();
|
||
let char_bytes: Vec<usize> = self.text[r.clone()].char_indices().map(|(b, _)| r.start + b).collect();
|
||
let mut char_index = 0usize;
|
||
for (ci, &cs) in cluster_starts.iter().enumerate() {
|
||
let (x0, x1, rtl) = extents[&cs];
|
||
let next = cluster_starts.get(ci + 1).copied().unwrap_or(r.end);
|
||
while char_index < char_bytes.len() && char_bytes[char_index] < cs {
|
||
char_index += 1;
|
||
}
|
||
let begin = char_index;
|
||
while char_index < char_bytes.len() && char_bytes[char_index] < next {
|
||
char_index += 1;
|
||
}
|
||
let count = (char_index - begin).max(1) as f32;
|
||
for (mi, b) in char_bytes[begin..char_index].iter().enumerate() {
|
||
let f = mi as f32 / count;
|
||
let x = if rtl { x1 - (x1 - x0) * f } else { x0 + (x1 - x0) * f };
|
||
carets.push((self.base + b, x));
|
||
}
|
||
}
|
||
// end of line caret
|
||
let end_x = match self.chars.iter().rev().find(|c| c.0 < r.end && c.0 >= r.start) {
|
||
Some(c) => {
|
||
let cs = cluster_starts.iter().rev().find(|s| **s <= c.0).copied();
|
||
match cs.and_then(|s| extents.get(&s)) {
|
||
Some(&(x0, x1, rtl)) => {
|
||
if rtl {
|
||
x0
|
||
} else {
|
||
x1
|
||
}
|
||
}
|
||
None => pen,
|
||
}
|
||
}
|
||
None => 0.0,
|
||
};
|
||
carets.push((self.base + r.end, end_x));
|
||
carets.sort_by_key(|c| c.0);
|
||
carets.dedup_by_key(|c| c.0);
|
||
carets
|
||
}
|
||
|
||
/// Ascent, descent and advance of the line: the tallest face and the largest leading on it.
|
||
fn line_metrics(&self, r: &Range<usize>, glyphs: &[Glyph]) -> (f32, f32, f32) {
|
||
let (mut asc, mut desc, mut adv) = (0.0f32, 0.0f32, 0.0f32);
|
||
let char_start = self.chars.partition_point(|c| c.0 < r.start);
|
||
let char_end = self.chars.partition_point(|c| c.0 < r.end);
|
||
let mut seen: Vec<usize> = self.chars[char_start..char_end].iter().map(|c| c.6).collect();
|
||
seen.sort_unstable();
|
||
seen.dedup();
|
||
if seen.is_empty() {
|
||
seen.push(self.chars.first().map_or(0, |c| c.6));
|
||
}
|
||
for &si in &seen {
|
||
let m = style_metrics(self.primaries, self.runs, si);
|
||
let rise = self.runs[si].1.rise();
|
||
asc = asc.max(m.ascent + rise.max(0.0));
|
||
desc = desc.max(m.descent - rise.min(0.0));
|
||
adv = adv.max(self.runs[si].1.line_advance());
|
||
}
|
||
for g in glyphs {
|
||
if g.face != self.primaries[g.run].face {
|
||
let m = fonts::face(g.face).metrics(g.size);
|
||
asc = asc.max(m.ascent);
|
||
desc = desc.max(m.descent);
|
||
}
|
||
}
|
||
(asc, desc, adv)
|
||
}
|
||
}
|
||
|
||
/// Each char with its shaped form, face, direction and run.
|
||
fn char_forms(text: &str, base: usize, runs: &[(Range<usize>, TextStyle)], primaries: &[Resolved], bidi: &BidiInfo) -> Vec<CharInfo> {
|
||
text.char_indices()
|
||
.map(|(b, c)| {
|
||
let si = run_at(runs, base + b);
|
||
let style = &runs[si].1;
|
||
let primary = primaries[si].face;
|
||
let face_of = |ch: char| if ch.is_whitespace() { primary } else { fonts::fallback_for(ch, primary) };
|
||
let all_small = style.opentype.all_small_caps && style.caps != Caps::All;
|
||
let small = (style.caps == Caps::Small || all_small) && c.is_lowercase();
|
||
let small_cap = all_small && c.is_uppercase();
|
||
let (sc, mut form) = match style.caps {
|
||
Caps::All => (upper_single(c), Form::Normal),
|
||
_ if small && feature_substitutes(face_of(c), b"smcp", c) => (c, Form::TrueSmall),
|
||
_ if small_cap && feature_substitutes(face_of(c), b"c2sc", c) => (c, Form::TrueSmall),
|
||
_ if small => (upper_single(c), Form::FauxSmall),
|
||
_ if small_cap => (c, Form::FauxSmall),
|
||
_ => (c, Form::Normal),
|
||
};
|
||
let face = face_of(sc);
|
||
if form == Form::Normal && style.script != Script::Normal && !sc.is_whitespace() {
|
||
let tag = if style.script == Script::Sub { b"subs" } else { b"sups" };
|
||
if feature_substitutes(face, tag, sc) {
|
||
form = Form::TrueScript;
|
||
}
|
||
}
|
||
(b, c, sc, face, bidi.levels[b].is_rtl(), form, si)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Runs of chars with the same face, direction, form, run and CJK vertical flag, shaped as one.
|
||
fn build_items(text: &str, chars: &[CharInfo], runs: &[(Range<usize>, TextStyle)]) -> Vec<Item> {
|
||
let key = |c: &CharInfo| (c.3, c.4, c.5, c.6, runs.get(c.6).is_some_and(|run| run.1.vertical) && crate::is_cjk(c.1));
|
||
let mut items: Vec<Item> = Vec::new();
|
||
let mut i = 0;
|
||
while i < chars.len() {
|
||
let k0 = key(&chars[i]);
|
||
let mut j = i + 1;
|
||
while j < chars.len() && key(&chars[j]) == k0 {
|
||
j += 1;
|
||
}
|
||
let (face, rtl, form, si, _) = k0;
|
||
let style = &runs[si].1;
|
||
let sub: Vec<(usize, char, char)> = chars[i..j].iter().map(|c| (c.0, c.1, c.2)).collect();
|
||
let size = match form {
|
||
Form::FauxSmall => style.glyph_size() * SMALL_CAPS_SCALE,
|
||
Form::TrueScript => style.size,
|
||
_ => style.glyph_size(),
|
||
};
|
||
let mut glyphs = shape_item(&sub, rtl, face, size, style, si, form);
|
||
space_item(&mut glyphs, style, text, true);
|
||
let end = if j < chars.len() { chars[j].0 } else { text.len() };
|
||
items.push(Item { range: chars[i].0..end, rtl, glyphs });
|
||
i = j;
|
||
}
|
||
items
|
||
}
|
||
|
||
fn paragraph(
|
||
text: &str,
|
||
base: usize,
|
||
runs: &[(Range<usize>, TextStyle)],
|
||
primaries: &[Resolved],
|
||
variation_ids: &[u32],
|
||
para: &ParagraphStyle,
|
||
width: Option<f32>,
|
||
) -> Vec<ParaLine> {
|
||
if text.is_empty() {
|
||
let si = run_at(runs, base);
|
||
let pm = style_metrics(primaries, runs, si);
|
||
return vec![ParaLine {
|
||
range: base..base,
|
||
glyphs: vec![],
|
||
carets: vec![(base, 0.0)],
|
||
content: 0.0,
|
||
left_trim: 0.0,
|
||
hang: (0.0, 0.0),
|
||
ascent: pm.ascent,
|
||
descent: pm.descent,
|
||
advance: runs[si].1.line_advance(),
|
||
align: align_for(para, width, true),
|
||
avail: avail_for(para, width, true),
|
||
margins: margins_for(para, true),
|
||
underline: None,
|
||
}];
|
||
}
|
||
let p = Para::new(text, base, runs, primaries, variation_ids, para, width);
|
||
let ranges = p.break_ranges();
|
||
let nlines = ranges.len();
|
||
ranges.into_iter().enumerate().map(|(li, r)| p.place_line(li, r, li + 1 == nlines)).collect()
|
||
}
|
||
|
||
pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Layout {
|
||
layout_rich_uncached(text, &[(0..text.len(), style.clone())], std::slice::from_ref(para))
|
||
}
|
||
|
||
/// Maximum input size processed by one layout request. Above this, return an empty layout
|
||
/// retaining the source length, so untrusted text cannot fan out into unbounded shaped data.
|
||
const MAX_LAYOUT_BYTES: usize = 1 << 20;
|
||
|
||
/// Lay out `text` with character style runs (byte ranges covering the text, in order; at least
|
||
/// one) and paragraph settings (one per CR/LF/CRLF-separated paragraph; the last repeats). The wrap
|
||
/// width is the first paragraph's.
|
||
pub fn layout_rich_uncached(text: &str, runs: &[(Range<usize>, TextStyle)], paras: &[ParagraphStyle]) -> Layout {
|
||
if text.len() > MAX_LAYOUT_BYTES || runs.len() > MAX_LAYOUT_BYTES || paras.len() > MAX_LAYOUT_BYTES {
|
||
return Layout { text_len: text.len(), ..Default::default() };
|
||
}
|
||
let fallback = [(0..text.len(), TextStyle::default())];
|
||
let runs = if runs.is_empty() { &fallback[..] } else { runs };
|
||
let default_para = ParagraphStyle::default();
|
||
let para_at = |i: usize| paras.get(i).or(paras.last()).unwrap_or(&default_para);
|
||
let width = para_at(0).width;
|
||
let primaries: Vec<Resolved> = runs.iter().map(|(_, s)| fonts::resolve(&s.family, &s.style)).collect();
|
||
// A named instance of a variable font (`Figtree` `Black`, or `Figtree-Black`) sets the base
|
||
// axis values; axes set explicitly in the style keep theirs.
|
||
let instanced = primaries.iter().any(|p| p.instance.is_some()).then(|| {
|
||
runs.iter()
|
||
.zip(&primaries)
|
||
.map(|((r, s), p)| {
|
||
let base = fonts::instance_variations(p);
|
||
let mut s = s.clone();
|
||
if !base.is_empty() {
|
||
s.variations = fonts::merge_variations(&base, &s.variations);
|
||
}
|
||
(r.clone(), s)
|
||
})
|
||
.collect::<Vec<_>>()
|
||
});
|
||
let runs = instanced.as_deref().unwrap_or(runs);
|
||
// A location that cannot be interned would outline at the default; shape there too.
|
||
let cleared = runs.iter().any(|(_, s)| !s.variations.is_empty() && crate::variable::intern(&s.variations) == 0).then(|| {
|
||
runs.iter()
|
||
.map(|(r, s)| {
|
||
let mut s = s.clone();
|
||
if crate::variable::intern(&s.variations) == 0 {
|
||
s.variations.clear();
|
||
}
|
||
(r.clone(), s)
|
||
})
|
||
.collect::<Vec<_>>()
|
||
});
|
||
let runs = cleared.as_deref().unwrap_or(runs);
|
||
let variation_ids: Vec<u32> = runs.iter().map(|(_, s)| crate::variable::intern(&s.variations)).collect();
|
||
let mut lay = Layout { missing_font: primaries.iter().any(|p| p.missing), text_len: text.len(), ..Default::default() };
|
||
let mut plines: Vec<(usize, ParaLine)> = Vec::new();
|
||
for (pi, span) in paragraph_ranges(text).enumerate() {
|
||
let p = text.get(span.bytes.clone()).unwrap_or_default();
|
||
plines.extend(paragraph(p, span.bytes.start, runs, &primaries, &variation_ids, para_at(pi), width).into_iter().map(|l| (pi, l)));
|
||
}
|
||
let mut baseline = 0.0f32;
|
||
for (i, (pi, pl)) in plines.into_iter().enumerate() {
|
||
let para = para_at(pi);
|
||
if i == 0 {
|
||
baseline = if width.is_some() { pl.ascent } else { 0.0 };
|
||
} else {
|
||
let prev_para = lay.lines.last().map_or(pi, |l| l.para);
|
||
let mut adv = pl.advance + para.leading;
|
||
if prev_para != pi {
|
||
adv += para_at(prev_para).space_after + para.space_before;
|
||
}
|
||
baseline += adv;
|
||
}
|
||
let shift = line_shift(&pl);
|
||
let g0 = lay.glyphs.len();
|
||
lay.glyphs.extend(pl.glyphs.into_iter().map(|mut g| {
|
||
g.x += shift;
|
||
g.y += baseline;
|
||
g
|
||
}));
|
||
let x = shift + pl.left_trim;
|
||
if let Some((u0, u1)) = pl.underline {
|
||
let si = run_at(runs, pl.range.start);
|
||
let pm = fonts::face(primaries[si].face).metrics(runs[si].1.size);
|
||
lay.underlines.push([u0 + shift, baseline + pm.underline_pos, u1 + shift, baseline + pm.underline_pos + pm.underline_thickness]);
|
||
}
|
||
lay.lines.push(Line {
|
||
range: pl.range,
|
||
baseline,
|
||
x,
|
||
width: pl.content,
|
||
ascent: pl.ascent,
|
||
descent: pl.descent,
|
||
glyphs: g0..lay.glyphs.len(),
|
||
carets: pl.carets.into_iter().map(|(b, cx)| (b, cx + shift)).collect(),
|
||
para: pi,
|
||
para_end: false,
|
||
});
|
||
}
|
||
for i in 0..lay.lines.len() {
|
||
lay.lines[i].para_end = lay.lines.get(i + 1).is_none_or(|n| n.para != lay.lines[i].para);
|
||
}
|
||
set_bounds(&mut lay, width);
|
||
lay
|
||
}
|
||
|
||
/// Horizontal shift of a line from its paragraph's alignment, margins and hanging punctuation.
|
||
fn line_shift(pl: &ParaLine) -> f32 {
|
||
let shown = pl.content - pl.hang.0 - pl.hang.1;
|
||
let (ml, mr) = pl.margins;
|
||
let shift = match (pl.avail, pl.align) {
|
||
(None, Align::Left | Align::Justify) => ml,
|
||
(None, Align::Center) => -shown / 2.0 + (ml - mr) / 2.0,
|
||
(None, Align::Right) => -shown - mr,
|
||
(Some(_), Align::Left | Align::Justify) => ml,
|
||
(Some(a), Align::Center) => ml + (a - shown) / 2.0,
|
||
(Some(a), Align::Right) => ml + a - shown,
|
||
};
|
||
shift - pl.left_trim - pl.hang.0
|
||
}
|
||
|
||
/// The layout's bounds: the lines' horizontal extent (and the wrap width), and their vertical extent.
|
||
fn set_bounds(lay: &mut Layout, width: Option<f32>) {
|
||
let (mut x0, mut x1) = (f32::MAX, f32::MIN);
|
||
for l in &lay.lines {
|
||
x0 = x0.min(l.x);
|
||
x1 = x1.max(l.x + l.width);
|
||
}
|
||
if let Some(w) = width {
|
||
x0 = x0.min(0.0);
|
||
x1 = x1.max(w);
|
||
}
|
||
if let (Some(first), Some(last)) = (lay.lines.first(), lay.lines.last()) {
|
||
lay.bounds = [x0, first.baseline - first.ascent, x1.max(x0), last.baseline + last.descent];
|
||
}
|
||
}
|
||
|
||
fn cache() -> &'static Mutex<HashMap<u64, (u64, Arc<Layout>)>> {
|
||
static C: OnceLock<Mutex<HashMap<u64, (u64, Arc<Layout>)>>> = OnceLock::new();
|
||
C.get_or_init(Default::default)
|
||
}
|
||
|
||
/// Lay out `text` in one style (cached by text and styles).
|
||
pub fn layout(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Arc<Layout> {
|
||
layout_rich(text, &[(0..text.len(), style.clone())], std::slice::from_ref(para))
|
||
}
|
||
|
||
/// [`layout_rich_uncached`], cached by text, runs and paragraph settings.
|
||
pub fn layout_rich(text: &str, runs: &[(Range<usize>, TextStyle)], paras: &[ParagraphStyle]) -> Arc<Layout> {
|
||
if text.len() > MAX_LAYOUT_BYTES || runs.len() > MAX_LAYOUT_BYTES || paras.len() > MAX_LAYOUT_BYTES {
|
||
return Arc::new(layout_rich_uncached(text, runs, paras));
|
||
}
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
text.hash(&mut h);
|
||
runs.hash(&mut h);
|
||
paras.hash(&mut h);
|
||
let key = h.finish();
|
||
static CLOCK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||
let now = CLOCK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||
if let Some(e) = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_mut(&key) {
|
||
e.0 = now;
|
||
return e.1.clone();
|
||
}
|
||
let l = Arc::new(layout_rich_uncached(text, runs, paras));
|
||
let mut c = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||
if c.len() >= 512 {
|
||
// evict the oldest quarter
|
||
let mut ages: Vec<u64> = c.values().map(|v| v.0).collect();
|
||
ages.sort_unstable();
|
||
let cut = ages[ages.len() / 4];
|
||
c.retain(|_, v| v.0 > cut);
|
||
}
|
||
c.insert(key, (now, l.clone()));
|
||
l
|
||
}
|
||
|
||
/// Width of a single line of `text` (no wrapping).
|
||
pub fn measure(text: &str, style: &TextStyle) -> f32 {
|
||
let l = layout(text, style, &ParagraphStyle::default());
|
||
l.lines.iter().map(|l| l.width).fold(0.0, f32::max)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|