mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-08 11:52:10 +00:00
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:
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ pub fn outline_object(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
/// Run `f` with the hooks live evaluation needs; symbols and patterns come from `doc` (without a
|
||||
/// document, symbol instances and pattern tiles stay as they are).
|
||||
fn with_hooks<R>(doc: Option<&Document>, f: impl FnOnce(Hooks) -> R) -> R {
|
||||
// Envelopes map type from its glyphs here too, without an engine session.
|
||||
vectorcraft_text::register_envelope_ink();
|
||||
let outline = |n: &Node| outline_object(doc, n);
|
||||
let tiles = |name: &str, xf: Affine, region: Rect| Some(doc?.pattern(name)?.instances_in(xf, region));
|
||||
f(Hooks { outline: Some(&outline), appearance: Some(&bake_appearance), pattern: Some(&tiles), symbols: doc })
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Live evaluation without an engine session makes envelopes measure type by its glyph outlines.
|
||||
//! One test in its own process, so nothing else has registered the measure before the evaluation.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{CharStyle, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
/// The largest distance between the edges of `a` and `b`.
|
||||
fn off(a: Rect, b: Rect) -> f64 {
|
||||
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_evaluation_measures_type_in_envelopes_by_its_glyphs() {
|
||||
let t = TextObject::point(Point::new(10.0, 60.0), "Type", CharStyle { size: 48.0, ..Default::default() });
|
||||
let n = Arc::new(Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone()))));
|
||||
let laid = n.geometric_bounds().unwrap();
|
||||
let ink = t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).ink_bounds().unwrap());
|
||||
assert!(laid.height() - ink.height() > 1.0, "the laid-out box {laid:?} reaches past the glyphs {ink:?}");
|
||||
let before = vectorcraft_doc::live::envelope_content_bounds(std::slice::from_ref(&n)).unwrap();
|
||||
assert!(off(before, laid) < 1e-6, "with nothing registered, type keeps its laid-out box: {before:?} vs {laid:?}");
|
||||
|
||||
let _ = vectorcraft_effects::expand_live(None, &n);
|
||||
let after = vectorcraft_doc::live::envelope_content_bounds(&[n]).unwrap();
|
||||
assert!(off(after, ink) < 1e-6, "after live evaluation, type is measured by its glyphs: {after:?} vs {ink:?}");
|
||||
}
|
||||
@@ -136,7 +136,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make with Warp…",
|
||||
["Object", "Envelope Distort"],
|
||||
Some("Cmd+Alt+Shift+W"),
|
||||
"{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected) → {id}",
|
||||
"{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected; type maps from its glyph outline bounds) → {id}",
|
||||
has_selection,
|
||||
env_make_warp
|
||||
),
|
||||
@@ -145,7 +145,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make with Mesh…",
|
||||
["Object", "Envelope Distort"],
|
||||
Some("Cmd+Alt+M"),
|
||||
"{ids?, rows?: 1..50 (4), cols?: 1..50 (4), columns?: as cols} envelope the selected objects with a mesh of rows × cols patches → {id}",
|
||||
"{ids?, rows?: 1..50 (4), cols?: 1..50 (4), columns?: as cols} envelope the selected objects with a mesh of rows × cols patches over their bounds (type measured by its glyph outlines) → {id}",
|
||||
has_selection,
|
||||
env_make_mesh
|
||||
),
|
||||
@@ -154,7 +154,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make with Top Object",
|
||||
["Object", "Envelope Distort"],
|
||||
Some("Cmd+Alt+C"),
|
||||
"{ids?} the topmost selected path becomes the envelope of the others → {id}",
|
||||
"{ids?} the topmost selected path becomes the envelope of the others (type maps from its glyph outline bounds) → {id}",
|
||||
has_multi,
|
||||
env_make_top
|
||||
),
|
||||
@@ -172,7 +172,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reset with Mesh…",
|
||||
["Object", "Envelope Distort"],
|
||||
None,
|
||||
"{rows?: 1..50, cols?: 1..50 (unset: a mesh envelope's own, else 4), maintainShape?: bool (true: the mesh follows the current envelope shape; false: a flat grid over the content), columns?: as cols} make the selected envelopes mesh envelopes, keeping their content",
|
||||
"{rows?: 1..50, cols?: 1..50 (unset: a mesh envelope's own, else 4), maintainShape?: bool (true: the mesh follows the current envelope shape; false: a flat grid over the content, type measured by its glyph outlines), columns?: as cols} make the selected envelopes mesh envelopes, keeping their content",
|
||||
has_envelope,
|
||||
env_reset_mesh
|
||||
),
|
||||
|
||||
@@ -84,7 +84,7 @@ pub(super) fn env_make_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = roots_param(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let nodes: Vec<Arc<Node>> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).map(Arc::new).collect();
|
||||
let src = live::nodes_bounds(&nodes).ok_or_else(|| bad(C, "selection has no bounds"))?;
|
||||
let src = live::envelope_content_bounds(&nodes).ok_or_else(|| bad(C, "selection has no bounds"))?;
|
||||
let kind = EnvelopeKind::Mesh { rows, cols, points: live::grid_points(src, rows, cols), handles: vec![] };
|
||||
let defaults = envelope_defaults(s);
|
||||
let id = s.edit("Make Envelope", |d, sel| wrap(d, sel, &roots, |id, content| envelope(id, content, kind, defaults)))?;
|
||||
|
||||
@@ -25,6 +25,8 @@ impl Session {
|
||||
vectorcraft_doc::hit::set_live_art(vectorcraft_render::effects::expand_live, || {
|
||||
vectorcraft_text::FontDb::global().generation().rotate_left(32) ^ vectorcraft_render::effects::plugin_revision()
|
||||
});
|
||||
// Envelopes map type from the bounds of its glyph outlines, underlines and strikethroughs.
|
||||
vectorcraft_text::register_envelope_ink();
|
||||
// Exports bake Vector Halftone into the dots the renderer samples.
|
||||
vectorcraft_render::effects::addons::halftone::set_art(vectorcraft_render::addons::halftone::art);
|
||||
// OpenType SVG glyphs are drawn as the SVG importer reads their documents.
|
||||
|
||||
@@ -445,3 +445,470 @@ fn warped_type_in_a_symbol_reopens_unchanged() {
|
||||
let differ = (0..300).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| before.pixel(x, y) != after.pixel(x, y)).count();
|
||||
assert_eq!(differ, 0, "{differ} pixels changed on reopening");
|
||||
}
|
||||
|
||||
/// The glyph outline bounds of type `id` in document space.
|
||||
fn ink_of(s: &Session, id: NodeId) -> Rect {
|
||||
match node(s, id).kind {
|
||||
NodeKind::Text(t) => t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).ink_bounds().unwrap()),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_box(a: Rect, b: Rect, what: &str) {
|
||||
let d = [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
|
||||
assert!(d < 1e-6, "{what}: {a:?} vs {b:?}");
|
||||
}
|
||||
|
||||
/// The box envelope `id` maps its content from.
|
||||
fn src_of(s: &Session, id: NodeId) -> Rect {
|
||||
live::EnvelopeMap::of(&node(s, id)).unwrap().src()
|
||||
}
|
||||
|
||||
/// Envelopes map point type, area type and type in a group from the glyph outline bounds, not
|
||||
/// from the laid-out box (ascent to descent, or the area's frame): with a flat warp the distorted
|
||||
/// type sits where the type was, and Make with Mesh lays its grid over the glyphs.
|
||||
#[test]
|
||||
fn envelopes_map_type_from_its_glyph_outline_bounds() {
|
||||
let mut s = session();
|
||||
let point = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Envelope", "size": 48})).unwrap());
|
||||
let area = id_of(
|
||||
&s.execute("text.create", &json!({"x": 100, "y": 300, "text": "Area type", "size": 36, "area": {"width": 300, "height": 200}})).unwrap(),
|
||||
);
|
||||
let grouped = id_of(&s.execute("text.create", &json!({"x": 450, "y": 200, "text": "Group", "size": 40})).unwrap());
|
||||
sel(&mut s, &[grouped]);
|
||||
let group = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
for (t, target) in [(point, point), (area, area), (grouped, group)] {
|
||||
let (ink, laid) = (ink_of(&s, t), node(&s, t).geometric_bounds().unwrap());
|
||||
assert!(laid.height() - ink.height() > 1.0, "the laid-out box {laid:?} reaches past the glyphs {ink:?}");
|
||||
let (stroked, plain) = live::envelope_content_boxes(&[std::sync::Arc::new(node(&s, target))], Affine::IDENTITY).unwrap();
|
||||
assert_box(plain, ink, "geometric box");
|
||||
assert_box(stroked, ink, "box with strokes (none)");
|
||||
sel(&mut s, &[target]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap());
|
||||
assert_box(src_of(&s, e), ink, "warp");
|
||||
assert_box(node(&s, e).geometric_bounds().unwrap(), ink, "flat warp's bounds");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let e = id_of(&s.execute("object.envelope.makeWithMesh", &json!({"rows": 2, "cols": 2})).unwrap());
|
||||
let EnvelopeKind::Mesh { points, .. } = kind(&s, e) else { panic!("mesh") };
|
||||
let grid = points.iter().fold(Rect::from_points(points[0], points[0]), |r, p| r.union_pt(*p));
|
||||
assert_box(grid, ink, "mesh grid");
|
||||
assert_box(src_of(&s, e), ink, "mesh");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Shrink Text to Fit lays type out by its size on the page: after the envelope is scaled, it
|
||||
/// maps from the glyphs of the type laid out at its new size, not those measured before.
|
||||
#[test]
|
||||
fn a_scaled_envelope_of_shrunk_type_maps_from_its_new_glyphs() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type", "size": 2, "area": {"width": 3, "height": 3}})).unwrap());
|
||||
s.edit("shrink", |d, _| {
|
||||
if let NodeKind::Text(tx) = &mut d.node_mut(t).unwrap().kind {
|
||||
tx.area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 10.0 };
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
sel(&mut s, &[t]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap());
|
||||
let text = |s: &Session| match node(s, e).kind {
|
||||
NodeKind::Envelope { content, .. } => match &content[0].kind {
|
||||
NodeKind::Text(tx) => tx.clone(),
|
||||
k => panic!("{k:?}"),
|
||||
},
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
let ink = |tx: &vectorcraft_doc::TextObject| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx).ink_bounds().unwrap();
|
||||
let before = text(&s);
|
||||
assert_box(src_of(&s, e), before.xf.transform_rect_bbox(ink(&before)), "before scaling");
|
||||
s.execute("object.scale", &json!({"sx": 150, "sy": 150, "origin": [100, 100]})).unwrap();
|
||||
let after = text(&s);
|
||||
let fresh = after.xf.transform_rect_bbox(ink(&after));
|
||||
let stale = after.xf.transform_rect_bbox(ink(&before));
|
||||
assert!((fresh.width() - stale.width()).abs() > 1e-3 || (fresh.height() - stale.height()).abs() > 1e-3, "{fresh:?} vs {stale:?}");
|
||||
assert_box(src_of(&s, e), fresh, "after scaling");
|
||||
}
|
||||
|
||||
/// A rotated envelope keeps a frame of its own: shrunk type in it is measured as it is laid out on
|
||||
/// the page, and its box is then taken into the frame.
|
||||
#[test]
|
||||
fn a_rotated_envelope_of_shrunk_type_maps_from_the_glyphs_on_the_page() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type", "size": 2, "area": {"width": 3, "height": 3}})).unwrap());
|
||||
s.edit("shrink", |d, _| {
|
||||
if let NodeKind::Text(tx) = &mut d.node_mut(t).unwrap().kind {
|
||||
tx.area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 10.0 };
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
sel(&mut s, &[t]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap());
|
||||
s.execute("object.rotate", &json!({"angle": 30, "origin": [100, 100]})).unwrap();
|
||||
s.execute("object.scale", &json!({"sx": 150, "sy": 150, "origin": [100, 100]})).unwrap();
|
||||
let (frame, tx) = match node(&s, e).kind {
|
||||
NodeKind::Envelope { frame, content, .. } => match &content[0].kind {
|
||||
NodeKind::Text(tx) => (*frame, tx.clone()),
|
||||
k => panic!("{k:?}"),
|
||||
},
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
assert!(frame != Affine::IDENTITY, "a rotated envelope has a frame");
|
||||
let ink = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx).ink_bounds().unwrap();
|
||||
assert_box(src_of(&s, e), (frame.inverse() * tx.xf).transform_rect_bbox(ink), "in the frame");
|
||||
}
|
||||
|
||||
/// The bars of underlined type are part of what it draws, so the envelope maps from them too and
|
||||
/// its bounds take them in.
|
||||
#[test]
|
||||
fn an_envelope_maps_underlined_type_from_its_glyphs_and_bars() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "HH", "size": 100})).unwrap());
|
||||
s.execute("text.setFormat", &json!({"ids": [t.0], "underline": true})).unwrap();
|
||||
let glyphs = ink_of(&s, t);
|
||||
sel(&mut s, &[t]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap());
|
||||
let src = src_of(&s, e);
|
||||
assert!(src.y1 > glyphs.y1 + 1.0, "the underline below the glyphs {glyphs:?} is in the box {src:?}");
|
||||
assert_box(node(&s, e).geometric_bounds().unwrap(), src, "flat warp's bounds");
|
||||
let drawn = vectorcraft_render::effects::expanded_live_group(Some(&s.doc().unwrap().doc), &node(&s, e)).geometric_bounds().unwrap();
|
||||
let off = [drawn.x0 - src.x0, drawn.y0 - src.y0, drawn.x1 - src.x1, drawn.y1 - src.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
|
||||
assert!(off < 0.05, "the drawn art {drawn:?} fills the box {src:?}");
|
||||
}
|
||||
|
||||
/// Undo of Make Envelope gives the type back with its laid-out box, and redo maps it from its
|
||||
/// glyphs again.
|
||||
#[test]
|
||||
fn undo_and_redo_of_an_envelope_of_type_keep_the_glyph_mapping() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Undo", "size": 60})).unwrap());
|
||||
let (ink, laid) = (ink_of(&s, t), node(&s, t).geometric_bounds().unwrap());
|
||||
sel(&mut s, &[t]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "bulge", "bend": 30})).unwrap());
|
||||
let made = node(&s, e).geometric_bounds().unwrap();
|
||||
assert_box(src_of(&s, e), ink, "made");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(e).is_none());
|
||||
assert_box(node(&s, t).geometric_bounds().unwrap(), laid, "the type after undo");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_box(src_of(&s, e), ink, "redone");
|
||||
assert_box(node(&s, e).geometric_bounds().unwrap(), made, "redone bounds");
|
||||
}
|
||||
|
||||
/// A saved envelope of type reopens drawn by the same rule: from the glyph outline bounds, which
|
||||
/// the file does not store.
|
||||
#[test]
|
||||
fn a_saved_envelope_of_type_reopens_mapped_from_its_glyphs() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Saved", "size": 72})).unwrap());
|
||||
let ink = ink_of(&s, t);
|
||||
sel(&mut s, &[t]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "flag", "bend": 40})).unwrap());
|
||||
let render = |s: &Session| vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 600, 400, Affine::IDENTITY, &Default::default());
|
||||
let before = render(&s);
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-envelope-ink-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("type.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": path})).unwrap();
|
||||
let mut o = Session::new();
|
||||
o.execute("document.open", &json!({"path": path})).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert_box(src_of(&o, e), ink, "reopened");
|
||||
let after = render(&o);
|
||||
let differ = (0..400).flat_map(|y| (0..600).map(move |x| (x, y))).filter(|&(x, y)| before.pixel(x, y) != after.pixel(x, y)).count();
|
||||
assert_eq!(differ, 0, "{differ} pixels changed on reopening");
|
||||
}
|
||||
|
||||
/// The type of envelope `e`'s first content object.
|
||||
fn content_text(s: &Session, e: NodeId) -> vectorcraft_doc::TextObject {
|
||||
match node(s, e).kind {
|
||||
NodeKind::Envelope { content, .. } => match &content[0].kind {
|
||||
NodeKind::Text(tx) => (**tx).clone(),
|
||||
k => panic!("{k:?}"),
|
||||
},
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The box of `tx`'s glyph outlines with text space mapped by `m`, each outline mapped point by point.
|
||||
fn outlines_in(tx: &vectorcraft_doc::TextObject, m: Affine) -> Rect {
|
||||
let path = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx).to_bezpath();
|
||||
kurbo::Shape::bounding_box(&(m * path))
|
||||
}
|
||||
|
||||
/// Rotated type maps from the box of its rotated glyph outlines, which is smaller than the box of
|
||||
/// its rotated glyph box; in a rotated envelope whose frame turns by another angle than the type,
|
||||
/// it maps from the outlines taken into the frame.
|
||||
#[test]
|
||||
fn an_envelope_of_rotated_type_maps_from_its_rotated_outlines() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 200, "y": 300, "text": "Rotated", "size": 60})).unwrap());
|
||||
sel(&mut s, &[t]);
|
||||
s.execute("object.rotate", &json!({"angle": 40, "origin": [200, 300]})).unwrap();
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap());
|
||||
let tx = content_text(&s, e);
|
||||
let outlines = outlines_in(&tx, tx.xf);
|
||||
let ink = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx).ink_bounds().unwrap();
|
||||
let glyph_box = tx.xf.transform_rect_bbox(ink);
|
||||
assert!(glyph_box.width() - outlines.width() > 1.0 || glyph_box.height() - outlines.height() > 1.0, "{glyph_box:?} vs {outlines:?}");
|
||||
assert_box(src_of(&s, e), outlines, "rotated type");
|
||||
s.execute("object.rotate", &json!({"angle": 15, "origin": [200, 300]})).unwrap();
|
||||
let (frame, tx) = match node(&s, e).kind {
|
||||
NodeKind::Envelope { frame, .. } => (*frame, content_text(&s, e)),
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
assert!(frame != Affine::IDENTITY, "a rotated envelope has a frame");
|
||||
let in_frame = frame.inverse() * tx.xf;
|
||||
assert!(in_frame.as_coeffs()[1].abs() > 0.1, "the type is turned against the frame: {in_frame:?}");
|
||||
assert_box(src_of(&s, e), outlines_in(&tx, in_frame), "in the frame");
|
||||
}
|
||||
|
||||
/// Type in perspective maps from the box of its outlines as its projection draws them, which is
|
||||
/// smaller than the projected box of its glyphs. Where the projection can't map a point (past its
|
||||
/// horizon), the point counts where it is, as the renderer draws it.
|
||||
#[test]
|
||||
fn an_envelope_of_type_in_perspective_maps_from_its_projected_outlines() {
|
||||
use vectorcraft_geom::Homography;
|
||||
// Every point of the type is past this one's horizon.
|
||||
let horizon = Homography([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0]]);
|
||||
for (h, crosses) in [(Homography([[1.0, 0.3, 0.0], [0.1, 1.0, 0.0], [0.0015, 0.0008, 1.0]]), false), (horizon, true)] {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Perspective", "size": 60})).unwrap());
|
||||
s.edit("perspective", |d, _| {
|
||||
let n = d.node_mut(t).unwrap();
|
||||
n.extras_mut().perspective = Some(Box::new(vectorcraft_doc::PerspectiveAttachment {
|
||||
projection: Some(h.to_array()),
|
||||
..vectorcraft_doc::PerspectiveAttachment::new("left", 0.0)
|
||||
}));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(node(&s, t).projection().is_some());
|
||||
let NodeKind::Text(tx) = node(&s, t).kind else { panic!("type") };
|
||||
let path = tx.xf * vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx).to_bezpath();
|
||||
let (mut drawn, mut unmapped): (Option<Rect>, usize) = (None, 0);
|
||||
kurbo::flatten(&path, 0.001, |el| {
|
||||
if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el {
|
||||
let q = h.apply(p).unwrap_or_else(|| {
|
||||
unmapped += 1;
|
||||
p
|
||||
});
|
||||
drawn = Some(drawn.map_or(Rect::from_points(q, q), |r| r.union_pt(q)));
|
||||
}
|
||||
});
|
||||
let drawn = drawn.unwrap();
|
||||
assert_eq!(unmapped > 0, crosses, "{unmapped} points past the horizon");
|
||||
if !crosses {
|
||||
let ink = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx).ink_bounds().unwrap();
|
||||
let boxed = h.map_rect_bbox(tx.xf.transform_rect_bbox(ink)).unwrap();
|
||||
assert!(boxed.width() - drawn.width() > 1.0 || boxed.height() - drawn.height() > 1.0, "{boxed:?} vs {drawn:?}");
|
||||
}
|
||||
sel(&mut s, &[t]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap());
|
||||
let src = src_of(&s, e);
|
||||
let off = [src.x0 - drawn.x0, src.y0 - drawn.y0, src.x1 - drawn.x1, src.y1 - drawn.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
|
||||
assert!(off < 0.05, "{src:?} vs {drawn:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Type that draws nothing (only spaces) keeps its laid-out box.
|
||||
#[test]
|
||||
fn an_envelope_of_type_that_draws_nothing_maps_from_its_laid_out_box() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": " ", "size": 48})).unwrap());
|
||||
let laid = node(&s, t).geometric_bounds().unwrap();
|
||||
assert_box(live::envelope_content_bounds(&[std::sync::Arc::new(node(&s, t))]).unwrap(), laid, "spaces");
|
||||
sel(&mut s, &[t]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap());
|
||||
assert_box(src_of(&s, e), laid, "warp");
|
||||
}
|
||||
|
||||
/// The strikethrough bar runs to the last glyph's advance, past its outline: the envelope maps
|
||||
/// from it too.
|
||||
#[test]
|
||||
fn an_envelope_maps_struck_through_type_from_its_glyphs_and_bars() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "HH", "size": 100})).unwrap());
|
||||
let plain = ink_of(&s, t);
|
||||
s.execute("text.setFormat", &json!({"ids": [t.0], "strikethrough": true})).unwrap();
|
||||
let NodeKind::Text(tx) = node(&s, t).kind else { panic!("type") };
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let laid = vectorcraft_text::layout(db, &tx);
|
||||
let bars = vectorcraft_text::decorations(&laid, db, &tx);
|
||||
assert!(!bars.is_empty(), "struck-through type has a bar");
|
||||
let bar = tx.xf.transform_rect_bbox(kurbo::Shape::bounding_box(&bars[0].1));
|
||||
assert!(bar.x1 > plain.x1 + 1.0, "the bar {bar:?} reaches past the glyphs {plain:?}");
|
||||
sel(&mut s, &[t]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap());
|
||||
assert_box(src_of(&s, e), plain.union(bar), "glyphs and bar");
|
||||
}
|
||||
|
||||
/// In a layer, the visible type is measured by its glyphs and hidden type isn't measured; in a
|
||||
/// clip group whose clipping path is type, the envelope maps from that type's glyphs.
|
||||
#[test]
|
||||
fn type_in_layers_and_clip_groups_is_measured_by_its_glyphs() {
|
||||
let mut s = session();
|
||||
let shown = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Shown", "size": 48})).unwrap());
|
||||
let hidden = id_of(&s.execute("text.create", &json!({"x": 100, "y": 500, "text": "Hidden", "size": 48})).unwrap());
|
||||
sel(&mut s, &[hidden]);
|
||||
s.execute("object.hide", &json!({})).unwrap();
|
||||
let ink = ink_of(&s, shown);
|
||||
let layer = s.doc().unwrap().doc.layers[0].clone();
|
||||
assert_box(live::envelope_content_bounds(&[layer]).unwrap(), ink, "layer");
|
||||
|
||||
let mut s = session();
|
||||
let back = rect(&mut s, 50.0, 100.0, 500.0, 200.0);
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Clip", "size": 72})).unwrap());
|
||||
let ink = ink_of(&s, t);
|
||||
sel(&mut s, &[back, t]);
|
||||
let g = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap());
|
||||
assert!(matches!(node(&s, g).kind, NodeKind::Group { clip: true, .. }));
|
||||
let (stroked, plain) = live::envelope_content_boxes(&[std::sync::Arc::new(node(&s, g))], Affine::IDENTITY).unwrap();
|
||||
assert_box(plain, ink, "clip group");
|
||||
assert_box(stroked, ink, "clip group with strokes (none)");
|
||||
sel(&mut s, &[g]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap());
|
||||
assert_box(src_of(&s, e), ink, "warp of the clip group");
|
||||
}
|
||||
|
||||
/// Stroked type: the box with strokes reaches past the glyphs as far as the type's strokes reach
|
||||
/// past its laid-out box. In perspective, the glyphs are the projected outlines and the reach is
|
||||
/// that of the strokes past the projected laid-out box.
|
||||
#[test]
|
||||
fn stroked_type_maps_from_its_glyphs_grown_by_its_strokes() {
|
||||
let h = vectorcraft_geom::Homography([[1.0, 0.3, 0.0], [0.1, 1.0, 0.0], [0.0015, 0.0008, 1.0]]);
|
||||
for perspective in [false, true] {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Stroke", "size": 60})).unwrap());
|
||||
sel(&mut s, &[t]);
|
||||
s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 6})).unwrap();
|
||||
if perspective {
|
||||
s.edit("perspective", |d, _| {
|
||||
d.node_mut(t).unwrap().extras_mut().perspective = Some(Box::new(vectorcraft_doc::PerspectiveAttachment {
|
||||
projection: Some(h.to_array()),
|
||||
..vectorcraft_doc::PerspectiveAttachment::new("left", 0.0)
|
||||
}));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let n = node(&s, t);
|
||||
assert_eq!(n.projection().is_some(), perspective);
|
||||
let (v, g) = (n.visual_bounds().unwrap(), n.geometric_bounds().unwrap());
|
||||
assert!(v.x0 < g.x0 - 1.0, "the strokes {v:?} reach past the laid-out box {g:?}");
|
||||
let NodeKind::Text(tx) = &n.kind else { panic!("type") };
|
||||
// The glyph outlines as drawn.
|
||||
let ink = if perspective {
|
||||
let path = tx.xf * vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx).to_bezpath();
|
||||
let mut drawn: Option<Rect> = None;
|
||||
kurbo::flatten(&path, 0.001, |el| {
|
||||
if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el {
|
||||
let q = h.apply(p).unwrap();
|
||||
drawn = Some(drawn.map_or(Rect::from_points(q, q), |r| r.union_pt(q)));
|
||||
}
|
||||
});
|
||||
drawn.unwrap()
|
||||
} else {
|
||||
ink_of(&s, t)
|
||||
};
|
||||
let (stroked, plain) = live::envelope_content_boxes(&[std::sync::Arc::new(n.clone())], Affine::IDENTITY).unwrap();
|
||||
let near = |a: Rect, b: Rect, what: &str| {
|
||||
let d = [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
|
||||
assert!(d < 0.05, "{what} (perspective {perspective}): {a:?} vs {b:?}");
|
||||
};
|
||||
near(plain, ink, "geometric box");
|
||||
near(stroked, Rect::new(ink.x0 - (g.x0 - v.x0), ink.y0 - (g.y0 - v.y0), ink.x1 + (v.x1 - g.x1), ink.y1 + (v.y1 - g.y1)), "with strokes");
|
||||
}
|
||||
}
|
||||
|
||||
/// Make with Top Object maps type from its glyphs onto the path's outline.
|
||||
#[test]
|
||||
fn a_top_object_envelope_of_type_maps_from_its_glyphs() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Top", "size": 72})).unwrap());
|
||||
let ink = ink_of(&s, t);
|
||||
let top = rect(&mut s, 80.0, 100.0, 300.0, 150.0);
|
||||
sel(&mut s, &[t, top]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithTopObject", &json!({})).unwrap());
|
||||
assert!(matches!(kind(&s, e), EnvelopeKind::TopObject { .. }));
|
||||
assert_box(src_of(&s, e), ink, "top object");
|
||||
}
|
||||
|
||||
/// Type in a symbol instance is measured as before: the envelope maps from the instance's own
|
||||
/// bounds, not from the type's glyphs.
|
||||
#[test]
|
||||
fn an_envelope_of_a_symbol_instance_of_type_maps_from_the_instance_s_bounds() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Symbol", "size": 60})).unwrap());
|
||||
let ink = ink_of(&s, t);
|
||||
sel(&mut s, &[t]);
|
||||
s.execute("symbol.new", &json!({"name": "Type"})).unwrap();
|
||||
let inst = s.doc().unwrap().selection.objects[0];
|
||||
assert!(matches!(node(&s, inst).kind, NodeKind::SymbolInstance { .. }));
|
||||
let own = node(&s, inst).geometric_bounds().unwrap();
|
||||
assert!((own.height() - ink.height()).abs() > 1.0, "{own:?} vs {ink:?}");
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap());
|
||||
assert_box(src_of(&s, e), own, "symbol instance");
|
||||
}
|
||||
|
||||
/// Type inside a blend or a repeat is measured as before: the envelope maps from the object's own
|
||||
/// bounds, not from the type's glyphs.
|
||||
#[test]
|
||||
fn an_envelope_of_a_blend_or_repeat_of_type_maps_from_its_own_bounds() {
|
||||
for repeat in [false, true] {
|
||||
let mut s = session();
|
||||
let a = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Blend", "size": 60})).unwrap());
|
||||
let mut parts = vec![a];
|
||||
if !repeat {
|
||||
parts.push(id_of(&s.execute("text.create", &json!({"x": 300, "y": 400, "text": "Blend", "size": 60})).unwrap()));
|
||||
}
|
||||
let ink = parts.iter().map(|t| ink_of(&s, *t)).reduce(|a, b| a.union(b)).unwrap();
|
||||
sel(&mut s, &parts);
|
||||
let made = if repeat { "object.repeat.grid" } else { "object.blend.make" };
|
||||
let holder = id_of(&s.execute(made, &json!({})).unwrap());
|
||||
let own = node(&s, holder).geometric_bounds().unwrap();
|
||||
let measured = live::envelope_content_bounds(&[std::sync::Arc::new(node(&s, holder))]).unwrap();
|
||||
assert_box(measured, own, made);
|
||||
assert!((own.height() - ink.height()).abs() > 1.0 || (own.width() - ink.width()).abs() > 1.0, "{made}: {own:?} vs {ink:?}");
|
||||
sel(&mut s, &[holder]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap());
|
||||
assert_box(src_of(&s, e), own, made);
|
||||
}
|
||||
}
|
||||
|
||||
/// An inline graphic counts by its box: the envelope maps from the glyph outlines and the box of
|
||||
/// the graphic, which reaches past the glyphs.
|
||||
#[test]
|
||||
fn an_envelope_maps_type_with_an_inline_graphic_from_its_glyphs_and_the_graphic() {
|
||||
let mut s = session();
|
||||
s.execute("shape.ellipse", &json!({"x": 600, "y": 500, "width": 40, "height": 40})).unwrap();
|
||||
s.execute("symbol.new", &json!({"name": "Dot"})).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "xx", "size": 40})).unwrap());
|
||||
s.execute("text.insertInline", &json!({"id": t.0, "at": 1, "symbol": "Dot"})).unwrap();
|
||||
let NodeKind::Text(tx) = node(&s, t).kind else { panic!("type") };
|
||||
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx);
|
||||
let [inline] = laid.inlines.as_slice() else { panic!("one inline graphic: {}", laid.inlines.len()) };
|
||||
let graphic = tx.xf.transform_rect_bbox(inline.bounds);
|
||||
let glyphs = outlines_in(&tx, tx.xf);
|
||||
assert!(graphic.y0 < glyphs.y0 - 1.0, "the graphic {graphic:?} reaches above the glyphs {glyphs:?}");
|
||||
sel(&mut s, &[t]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap());
|
||||
assert_box(src_of(&s, e), glyphs.union(graphic), "glyphs and graphic");
|
||||
}
|
||||
|
||||
/// Reset with Mesh without Maintain Shape lays its flat grid over the glyphs.
|
||||
#[test]
|
||||
fn reset_with_mesh_lays_a_flat_grid_over_the_glyphs() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Reset", "size": 72})).unwrap());
|
||||
let ink = ink_of(&s, t);
|
||||
sel(&mut s, &[t]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "bulge", "bend": 40})).unwrap());
|
||||
s.execute("object.envelope.resetWithMesh", &json!({"rows": 2, "cols": 3, "maintainShape": false})).unwrap();
|
||||
let EnvelopeKind::Mesh { points, .. } = kind(&s, e) else { panic!("mesh") };
|
||||
let grid = points.iter().fold(Rect::from_points(points[0], points[0]), |r, p| r.union_pt(*p));
|
||||
assert_box(grid, ink, "flat grid");
|
||||
}
|
||||
|
||||
@@ -639,6 +639,9 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
|
||||
fn new(data: &'a [u8]) -> Self {
|
||||
// An envelope's saved margins and its check against its drawn art measure type by its
|
||||
// glyphs, as the app maps it.
|
||||
vectorcraft_text::register_envelope_ink();
|
||||
let mut doc = Document::new(1.0, 1.0);
|
||||
doc.layers.clear();
|
||||
Self {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Reading an `.ai` file makes envelopes measure type by its glyph outlines, as the app does. One
|
||||
//! test in its own process, so nothing else has registered the measure before the file is read.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{CharStyle, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
/// The largest distance between the edges of `a` and `b`.
|
||||
fn off(a: Rect, b: Rect) -> f64 {
|
||||
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_an_ai_file_measures_type_in_envelopes_by_its_glyphs() {
|
||||
let t = TextObject::point(Point::new(10.0, 60.0), "Type", CharStyle { size: 48.0, ..Default::default() });
|
||||
let n = Arc::new(Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone()))));
|
||||
let laid = n.geometric_bounds().unwrap();
|
||||
let ink = t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).ink_bounds().unwrap());
|
||||
assert!(laid.height() - ink.height() > 1.0, "the laid-out box {laid:?} reaches past the glyphs {ink:?}");
|
||||
let before = vectorcraft_doc::live::envelope_content_bounds(std::slice::from_ref(&n)).unwrap();
|
||||
assert!(off(before, laid) < 1e-6, "with nothing registered, type keeps its laid-out box: {before:?} vs {laid:?}");
|
||||
|
||||
let file = "%!PS-Adobe-3.0\n%%Creator: test\n%AI5_FileFormat 3.0\n%AI3_Cropmarks: 0 0 200 100\n%%EndComments\n%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\nLB\n%AI5_EndLayer--\n%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n";
|
||||
vectorcraft_eps::import(file.as_bytes()).unwrap();
|
||||
let after = vectorcraft_doc::live::envelope_content_bounds(&[n]).unwrap();
|
||||
assert!(off(after, ink) < 1e-6, "after reading, type is measured by its glyphs: {after:?} vs {ink:?}");
|
||||
}
|
||||
@@ -31,6 +31,8 @@ pub(crate) use paint::{CROP_STROKES, crop_line};
|
||||
|
||||
impl Renderer {
|
||||
pub fn new() -> Self {
|
||||
// Envelopes map type from its glyphs here too, without an engine session.
|
||||
vectorcraft_text::register_envelope_ink();
|
||||
Self {
|
||||
texts: FrameCache::new(lru::TEXT_BUDGET, 1024),
|
||||
text_ids: PtrMap::default(),
|
||||
|
||||
@@ -320,3 +320,35 @@ fn enveloped_type_under_effects_redraws_when_its_font_becomes_available() {
|
||||
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "{what}: as a new renderer draws it");
|
||||
}
|
||||
}
|
||||
|
||||
/// Without an engine session, a renderer maps an envelope of type from its glyph outline bounds,
|
||||
/// and measures the type again when its font becomes available.
|
||||
#[test]
|
||||
fn a_renderer_maps_enveloped_type_from_its_glyphs_in_the_current_font() {
|
||||
let _adding = adding_fonts();
|
||||
const FAMILY: &str = "Mapped Serif 4";
|
||||
let style = CharStyle { size: 50.0, font_family: FAMILY.into(), ..Default::default() };
|
||||
let t = TextObject::point(Point::new(10.0, 60.0), "Hamburg", style);
|
||||
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
|
||||
let envelope = Node::new(
|
||||
vectorcraft_doc::NodeId(1),
|
||||
NodeKind::Envelope {
|
||||
content: vec![Arc::new(Node::new(vectorcraft_doc::NodeId(2), NodeKind::Text(Box::new(t.clone()))))],
|
||||
kind: Box::new(kind),
|
||||
fidelity: 50.0,
|
||||
editing: false,
|
||||
options: Default::default(),
|
||||
frame: Box::new(Affine::IDENTITY),
|
||||
},
|
||||
);
|
||||
let ink = || t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).ink_bounds().unwrap());
|
||||
let src = || vectorcraft_doc::live::EnvelopeMap::of(&envelope).unwrap().src();
|
||||
let off = |a: Rect, b: Rect| [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
|
||||
let _ = Renderer::new();
|
||||
let before = ink();
|
||||
assert!(off(src(), before) < 1e-6, "{:?} vs {before:?}", src());
|
||||
assert!(vectorcraft_text::FontDb::global().add_font(serif_as(FAMILY)) > 0);
|
||||
let after = ink();
|
||||
assert!(off(before, after) > 0.5, "the type draws in the font that arrived: {before:?} vs {after:?}");
|
||||
assert!(off(src(), after) < 1e-6, "{:?} vs {after:?}", src());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//! A renderer made without an engine session makes envelopes measure type by its glyph outlines.
|
||||
//! One test in its own process, so nothing else has registered the measure before the renderer is
|
||||
//! made.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{CharStyle, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
/// The largest distance between the edges of `a` and `b`.
|
||||
fn off(a: Rect, b: Rect) -> f64 {
|
||||
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_renderer_measures_type_in_envelopes_by_its_glyphs() {
|
||||
let t = TextObject::point(Point::new(10.0, 60.0), "Type", CharStyle { size: 48.0, ..Default::default() });
|
||||
let n = Arc::new(Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone()))));
|
||||
let laid = n.geometric_bounds().unwrap();
|
||||
let ink = t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).ink_bounds().unwrap());
|
||||
assert!(laid.height() - ink.height() > 1.0, "the laid-out box {laid:?} reaches past the glyphs {ink:?}");
|
||||
let before = vectorcraft_doc::live::envelope_content_bounds(std::slice::from_ref(&n)).unwrap();
|
||||
assert!(off(before, laid) < 1e-6, "with nothing registered, type keeps its laid-out box: {before:?} vs {laid:?}");
|
||||
|
||||
let _ = vectorcraft_render::Renderer::new();
|
||||
let after = vectorcraft_doc::live::envelope_content_bounds(&[n]).unwrap();
|
||||
assert!(off(after, ink) < 1e-6, "after a renderer is made, type is measured by its glyphs: {after:?} vs {ink:?}");
|
||||
}
|
||||
@@ -501,12 +501,17 @@ mod tests {
|
||||
assert_eq!(hyphen_points_with("language", &opts(&|o| o.capitalized = false)), vec![3]);
|
||||
}
|
||||
|
||||
/// Process-wide store used by `prefs.set` / restore.
|
||||
/// Process-wide store used by `prefs.set` / restore. Each change makes envelopes measure type
|
||||
/// again.
|
||||
#[test]
|
||||
fn set_hyphenation_exceptions_round_trips_the_source_string() {
|
||||
let inputs = crate::layout::envelope_ink_inputs();
|
||||
set_hyphenation_exceptions("typography, hap-pen");
|
||||
assert_eq!(hyphenation_exceptions(), "typography, hap-pen");
|
||||
let changed = crate::layout::envelope_ink_inputs();
|
||||
assert_ne!(changed, inputs, "the envelope inputs change with the exceptions");
|
||||
set_hyphenation_exceptions("");
|
||||
assert!(hyphenation_exceptions().is_empty());
|
||||
assert_ne!(crate::layout::envelope_ink_inputs(), changed, "and again when they are cleared");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,10 @@ pub(crate) use driver::layout_faces_per_call;
|
||||
pub use driver::{decorations, fit_first_line, layout, layout_with, scaled};
|
||||
use vertical::{TcyStyle, stands_upright, tate_chu_yoko, upright_cell, upright_centre, upright_extent, whole_character_widths};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use driver::envelope_ink_inputs;
|
||||
pub use driver::{envelope_ink, register_envelope_ink};
|
||||
|
||||
const EPS: f64 = 1e-6;
|
||||
|
||||
/// Is glyph `g` a later glyph of the cluster of glyph `prev` before it (a combining mark the shaper
|
||||
|
||||
@@ -112,6 +112,62 @@ pub fn decorations(layout: &TextLayout, db: &FontDb, t: &TextObject) -> Vec<(usi
|
||||
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;
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ pub use fontdb::{
|
||||
style_weight, system_font_dirs, user_font_dirs,
|
||||
};
|
||||
pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions};
|
||||
pub use layout::{envelope_ink, register_envelope_ink};
|
||||
use std::sync::Arc;
|
||||
pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader};
|
||||
|
||||
@@ -440,6 +441,20 @@ impl TextLayout {
|
||||
let inlines = self.inlines.iter().map(|i| Some(i.bounds));
|
||||
glyphs.chain(ruby).chain(inlines).flatten().reduce(|a, b| a.union(b))
|
||||
}
|
||||
/// The box of [`Self::ink_bounds`]' ink with text space mapped by `m`: the glyph and ruby
|
||||
/// outlines are mapped point by point, so a rotated or sheared `m` gives the outlines' own
|
||||
/// bounds. Images, color glyphs and inline graphics count by their mapped boxes.
|
||||
pub fn ink_bounds_in(&self, m: Affine) -> Option<Rect> {
|
||||
let outlined = |g: &PositionedGlyph| (!g.outline.elements().is_empty()).then(|| (m * &g.outline).bounding_box());
|
||||
let glyphs = self.glyphs.iter().flat_map(|g| {
|
||||
let image = g.placed_image().map(|(image, xf)| (m * xf).transform_rect_bbox(image.rect()));
|
||||
let colour = g.colour.as_ref().and_then(|c| c.geometric_bounds()).map(|b| (m * g.xf).transform_rect_bbox(b));
|
||||
[outlined(g), image, colour]
|
||||
});
|
||||
let ruby = self.ruby.iter().map(outlined);
|
||||
let inlines = self.inlines.iter().map(|i| Some(m.transform_rect_bbox(i.bounds)));
|
||||
glyphs.chain(ruby).chain(inlines).flatten().reduce(|a, b| a.union(b))
|
||||
}
|
||||
/// All glyph outlines combined (e.g. for Create Outlines).
|
||||
pub fn to_bezpath(&self) -> BezPath {
|
||||
let mut p = BezPath::new();
|
||||
|
||||
@@ -6382,6 +6382,13 @@ envelopes, the path for a top-object envelope); the content keeps the envelope's
|
||||
opacity mask.
|
||||
Type inside an envelope distorts as its glyph outlines, run by run in each run's paint, on the canvas and in every
|
||||
export (SVG, PDF, EPS, EMF/WMF, DXF).
|
||||
An envelope maps type from the bounds of its glyph outlines, underlines and strikethroughs instead of its laid-out
|
||||
box (ascent to descent, or an area's frame) when the type is on its own, in groups and layers, or used as a clipping
|
||||
path. Inline graphics in the type count by their boxes. Rotated type and type in perspective map from the bounds of
|
||||
their outlines as they are drawn. `object.envelope.makeWithMesh`, `object.envelope.makeWithTopObject` and
|
||||
`object.envelope.resetWithMesh` with `maintainShape: false` 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. This is
|
||||
VectorCraft's own rule, and envelopes of type in saved files redraw by it.
|
||||
Everything inside bends with the envelope: images as a raster mesh warp (cut into pieces by Fidelity, each a clipped
|
||||
image under its own affine map; SVG shares the pixels between the pieces), symbol instances as their symbol's art,
|
||||
and with Distort Appearance strokes (as their filled outlines) and geometry effects; with it, Distort Linear Gradients
|
||||
|
||||
@@ -1438,6 +1438,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
|
||||
- **Type drawn as glyph outlines:** under a geometry effect, in EMF and WMF export, in DXF export when it is written as outlines, flattened with Convert All Text to Outlines, and inside an envelope, type keeps its underline and strikethrough, the weight and dashes of its character strokes when it is scaled, and the placement of its gradients and patterns, as the canvas draws it. A group's or layer's own fills and strokes also paint the underline and strikethrough of the type in it.
|
||||
|
||||
- **Envelopes of type map from the glyphs:** an envelope maps point type, area type, type in groups and layers and type used as a clipping path from the bounds of the glyph outlines, underlines and strikethroughs instead of the type's laid-out box (ascent to descent, or the area's frame), with inline graphics counted by their boxes; rotated type and type in perspective map from their outlines as 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. This is VectorCraft's own rule; envelopes of type made in VectorCraft and in saved files redraw by it. Tests: `crates/engine/src/tests/envelope.rs`, and `tests/envelope_ink.rs` in `vectorcraft-eps`, `vectorcraft-render` and `vectorcraft-effects`. Scores and hours unchanged.
|
||||
|
||||
- **Texture redraws (#1257):** Craquelure, Mosaic Tiles and Stained Glass compare squared distances when finding their nearest cells; rounding/tie boundaries use the original distance ordering. Brute-force candidate tests and three 1800×1500 viewport comparisons preserve the old pixels. A CMYK rectangle move at 190.07%/DPI 1.5 is reproducible with `cargo test -p vectorcraft-render texture_drag_perf -- --ignored --nocapture`; idle-machine Windows interaction is still unverified.
|
||||
|
||||
- **Smart Blur modes:** Effect › Blur › Smart Blur… has a Mode menu with Normal, Edge Only and Overlay Edge (`blurMode`: `normal`, `edgeOnly` or `overlayEdge`; an effect saved without it draws as Normal). Edge Only draws the edges in white on black, and Overlay Edge draws them in white over the Normal blur. A pixel is on an edge when one of the pixels the search reads within Radius of it differs from it by more than Threshold in a channel and its channels, alpha included, add up to at least that pixel's. The search reads neighbors as the Normal blur does, kept within Radius, and always the four nearest pixels, so a color step gives a line about as wide as Radius, and at least one pixel wide, on the side with the larger sum (lines on both sides when the sums are equal). The white and the black keep each pixel's coverage. Behavior reference: [Summary of effects](https://helpx.adobe.com/illustrator/using/summary-effects.html).
|
||||
|
||||
Reference in New Issue
Block a user