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.
This commit is contained in:
dotCooCoo
2026-11-08 13:20:19 +07:00
committed by bflatastic
parent 97c31e076f
commit c94aec3fff
19 changed files with 1089 additions and 25 deletions
+3 -2
View File
@@ -53,8 +53,9 @@ pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation,
#[cfg(test)]
use blend_spec::{blend_color_shift, blend_ramp};
pub use envelope::{
EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, envelope_bounds, envelope_content_boxes,
envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, envelope_overlay, envelope_stroke_margin, smooth_handles,
EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, TypeInk, envelope_bounds, envelope_content_bounds,
envelope_content_boxes, envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, envelope_overlay, envelope_stroke_margin,
set_type_ink, smooth_handles,
};
pub(crate) use expand::max_outset;
pub use expand::{
+394 -17
View File
@@ -1,6 +1,7 @@
use super::expand::{Hooks, Outliner, Warper, map_node};
use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, nodes_bounds, surface_of};
use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, surface_of};
use super::{Affine, Arc, Color, Deserialize, Node, NodeId, NodeKind, PathData, Point, Rect, Serialize, Vec2, yes};
use vectorcraft_geom::Homography;
/// How an envelope distorts its content.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -127,19 +128,293 @@ impl EnvelopeOptions {
}
}
/// The bounds of `content` in the axes of `frame`, with its strokes and geometric. Strokes scale
/// with the frame as [`Node::transform`] scales them.
/// The bounds of `content` in the axes of `frame`, with its strokes and geometric, type measured by
/// its glyph outlines (see [`envelope_content_bounds`]). Strokes scale with the frame as
/// [`Node::transform`] scales them.
pub fn envelope_content_boxes(content: &[Arc<Node>], frame: Affine) -> Option<(Rect, Rect)> {
let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY };
let to_frame = frame.inverse();
let (stroked, plain) = content.iter().fold((None, None), |(stroked, plain), c| {
let mut m = (**c).clone();
m.transform(to_frame, true);
(vectorcraft_geom::union_opt(stroked, m.visual_bounds()), vectorcraft_geom::union_opt(plain, m.geometric_bounds()))
(vectorcraft_geom::union_opt(stroked, content_box(c, true, to_frame)), vectorcraft_geom::union_opt(plain, content_box(c, false, to_frame)))
});
Some((stroked?, plain?))
}
/// The geometric bounds an envelope around `content` on the page axes maps from: type, alone or
/// in groups and layers, is measured by its glyph outlines, underlines and strikethroughs instead
/// of its laid-out box. Type keeps its laid-out box while nothing is registered with
/// [`set_type_ink`], and when it draws nothing.
pub fn envelope_content_bounds(content: &[Arc<Node>]) -> Option<Rect> {
content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, false, Affine::IDENTITY)))
}
/// The box of type's glyph outlines, underlines and strikethroughs with its text space mapped by
/// the given transform (outlines mapped point by point) and, for type in perspective, then drawn
/// through its projection and mapped by the second transform. `None` when it draws nothing. Type is
/// laid out above this crate, so the text crate registers one with [`set_type_ink`].
pub type TypeInk = fn(&crate::TextObject, Affine, Option<(Homography, Affine)>) -> Option<Rect>;
/// What [`TypeInk`] maps type's text space by: a transform, and a projection with the transform
/// after it.
type TypeMap = (Affine, Option<(Homography, Affine)>);
static TYPE_INK: std::sync::OnceLock<(TypeInk, crate::hit::LiveInputs)> = std::sync::OnceLock::new();
/// Register how envelopes measure type, and a number that changes whenever what it reads besides
/// the type changes, such as the installed fonts (the first registration stays).
pub fn set_type_ink(f: TypeInk, inputs: crate::hit::LiveInputs) {
// Already set: the same measure, registered by another session, an import or a renderer.
let _ = TYPE_INK.set((f, inputs));
}
/// How many entries each table of [`TypeInks`] holds before it is emptied.
const TYPE_INK_KEPT: usize = 1024;
/// `map`, bit for bit, for a table key.
fn map_bits(map: &TypeMap) -> [u64; 21] {
let mut bits = [0; 21];
let (m, projection) = map;
for (b, v) in bits.iter_mut().zip(m.as_coeffs()) {
*b = v.to_bits();
}
if let Some((h, after)) = projection {
for (b, v) in bits[6..].iter_mut().zip(h.to_array().into_iter().chain(after.as_coeffs())) {
*b = v.to_bits();
}
}
bits
}
/// A key of [`TypeInks::nodes`]: the object's address, the map its outlines are measured in (an
/// affine map without its move) and the inputs number registered with [`set_type_ink`].
type NodeKey = (usize, [u64; 21], u64);
/// A key of [`TypeInks::content`]: two 64-bit hashes of the type without its transform's move and
/// its layout caches ([`content_hash`]), the map its outlines are measured in, and the inputs number.
type ContentKey = ([u64; 2], [u64; 21], u64);
/// Type measured by [`type_ink`] on one thread, by the map it is measured in and the inputs
/// number: by the object (an unchanged object is found without being read),
/// and by its content (a moved or copied object is found without laying it out again). Neither
/// table keeps a copy of the type.
#[derive(Default)]
struct TypeInks {
/// By the object's address, with a weak pointer that keeps the address from being reused.
nodes: std::collections::HashMap<NodeKey, (std::sync::Weak<Node>, Option<Rect>)>,
/// By the type's content.
content: std::collections::HashMap<ContentKey, Option<Rect>>,
}
/// Where [`TypeInks::find`] did not find type: its keys in both tables (no content key for type
/// that could not be hashed whole).
struct Missing {
by_node: NodeKey,
content: Option<ContentKey>,
}
impl TypeInks {
/// The kept box of type node `n` (holding `t`) mapped by `map`, or the keys to keep it under
/// once it is measured.
fn find(&mut self, n: &Arc<Node>, t: &crate::TextObject, map: &TypeMap, inputs: u64) -> Result<Option<Rect>, Box<Missing>> {
// While the table holds a weak pointer to `n`'s allocation, no other object can be at that
// address, and `n` can't be changed in place (`Arc::get_mut` refuses it and `Arc::make_mut`
// moves it).
let by_node = (Arc::as_ptr(n) as usize, map_bits(map), inputs);
if let Some((_, ink)) = self.nodes.get(&by_node) {
return Ok(*ink);
}
// Shrink Text to Fit lays type out by its size on the page, so only a move keeps its layout.
let [a, b, c, d, _, _] = t.xf.as_coeffs();
let mut text = t.clone();
text.xf = Affine::new([a, b, c, d, 0.0, 0.0]);
text.cached_bounds = None;
text.cached_baselines.clear();
let content = content_hash(&text).map(|hash| (hash, by_node.1, inputs));
match content.and_then(|key| self.content.get(&key)) {
Some(ink) => {
let ink = *ink;
self.keep_node(n, by_node, ink);
Ok(ink)
}
None => Err(Box::new(Missing { by_node, content })),
}
}
/// Keep the box `ink` measured for type node `n` under the keys `missing`.
fn keep(&mut self, n: &Arc<Node>, missing: Missing, ink: Option<Rect>) {
if let Some(key) = missing.content {
if self.content.len() >= TYPE_INK_KEPT {
self.content.clear();
}
self.content.insert(key, ink);
}
self.keep_node(n, missing.by_node, ink);
}
fn keep_node(&mut self, n: &Arc<Node>, by_node: NodeKey, ink: Option<Rect>) {
if self.nodes.len() >= TYPE_INK_KEPT {
self.nodes.retain(|_, (weak, _)| weak.strong_count() > 0);
if self.nodes.len() >= TYPE_INK_KEPT {
self.nodes.clear();
}
}
self.nodes.insert(by_node, (Arc::downgrade(n), ink));
}
}
/// Two independent 64-bit hashes of `text` as saved (the layout caches are not saved), with what
/// layout reads that is not saved: whether its first paragraph continues a thread and the bounds
/// of its inline graphics. `None` when it can't be written whole.
fn content_hash(text: &crate::TextObject) -> Option<[u64; 2]> {
use std::hash::Hasher;
/// Feeds what is written to two hashers.
struct Feed([std::collections::hash_map::DefaultHasher; 2]);
impl std::io::Write for Feed {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
for h in &mut self.0 {
h.write(buf);
}
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let mut feed = Feed(Default::default());
// The second hasher starts from another state.
feed.0[1].write_u8(1);
serde_json::to_writer(&mut feed, text).ok()?;
let inline_bounds = text.runs.iter().filter_map(|r| r.inline.as_ref()).map(|i| i.bounds.map(|b| [b.x0, b.y0, b.x1, b.y1].map(f64::to_bits)));
for h in &mut feed.0 {
h.write_u8(u8::from(text.first_para_continues));
for b in inline_bounds.clone() {
match b {
None => h.write_u8(0),
Some(bits) => {
h.write_u8(1);
bits.into_iter().for_each(|v| h.write_u64(v));
}
}
}
}
let [a, b] = feed.0;
Some([a.finish(), b.finish()])
}
std::thread_local! {
/// The type [`type_ink`] measured on this thread.
static TYPE_INKS: std::cell::RefCell<TypeInks> = std::cell::RefCell::new(TypeInks::default());
}
/// The box of the glyph outlines of type node `n` (holding `t`) with its text space mapped by `m`
/// ([`set_type_ink`]) and, for type in perspective, drawn through `projection` and mapped by its
/// second transform; measured again when `t`, the maps (without a move of `m` when there is no
/// projection) or the registered inputs number change. `None` while nothing is registered.
fn type_ink(n: &Arc<Node>, t: &crate::TextObject, m: Affine, projection: Option<(Homography, Affine)>) -> Option<Option<Rect>> {
let (f, inputs) = TYPE_INK.get()?;
let inputs = inputs();
// Without a projection the type is measured without the move, which is added to the box.
let [a, b, c, d, e, g] = m.as_coeffs();
let (map, offset) = match projection {
None => ((Affine::new([a, b, c, d, 0.0, 0.0]), None), Vec2::new(e, g)),
Some(p) => ((m, Some(p)), Vec2::ZERO),
};
let ink = match TYPE_INKS.with_borrow_mut(|kept| kept.find(n, t, &map, inputs)) {
Ok(ink) => ink,
Err(missing) => {
// Measured outside the borrow: laying type out may measure other type.
let ink = f(t, map.0, map.1);
TYPE_INKS.with_borrow_mut(|kept| kept.keep(n, *missing, ink));
ink
}
};
Some(ink.map(|r| r + offset))
}
/// Add to `typed` the address of `n` and of each group and layer in it that holds type, which
/// [`content_box`] measures by its glyphs; is there type in `n`? One walk of the tree.
fn find_type(n: &Node, typed: &mut std::collections::HashSet<usize>) -> bool {
let holds = match &n.kind {
NodeKind::Text(_) => true,
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().fold(false, |holds, c| find_type(c, typed) | holds),
_ => false,
};
if holds {
typed.insert(n as *const Node as usize);
}
holds
}
/// The box of `n` an envelope maps from, in the axes `to` takes the page to: its geometric bounds,
/// or with `stroked` its visual bounds (strokes scaled by `to` as [`Node::transform`] scales them),
/// with type measured by its glyph outlines ([`type_ink`]) instead of its laid-out box, in groups
/// and layers too. Type is laid out as it is on the page, and its outlines are then taken into
/// those axes. A character's strokes reach as far past its glyphs as past the laid-out box.
fn content_box(n: &Arc<Node>, stroked: bool, to: Affine) -> Option<Rect> {
let mut typed = std::collections::HashSet::new();
find_type(n, &mut typed);
content_box_in(n, stroked, to, &typed)
}
/// [`content_box`], with the addresses of the objects in `n` that hold type ([`find_type`]).
fn content_box_in(n: &Arc<Node>, stroked: bool, to: Affine, typed: &std::collections::HashSet<usize>) -> Option<Rect> {
// `n` in the axes of `to`.
let moved = || {
if to == Affine::IDENTITY {
return std::borrow::Cow::Borrowed(&**n);
}
let mut m = (**n).clone();
m.transform(to, true);
std::borrow::Cow::Owned(m)
};
let own = |m: &Node| if stroked { m.visual_bounds() } else { m.geometric_bounds() };
if !typed.contains(&(Arc::as_ptr(n) as usize)) {
return own(&moved());
}
match &n.kind {
NodeKind::Text(t) => {
// Type in perspective: its outlines as its projection draws them.
let ink = match n.projection() {
None => type_ink(n, t, to * t.xf, None),
Some(h) => type_ink(n, t, t.xf, Some((h, to))),
}
.flatten();
let m = moved();
let Some(ink) = ink else { return own(&m) };
if !stroked {
return Some(ink);
}
let (Some(v), Some(g)) = (m.visual_bounds(), m.geometric_bounds()) else { return Some(ink) };
Some(Rect::new(
ink.x0 - (g.x0 - v.x0).max(0.0),
ink.y0 - (g.y0 - v.y0).max(0.0),
ink.x1 + (v.x1 - g.x1).max(0.0),
ink.y1 + (v.y1 - g.y1).max(0.0),
))
}
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
// A clipping path that paints strokes over the clip edge, as in `Node::visual_bounds`.
let clip = children.first()?;
content_box_in(clip, stroked && matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)), to, typed)
}
NodeKind::Layer { .. } | NodeKind::Group { .. } => {
let layer = matches!(n.kind, NodeKind::Layer { .. });
let members = n.editing_children().iter();
if stroked {
// The container's own strokes, scaled by `to` as `Node::transform` scales them,
// without copying its members.
let mut own_strokes = Node::new(n.id, NodeKind::Group { children: Vec::new(), clip: false });
own_strokes.appearance = n.appearance.clone();
own_strokes.transform(to, true);
own_strokes.visual_of_members(members.map(|c| content_box_in(c, true, to, typed)))
} else {
members.filter(|c| c.visible || !layer).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box_in(c, false, to, typed)))
}
}
_ => own(&moved()),
}
}
/// The [`EnvelopeOptions::bounds_margin`] of content with bounds `stroked` and `plain`. `None` for
/// content with no width or no height, or for margins an `f32` can't hold.
pub fn envelope_stroke_margin(stroked: Rect, plain: Rect) -> Option<[f32; 4]> {
@@ -265,20 +540,13 @@ pub struct EnvelopeMap<'a> {
}
impl<'a> EnvelopeMap<'a> {
/// The map of an envelope of `kind` around `content` with `frame` and `options`. `None` when
/// the content has no bounds.
/// The map of an envelope of `kind` around `content` with `frame` and `options`, from the
/// content's bounds with type measured by its glyph outlines ([`envelope_content_bounds`]).
/// `None` when the content has no bounds.
pub fn new(content: &[Arc<Node>], kind: &'a EnvelopeKind, frame: Affine, options: EnvelopeOptions) -> Option<Self> {
let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY };
let to_frame = frame.inverse();
let src = if frame == Affine::IDENTITY {
nodes_bounds(content)?
} else {
content.iter().try_fold(None, |acc, c| {
let mut m = (**c).clone();
m.transform(to_frame, false);
Some(vectorcraft_geom::union_opt(acc, m.geometric_bounds()))
})??
};
let src = content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, false, to_frame)))?;
let src = options.source_box(src);
Some(Self { src, frame, to_frame, surface: surface_of(kind, src, frame) })
}
@@ -378,3 +646,112 @@ pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine
EnvelopeKind::Warp { .. } => EnvelopeMap::new(content, kind, frame, options)?.bounds(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{CharStyle, TextObject};
/// Look `n` up in `kept`, measuring it (and counting the measures) when it isn't there.
fn look_up(kept: &mut TypeInks, n: &Arc<Node>, linear: Affine, measured: &mut usize) -> Option<Rect> {
let NodeKind::Text(t) = &n.kind else { panic!("type") };
match kept.find(n, t, &(linear, None), 0) {
Ok(ink) => ink,
Err(missing) => {
*measured += 1;
let ink = Some(Rect::new(0.0, 0.0, *measured as f64, 1.0));
kept.keep(n, *missing, ink);
ink
}
}
}
fn type_node(i: usize, text: &str) -> Arc<Node> {
let t = TextObject::point(Point::new(i as f64 * 10.0, 0.0), text, CharStyle::default());
Arc::new(Node::new(NodeId(i as u64), NodeKind::Text(Box::new(t))))
}
/// Many type objects are each measured once however often they are looked up; a moved or copied
/// object is found by its content, and a changed one or another transform is measured again.
#[test]
fn measured_type_is_kept_for_many_objects() {
let mut kept = TypeInks::default();
let mut measured = 0;
let nodes: Vec<_> = (0..200).map(|i| type_node(i, &format!("Type {i}"))).collect();
for _ in 0..5 {
for n in &nodes {
look_up(&mut kept, n, Affine::IDENTITY, &mut measured);
}
}
assert_eq!(measured, 200);
let mut moved = (*nodes[7]).clone();
moved.transform(Affine::translate((300.0, 40.0)), true);
let moved = Arc::new(moved);
assert_eq!(look_up(&mut kept, &moved, Affine::IDENTITY, &mut measured), look_up(&mut kept, &nodes[7], Affine::IDENTITY, &mut measured));
assert_eq!(measured, 200, "a moved object is found by its content");
let changed = type_node(7, "Changed");
look_up(&mut kept, &changed, Affine::IDENTITY, &mut measured);
assert_eq!(measured, 201, "a changed object is measured again");
look_up(&mut kept, &nodes[7], Affine::rotate(0.5), &mut measured);
assert_eq!(measured, 202, "another transform is measured again");
}
/// Type that differs only in what layout reads but files don't save (a first paragraph that
/// continues a thread, the resolved bounds of an inline graphic) is measured again.
#[test]
fn type_that_differs_in_what_is_not_saved_is_measured_again() {
let mut kept = TypeInks::default();
let mut measured = 0;
let mut t = TextObject::point(Point::ZERO, "Type", CharStyle::default());
t.runs.push(crate::TextRun::new("", CharStyle::default()));
t.runs[1].inline = Some(crate::InlineArt { symbol: "S".into(), scale: 1.0, baseline_shift: 0.0, bounds: None });
let node = |t: &TextObject| Arc::new(Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone()))));
look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured);
look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured);
assert_eq!(measured, 1, "a copy is found by its content");
t.first_para_continues = true;
look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured);
assert_eq!(measured, 2, "a first paragraph that continues a thread");
if let Some(inline) = &mut t.runs[1].inline {
inline.bounds = Some(Rect::new(0.0, 0.0, 10.0, 20.0));
}
look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured);
assert_eq!(measured, 3, "an inline graphic's bounds");
}
/// A stroked group of type in a scaled frame reaches past its members by its own strokes scaled
/// with the frame, as the group moved into the frame does; deeply nested groups are measured in
/// one walk each.
#[test]
fn a_stroked_group_of_type_is_measured_with_its_strokes_in_the_frame() {
let text = type_node(1, "Group");
let mut group = Node::group(NodeId(2), vec![text.clone()]);
group.appearance = crate::Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::solid(Color::BLACK), 4.0).into();
let group = Arc::new(group);
let to = Affine::scale(2.0);
let mut moved = (*group).clone();
moved.transform(to, true);
let want = moved.visual_of_members(std::iter::once(content_box(&text, true, to)));
assert!(want.is_some());
assert_eq!(content_box(&group, true, to), want);
let mut nested = group;
for i in 0..300 {
nested = Arc::new(Node::group(NodeId(10 + i), vec![nested]));
}
assert!(content_box(&nested, false, Affine::IDENTITY).is_some());
}
/// A table that fills up is emptied instead of growing, and what is looked up after that is
/// measured again.
#[test]
fn the_tables_stay_bounded() {
let mut kept = TypeInks::default();
let mut measured = 0;
let nodes: Vec<_> = (0..TYPE_INK_KEPT + 10).map(|i| type_node(i, &format!("Type {i}"))).collect();
for n in &nodes {
look_up(&mut kept, n, Affine::IDENTITY, &mut measured);
}
assert!(kept.content.len() <= TYPE_INK_KEPT && kept.nodes.len() <= TYPE_INK_KEPT, "{} {}", kept.content.len(), kept.nodes.len());
assert_eq!(measured, TYPE_INK_KEPT + 10);
}
}