Files
vectorcraft/crates/text/src/layout/driver.rs
T
dotCooCoo c94aec3fff Envelopes: type maps from its glyph outline bounds
An envelope mapped type from its laid-out box: ascent to descent for
point type, the frame for area type. The distorted glyphs then filled
less of the envelope than other art does, with room above the capitals
and below the baseline.

The envelope now maps type from the bounds of its glyph outlines,
underlines and strikethroughs when the type is on its own, in groups
and layers, or used as a clipping path. Inline graphics count by their
boxes. Rotated type and type in perspective map from their outlines as
they are drawn. Make with Mesh,
Make with Top Object and Reset with Mesh without Maintain Shape use
those bounds. Type that draws nothing keeps its laid-out box, and so
does type inside any other object, such as a blend, a repeat or a
symbol instance. Shrink Text to Fit type is measured as it is laid out
on the page.

vectorcraft-text's envelope_ink measures the type, and
register_envelope_ink registers it with the document crate. The engine
session, Renderer::new, live evaluation in vectorcraft-effects and the
.ai reader register it. The document crate keeps the measured boxes per
thread, by object and by a hash of the content, and measures again when
the type, the fonts, the hyphenation exceptions or the transform's
scale or rotation change.

This is VectorCraft's own rule. Envelopes of type made in VectorCraft
and in saved files map from the glyph outline bounds instead of the
type's laid-out box, so they redraw differently.
2026-11-08 13:20:19 +07:00

391 lines
17 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.
use super::flow::flow;
use super::on_path::on_path;
use super::region::Region;
use super::ruby::ruby_layout;
use super::vertical::{EM_CENTER, QUARTER_TURN, align_vertically, finish_bounds};
use super::{
Affine, BezPath, CharStyle, Ctx, FontDb, LayoutOptions, OtFeatures, Point, Rect, Shape, TextKind, TextLayout, TextObject, VerticalAlign,
keep_lone_ligature_carets,
};
/// Lay out a text object into text-space glyph outlines (default [`LayoutOptions`]).
pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout {
let a = &t.area;
let opts = LayoutOptions {
rows: a.rows,
columns: a.columns,
gutter: a.gutter,
row_gutter: a.row_gutter,
inset: a.inset,
first_baseline: a.first_baseline,
first_baseline_min: a.first_baseline_min,
vertical_align: a.vertical_align,
flow: a.flow,
fit: a.fit,
..LayoutOptions::default()
};
layout_with(db, t, &opts)
}
/// Area type in a frame too short for its first line draws nothing (type that doesn't fit its frame
/// isn't drawn). For importers whose source shows that line: the frame is made taller, from its
/// top, by what the first line needs (vertical type's frame wider, from its right edge, where its
/// first column is). Whether it was.
pub fn fit_first_line(db: &FontDb, t: &mut TextObject) -> bool {
let TextKind::Area { frame } = &t.kind else { return false };
let across = |b: &Rect| if t.vertical { b.width() } else { b.height() };
let Some(b) = frame.bounds().filter(|b| across(b) > 1e-6) else { return false };
if !layout(db, t).lines.is_empty() {
return false;
}
// The height of its first line (a column's width): the line's as point type, its baseline at
// least Area Type Options › First Baseline's Min from the frame's top.
let point = TextObject { kind: TextKind::Point, ..t.clone() };
let min = if t.area.first_baseline_min.is_finite() { t.area.first_baseline_min.max(0.0) } else { 0.0 };
let Some(need) = layout(db, &point).lines.first().map(|l| l.ascent.max(min) + l.descent) else { return false };
// (A little more than it, for the rounding of the layout.)
let by = (need + 0.05) / across(&b);
if !(by.is_finite() && by > 1.0) {
return false;
}
let taller = if t.vertical {
frame.transformed(Affine::translate((b.x1, 0.0)) * Affine::scale_non_uniform(by, 1.0) * Affine::translate((-b.x1, 0.0)))
} else {
frame.transformed(Affine::translate((0.0, b.y0)) * Affine::scale_non_uniform(1.0, by) * Affine::translate((0.0, -b.y0)))
};
let short = std::mem::replace(&mut t.kind, TextKind::Area { frame: taller });
// Something else keeps the line out (a frame narrower than a letter): as the source has it.
if layout(db, t).lines.is_empty() {
t.kind = short;
return false;
}
true
}
/// The underline and strikethrough bars of `t` laid out as `layout` (#847): per run and line, a
/// bar from the stretch's first glyph to its last one's advance, placed and sized by the font's
/// own underline and strikeout metrics through the glyph's transform (so the size, baseline shift
/// and scaling follow) → (run, bar in text space). Type on a path and vertical type have none yet.
pub fn decorations(layout: &TextLayout, db: &FontDb, t: &TextObject) -> Vec<(usize, BezPath)> {
let mut out = vec![];
if layout.on_path || layout.vertical {
return out;
}
let glyphs = &layout.glyphs;
let mut i = 0;
while let Some(g) = glyphs.get(i) {
// The stretch: the glyphs of this run on this line, in a row. A list marker has no
// underline or strikethrough.
let marker = layout.marker_of(i).is_some();
let end = glyphs
.iter()
.enumerate()
.skip(i)
.position(|(k, h)| h.run != g.run || h.line != g.line || layout.marker_of(k).is_some() != marker)
.map_or(glyphs.len(), |n| i + n);
let stretch = glyphs.get(i..end).unwrap_or_default();
i = end.max(i + 1);
if marker {
continue;
}
let Some(style) = t.runs.get(g.run).map(|r| &r.style) else { continue };
if !(style.underline || style.strikethrough) {
continue;
}
let Some(face) = db.face_by_id(g.font_id) else { continue };
let (x0, x1) = stretch.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), h| {
let (s, e) = (h.origin.x, h.origin.x + h.advance);
(a.min(s).min(e), b.max(s).max(e))
});
for (on, (offset, thickness)) in [(style.underline, face.underline), (style.strikethrough, face.strikeout)] {
if !on {
continue;
}
// Font units run y down in the glyph's transform: the bar's top is at -offset.
let (top, bottom) = ((g.xf * Point::new(0.0, -offset)).y, (g.xf * Point::new(0.0, -offset + thickness)).y);
let bar = Rect::new(x0, top.min(bottom), x1, top.max(bottom));
if bar.width() > 0.0 && bar.height() > 0.0 && bar.width().is_finite() && bar.height().is_finite() {
out.push((g.run, bar.to_path(0.1)));
}
}
}
out
}
/// The box of what `t` draws with text space mapped by `m`: its glyph outlines
/// ([`TextLayout::ink_bounds_in`]) and its underline and strikethrough bars ([`decorations`]). With
/// `projection` `(h, after)`, each point `m` maps is then drawn through `h` (a point `h` can't map
/// stays where it is, as the renderer draws it) and mapped by `after`; the outlines and bars are
/// flattened to within 1/1000 of their size, and images, color glyphs and inline graphics count by
/// their boxes' corners. What an envelope maps type from ([`register_envelope_ink`]). `None` when
/// nothing draws.
pub fn envelope_ink(t: &TextObject, m: Affine, projection: Option<(vectorcraft_geom::Homography, Affine)>) -> Option<Rect> {
let db = FontDb::global();
let laid = layout(db, t);
let bars = decorations(&laid, db, t).into_iter().map(|(_, bar)| bar);
let union = |acc: Option<Rect>, b: Rect| Some(acc.map_or(b, |a| a.union(b)));
let Some((h, after)) = projection else {
return bars.map(|bar| (m * bar).bounding_box()).fold(laid.ink_bounds_in(m), union);
};
let draw = |p: Point| {
let q = m * p;
after * h.apply(q).unwrap_or(q)
};
let add = |acc: Option<Rect>, p: Point| union(acc, Rect::from_points(p, p));
let projected = |path: &BezPath| {
let size = path.bounding_box();
let tolerance = (size.width().max(size.height()) / 1000.0).max(1e-6);
let mut points = vec![];
kurbo::flatten(path, tolerance, |el| {
if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el {
points.push(draw(p));
}
});
points.into_iter().fold(None, add)
};
let corners = |r: Rect| {
[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(draw).into_iter().fold(None, add)
};
let glyphs = laid.glyphs.iter().chain(&laid.ruby).filter_map(|g| projected(&g.outline));
let boxes = laid.glyphs.iter().flat_map(|g| {
let image = g.placed_image().map(|(image, xf)| xf.transform_rect_bbox(image.rect()));
let colour = g.colour.as_ref().and_then(|c| c.geometric_bounds()).map(|b| g.xf.transform_rect_bbox(b));
[image, colour]
});
let boxes = boxes.chain(laid.inlines.iter().map(|i| Some(i.bounds))).flatten().filter_map(corners);
glyphs.chain(bars.filter_map(|bar| projected(&bar))).chain(boxes).fold(None, union)
}
/// Make envelopes map type from [`envelope_ink`], measured again when the fonts or the hyphenation
/// exceptions change (the first registration stays, see `vectorcraft_doc::live::set_type_ink`).
pub fn register_envelope_ink() {
vectorcraft_doc::live::set_type_ink(envelope_ink, envelope_ink_inputs);
}
/// A number that changes whenever what [`envelope_ink`] reads besides the type changes: the
/// installed fonts and the hyphenation exceptions.
pub(crate) fn envelope_ink_inputs() -> u64 {
FontDb::global().generation().rotate_left(32) ^ crate::hyphen::exceptions_generation()
}
/// Most layout passes Shrink Text to Fit runs past the first (bisection plus the final pass).
const SHRINK_PASSES: usize = 11;
/// Lay out a text object with explicit options (area type rows/columns, inset, first baseline,
/// fit, composer, OpenType features). Each run is set at the em size its font height reference
/// gives it ([`FontDb::font_heights_resolved`]).
pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout {
#[cfg(any(test, feature = "test-fonts"))]
crate::test_fonts::count_layout();
let resolved = db.font_heights_resolved(t);
let t = &*resolved;
let first = layout_once(db, t, opts);
match (opts.fit.min_scale(), &t.kind) {
(Some(min), TextKind::Area { .. }) if first.overflow => shrink_to_fit(db, t, opts, min).unwrap_or(first),
_ => first,
}
}
/// Shrink Text to Fit for text that overflows at full size: the largest scale in `min..1` at which
/// it fits, searched by bisection over the first run's document size in steps of 0.1 pt (so its scaled size
/// is a whole number of tenths of a point, the same every time). Down at `min` it lays out there,
/// still overflowing. None (keep the full-size layout) when the text has no usable size.
fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) -> Option<TextLayout> {
// Exact size edits after object scaling may store a local size below 0.1. Fit in the
// same document points as the Character controls, independent of that representation.
let points = t.style_scale()?.points;
let size = t.runs.first().map(|r| r.style.size * points).filter(|s| s.is_finite() && *s >= 0.1)?;
if !(min.is_finite() && min < 1.0) {
return None;
}
let tenths = size * 10.0;
if !tenths.is_finite() {
return None;
}
// Candidate sizes k/10 pt for k in lo..hi: `hi` (full size, rounded down) is known to overflow
// unless it is below the full size; `lo` is the smallest allowed.
let mut lo = (tenths * min).ceil().max(1.0);
let mut hi = tenths.floor();
if hi < lo {
hi = lo;
}
let at = |k: f64| -> TextLayout {
let f = (k / tenths).clamp(min, 1.0);
let mut l = layout_once(db, &scaled(t, f), opts);
l.fit_scale = f;
l
};
let mut passes = 0;
let mut best: Option<TextLayout> = None;
// `hi` itself may fit when the size isn't a whole number of tenths.
if hi < tenths && hi > lo {
let l = at(hi);
passes += 1;
if !l.overflow {
return Some(l);
}
}
// Invariant: everything at `hi` or above overflows; `lo` fits or is the floor.
while hi - lo > 1.0 && passes + 1 < SHRINK_PASSES {
let mid = (lo + (hi - lo) * 0.5).floor();
let l = at(mid);
passes += 1;
if l.overflow {
hi = mid;
} else {
lo = mid;
best = Some(l);
}
}
// `best` is the layout at `lo` once `lo` has moved; at the floor it still needs laying out.
Some(best.unwrap_or_else(|| at(lo)))
}
/// `t` with every run's size, explicit leading, baseline shift and tate-chu-yoko offsets scaled by `f`, inline art's own
/// shift included (auto leading and the art's size follow the size; paragraph spacing stays).
/// Shrink Text to Fit lays `t` out as `scaled(t, f)`, where `f` is the layout's
/// [`TextLayout::fit_scale`], so the result holds the styles the text is drawn in.
pub fn scaled(t: &TextObject, f: f64) -> TextObject {
let mut s = t.clone();
for r in &mut s.runs {
r.style.size *= f;
r.style.leading = r.style.leading.map(|l| l * f);
r.style.baseline_shift *= f;
r.style.tcy_up_down *= f;
r.style.tcy_left_right *= f;
if let Some(a) = &mut r.inline {
a.baseline_shift *= f;
}
}
s
}
#[cfg(test)]
thread_local! {
/// Set while [`layout_faces_per_call`] lays text out: layout keeps no run faces.
static FACES_PER_CALL: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// [`layout`] keeping no run faces, so that every lookup of a face asks [`FontDb::style_face`]
/// again: the reference the faces kept per run are compared with.
#[cfg(test)]
pub(crate) fn layout_faces_per_call(db: &FontDb, t: &TextObject) -> TextLayout {
FACES_PER_CALL.set(true);
let l = layout(db, t);
FACES_PER_CALL.set(false);
l
}
/// One layout pass, without fitting.
fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout {
let text = t.plain_text();
let mut runs = Vec::with_capacity(t.runs.len());
let mut inlines = Vec::with_capacity(t.runs.len());
let mut off = 0;
for r in &t.runs {
runs.push((off..off + r.text.len(), &r.style));
// Only a well-formed inline run (one object replacement character) is a graphic.
inlines.push(r.inline.as_ref().filter(|_| crate::edit::is_inline_text(&r.text)));
off += r.text.len();
}
let mut paras = Vec::new();
let mut s = 0;
for (i, c) in text.char_indices() {
if c == '\n' {
paras.push(s..i);
s = i + 1;
}
}
paras.push(s..text.len());
let vertical = t.vertical;
let is_on_path = matches!(&t.kind, TextKind::OnPath { .. });
let vopts;
let opts = if vertical {
vopts = LayoutOptions { features: OtFeatures { vertical: true, ..opts.features }, ..opts.clone() };
&vopts
} else {
opts
};
let faces = runs.iter().map(|_| std::cell::OnceCell::new()).collect();
#[cfg(test)]
let faces = if FACES_PER_CALL.get() { Vec::new() } else { faces };
let mut cx = Ctx {
on_path: is_on_path,
db,
text: &text,
faces,
runs,
inlines,
default: CharStyle::default(),
opts,
vertical,
system_layout: t.system_layout,
fractional_widths: t.fractional_widths,
upright_cluster: None,
out: TextLayout::default(),
};
// Vertical type: line space turned a quarter turn clockwise (lines become columns, each next
// one to the left). Point type's anchor is on the first column's centre line. Vertical path
// type stays on its path (each glyph turned across it in `emit`).
let line_xf = match &t.kind {
_ if !vertical => Affine::IDENTITY,
TextKind::OnPath { .. } => Affine::IDENTITY,
TextKind::Point => {
let first = cx.style_at(0);
let centre = cx.face_at(0).map_or(EM_CENTER, |f| f.ideographic_centre());
Affine::translate((-centre * first.size, 0.0)) * QUARTER_TURN
}
_ => QUARTER_TURN,
};
match &t.kind {
TextKind::Point => flow(&mut cx, &paras, t, None),
TextKind::Area { frame } => {
let to_lines = line_xf.inverse();
let wrap: Vec<vectorcraft_doc::WrapShape> = if vertical {
t.wrap.iter().map(|w| vectorcraft_doc::WrapShape { path: w.path.transformed(to_lines), ..w.clone() }).collect()
} else {
t.wrap.clone()
};
let mut regions = Region::cells(&(to_lines * frame.to_bezpath()), opts, &wrap);
cx.out.frames = regions.iter().map(|r| line_xf.transform_rect_bbox(r.cell)).collect();
flow(&mut cx, &paras, t, Some(&regions));
if opts.vertical_align != VerticalAlign::Top {
align_vertically(&mut cx, &paras, t, &mut regions);
}
}
TextKind::OnPath { path, .. } => on_path(&mut cx, &paras, t, path),
}
// Ruby (furigana): placed over its base text in line space, before vertical type turns the
// lines into columns, so the same pass puts it above (right in vertical type) and widens the
// lines it sits on. Type on a path has no above/below; its ruby is left out for now.
if !is_on_path && !t.ruby.is_empty() {
ruby_layout(&mut cx, t);
}
if vertical && !is_on_path {
// Glyph origins, outlines and transforms to text space (lines stay in line space).
for g in &mut cx.out.glyphs {
g.outline.apply_affine(line_xf);
g.xf = line_xf * g.xf;
g.origin = line_xf * g.origin;
g.angle += std::f64::consts::FRAC_PI_2;
}
// Ruby glyphs are placed in line space too (upright, along the column, in the vertical
// pass); the same turn takes them to text space.
for g in &mut cx.out.ruby {
g.outline.apply_affine(line_xf);
g.xf = line_xf * g.xf;
g.origin = line_xf * g.origin;
g.angle += std::f64::consts::FRAC_PI_2;
}
for i in &mut cx.out.inlines {
i.xf = line_xf * i.xf;
i.bounds = line_xf.transform_rect_bbox(i.bounds);
}
cx.out.vertical = true;
cx.out.line_xf = line_xf;
}
keep_lone_ligature_carets(&mut cx.out.glyphs);
finish_bounds(&mut cx.out);
cx.out
}