Open: the first frame uses the layouts the open made for type objects

Opening a document laid out every type object for its bounds and
baselines and threw the layouts away, and the first frame laid the same
objects out again. The open now keeps them, up to 128 MiB, and a frame
takes each one once.

A frame takes a layout only for a text object equal to the one laid out,
and only while the fonts, the system fallback setting and the hyphenation
exceptions are unchanged. When the last frame being drawn ends, the rest
are dropped if a frame took one, and all of them are dropped after eight
frames that take none. A layout with bitmap or color glyphs and a text
object with a gradient are not kept.
This commit is contained in:
dotCooCoo
2026-11-08 11:42:45 +07:00
committed by bflatastic
parent a18364e2b4
commit dbcdbf2466
14 changed files with 975 additions and 2 deletions
+12
View File
@@ -298,6 +298,18 @@ pub(crate) fn refresh_bounds(t: &mut TextObject) {
t.cached_baselines = lay.baselines();
}
/// [`refresh_bounds`] for a document that is opening: the first frame takes the layout
/// ([`vectorcraft_text::take_kept_layout`]) instead of laying the object out again.
pub(crate) fn refresh_bounds_for_first_frame(t: &mut TextObject) {
t.normalize_paras();
auto_height(t);
let stamp = vectorcraft_text::LayoutStamp::now();
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
t.cached_bounds = Some(lay.bounds);
t.cached_baselines = lay.baselines();
vectorcraft_text::keep_layout(t, stamp, lay);
}
/// The text-space bounds of area type's frame, when Auto Size can fit it: a rectangle (one
/// subpath, as large as its bounds) of horizontal type in one row, with some height.
fn auto_height_frame(t: &TextObject) -> Option<vectorcraft_geom::Rect> {
+1 -1
View File
@@ -335,7 +335,7 @@ fn refresh_tree_text_bounds(n: &mut Arc<vectorcraft_doc::Node>) {
if let NodeKind::Text(t) = &mut n.kind
&& t.cached_bounds.is_none()
{
cmd::typecmd::refresh_bounds(t);
cmd::typecmd::refresh_bounds_for_first_frame(t);
}
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) {
refresh_tree_text_bounds(&mut m.art);
+279
View File
@@ -0,0 +1,279 @@
//! A document of type opened and drawn with the layouts the open keeps, compared with the same
//! document drawn after laying every object out again: the nodes, bounds, baselines and pixels are
//! the same, and the first frame lays out only what it could not take. Its own test binary: kept
//! layouts and the font database's generation belong to the whole process.
// 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::{Mutex, MutexGuard};
use serde_json::{Value, json};
use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, TextObject, TextRun};
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
use vectorcraft_geom::{Affine, Point};
use vectorcraft_render::{RenderOptions, Renderer};
use vectorcraft_text::test_fonts::{drop_kept_layouts, layouts_on_this_thread};
use vectorcraft_text::{FontDb, KeptLayoutsFrame};
const WIDTH: u32 = 900;
const HEIGHT: u32 = 1300;
/// Taken by each test: kept layouts belong to the whole process.
static PROCESS: Mutex<()> = Mutex::new(());
fn process() -> MutexGuard<'static, ()> {
PROCESS.lock().unwrap_or_else(|e| e.into_inner())
}
fn style() -> CharStyle {
CharStyle { size: 40.0, ..CharStyle::default() }
}
fn file_of(objects: Vec<TextObject>) -> Vec<u8> {
let mut doc = Document::new(f64::from(WIDTH), f64::from(HEIGHT));
let layer = doc.layers[0].id;
for (i, mut t) in objects.into_iter().enumerate() {
t.xf = Affine::translate((20.0, 70.0 * (i + 1) as f64));
let id = doc.alloc_id();
doc.insert(Some(layer), usize::MAX, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap();
}
vectorcraft_format::save_file(&doc)
}
fn latin() -> Vec<u8> {
file_of(["Plain Latin type", "AVATAR office", "123 ... 456"].map(|s| TextObject::point(Point::ZERO, s, style())).into())
}
fn mixed_fonts() -> Vec<u8> {
let font = |family: &str, text: &str| TextRun::new(text, CharStyle { font_family: family.into(), ..style() });
let mut mixed = TextObject::point(Point::ZERO, "", style());
mixed.runs = vec![font("Inter", "Inter "), font("JetBrains Mono", "Mono "), font("Source Serif 4", "Serif "), font("No Such Family", "Missing")];
file_of(vec![mixed, TextObject::point(Point::ZERO, "Source Sans", style())])
}
fn complex_scripts() -> Vec<u8> {
let scripts = [
"\u{645}\u{631}\u{62d}\u{628}\u{627}",
"\u{5e9}\u{5dc}\u{5d5}\u{5dd}",
"\u{928}\u{92e}\u{938}\u{94d}\u{924}\u{947}",
"\u{e2a}\u{e27}\u{e31}\u{e2a}\u{e14}\u{e35}",
"\u{65e5}\u{672c}\u{8a9e} abc",
];
file_of(scripts.map(|s| TextObject::point(Point::ZERO, s, style())).into())
}
/// Area type, and a story through two frames, as a session saves them.
fn threaded_story() -> Vec<u8> {
let mut s = Session::new();
s.add_document(Document::new(f64::from(WIDTH), f64::from(HEIGHT)), None);
let story = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame. ".repeat(4);
let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}})).unwrap()["id"]
.as_u64()
.unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute(
"text.create",
&json!({"x": 10, "y": 300, "text": "Area type on its own. ".repeat(20), "size": 12, "area": {"width": 300, "height": 200}}),
)
.unwrap();
vectorcraft_format::save_file(&s.doc().unwrap().doc)
}
fn documents() -> Vec<(&'static str, Vec<u8>)> {
vec![("Latin", latin()), ("mixed fonts", mixed_fonts()), ("complex scripts", complex_scripts()), ("threaded story", threaded_story())]
}
/// The session `bytes` opened in, as `document.open` reads a file and then opens it.
fn open(bytes: &[u8]) -> Session {
let name = "layouts.vectorcraft";
let mut s = Session::new();
let read = fileio::read_for_open(name, bytes, Some(name.into()), &Value::Null, false, false).unwrap();
fileio::open_read(&mut s, read).unwrap();
s
}
fn frame(doc: &Document) -> Vec<u8> {
Renderer::new().render(doc, WIDTH, HEIGHT, Affine::IDENTITY, &RenderOptions::default()).pixels
}
fn type_objects(doc: &Document) -> Vec<TextObject> {
let mut all = vec![];
doc.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
all.push((**t).clone());
}
});
all
}
/// Open once to load every font the document uses: a load changes the font database's generation.
fn fonts_loaded(bytes: &[u8]) {
let s = open(bytes);
frame(&s.doc().unwrap().doc);
drop_kept_layouts();
}
#[test]
fn the_first_frame_draws_the_pixels_of_laying_every_object_out_again() {
let _process = process();
for (name, bytes) in documents() {
fonts_loaded(&bytes);
let s = open(&bytes);
let doc = &s.doc().unwrap().doc;
let taken = frame(doc);
drop_kept_layouts();
let again = frame(doc);
assert!(again != frame(&Document::new(f64::from(WIDTH), f64::from(HEIGHT))), "{name}: the frame shows the type");
let differing = taken.iter().zip(&again).filter(|(a, b)| a != b).count();
assert_eq!((taken.len(), differing), (again.len(), 0), "{name}: bytes of the frame that differ");
}
}
#[test]
fn the_kept_layouts_are_the_layouts_of_the_opened_objects() {
let _process = process();
for (name, bytes) in documents() {
fonts_loaded(&bytes);
let s = open(&bytes);
let doc = &s.doc().unwrap().doc;
let mut taken = 0;
let frame_drawn = KeptLayoutsFrame::begin();
for t in type_objects(doc) {
let fresh = vectorcraft_text::layout(FontDb::global(), &t);
assert_eq!(t.cached_bounds, Some(fresh.bounds), "{name}: {:?}", t.plain_text());
assert_eq!(t.cached_baselines, fresh.baselines(), "{name}: {:?}", t.plain_text());
if let Some(kept) = vectorcraft_text::take_kept_layout(&t) {
let outlines =
|l: &vectorcraft_text::TextLayout| l.glyphs.iter().map(|g| (g.font_id, g.gid, g.origin, g.outline.clone())).collect::<Vec<_>>();
assert!(outlines(&kept) == outlines(&fresh), "{name}: {:?}", t.plain_text());
assert_eq!(kept.bounds, fresh.bounds, "{name}: {:?}", t.plain_text());
taken += 1;
}
}
assert!(taken > 0, "{name}: the open kept layouts");
drop(frame_drawn);
drop_kept_layouts();
let again = open(&bytes);
assert!(*again.doc().unwrap().doc == **doc, "{name}: another open reads the same document");
drop_kept_layouts();
}
}
#[test]
fn the_first_frame_lays_out_no_object_the_open_laid_out() {
let _process = process();
let bytes = latin();
fonts_loaded(&bytes);
let s = open(&bytes);
let doc = &s.doc().unwrap().doc;
let before = layouts_on_this_thread();
frame(doc);
assert_eq!(layouts_on_this_thread() - before, 0, "the frame takes every layout");
// A second renderer has nothing left to take.
let before = layouts_on_this_thread();
frame(doc);
assert_eq!(layouts_on_this_thread() - before, 3);
}
#[test]
fn an_edited_object_is_laid_out_again() {
let _process = process();
let bytes = latin();
fonts_loaded(&bytes);
let mut s = open(&bytes);
let id = {
let mut ids = vec![];
s.doc().unwrap().doc.walk(|n| {
if matches!(n.kind, NodeKind::Text(_)) {
ids.push(n.id.0);
}
});
ids[0]
};
s.execute("text.setText", &json!({"id": id, "text": "Edited"})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let before = layouts_on_this_thread();
let edited = frame(&doc);
assert_eq!(layouts_on_this_thread() - before, 1, "only the edited object is laid out");
drop_kept_layouts();
assert!(edited == frame(&doc), "the frame is what laying every object out again draws");
}
#[test]
fn changing_the_hyphenation_exceptions_or_the_fonts_lays_every_object_out_again() {
let _process = process();
let bytes = latin();
fonts_loaded(&bytes);
let s = open(&bytes);
let doc = &s.doc().unwrap().doc;
vectorcraft_text::set_hyphenation_exceptions("");
let before = layouts_on_this_thread();
frame(doc);
assert_eq!(layouts_on_this_thread() - before, 3, "hyphenation exceptions were set");
let s = open(&bytes);
let doc = &s.doc().unwrap().doc;
FontDb::global().add_font(vectorcraft_text::test_fonts::variable_font().unwrap());
let generation = FontDb::global().generation();
let before = layouts_on_this_thread();
frame(doc);
assert_eq!(layouts_on_this_thread() - before, 3, "fonts were added (generation {generation})");
}
/// The layout of an object the first frame culls is dropped when that frame ends.
#[test]
fn a_frame_lets_go_of_what_it_did_not_draw() {
let _process = process();
let mut objects = vec![TextObject::point(Point::ZERO, "In view", style())];
objects.extend((0..3).map(|i| TextObject::point(Point::ZERO, &format!("Out of view {i}"), style())));
let bytes = file_of(objects);
fonts_loaded(&bytes);
let s = open(&bytes);
let doc = &s.doc().unwrap().doc;
let small = Renderer::new().render(doc, 300, 100, Affine::IDENTITY, &RenderOptions::default());
assert_eq!(small.width, 300);
let out = type_objects(doc).into_iter().find(|t| t.plain_text() == "Out of view 2").unwrap();
let _frame = KeptLayoutsFrame::begin();
assert!(vectorcraft_text::take_kept_layout(&out).is_none());
}
/// A thumbnail lays its object out, and the first frame still takes that object's layout.
#[test]
fn a_thumbnail_leaves_the_layouts_for_the_first_frame() {
let _process = process();
let bytes = latin();
fonts_loaded(&bytes);
let s = open(&bytes);
let doc = &s.doc().unwrap().doc;
let mut ids = vec![];
doc.walk(|n| {
if matches!(n.kind, NodeKind::Text(_)) {
ids.push(n.id);
}
});
let before = layouts_on_this_thread();
assert!(Renderer::new().render_thumbnail(doc, ids[0], 64).is_some());
assert_eq!(layouts_on_this_thread() - before, 1, "the thumbnail lays its object out");
let before = layouts_on_this_thread();
frame(doc);
assert_eq!(layouts_on_this_thread() - before, 0, "the frame takes every layout");
}
/// A frame of ink amounts ends like any other.
#[test]
fn an_ink_frame_lets_go_of_what_it_did_not_draw() {
let _process = process();
let mut objects = vec![TextObject::point(Point::ZERO, "In view", style())];
objects.extend((0..3).map(|i| TextObject::point(Point::ZERO, &format!("Out of view {i}"), style())));
let bytes = file_of(objects);
fonts_loaded(&bytes);
let s = open(&bytes);
let doc = &s.doc().unwrap().doc;
let region = vectorcraft_geom::Rect::new(0.0, 0.0, 300.0, 100.0);
assert!(!Renderer::new().render_region_inks(doc, region, 1.0, &RenderOptions::default()).is_empty());
let out = type_objects(doc).into_iter().find(|t| t.plain_text() == "Out of view 2").unwrap();
let _frame = KeptLayoutsFrame::begin();
assert!(vectorcraft_text::take_kept_layout(&out).is_none());
}
+2
View File
@@ -91,6 +91,7 @@ impl Renderer {
/// Render `doc` into a `width`×`height` image using `view` (document → pixel transform).
pub fn render(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions) -> Rendered {
let _frame = vectorcraft_text::KeptLayoutsFrame::begin();
self.render_as(doc, width, height, view, opts, false)
}
@@ -315,6 +316,7 @@ impl Renderer {
pub fn render_region_inks(&mut self, doc: &Document, region: Rect, scale: f64, opts: &RenderOptions) -> Vec<u8> {
let (w, h) = region_pixels(region, scale);
let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0));
let _frame = vectorcraft_text::KeptLayoutsFrame::begin();
self.render_as(doc, w, h, view, opts, true).pixels
}
+1 -1
View File
@@ -147,7 +147,7 @@ pub(crate) fn text_geom(t: &TextObject) -> TextGeom {
/// Glyph geometry of `t`; `snap` (text space → device pixels) puts the glyphs on whole pixels.
pub(crate) fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
let db = vectorcraft_text::FontDb::global();
let mut layout = vectorcraft_text::layout(db, t);
let mut layout = vectorcraft_text::take_kept_layout(t).unwrap_or_else(|| vectorcraft_text::layout(db, t));
if let Some(xf) = snap {
layout.snap_to_pixels(xf);
}
+12
View File
@@ -83,6 +83,18 @@ impl FontDb {
let _ = on;
}
/// Is the lazy system-font fallback on ([`Self::set_system_fallback`])?
pub(crate) fn system_fallback_on(&self) -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled
}
#[cfg(target_arch = "wasm32")]
{
false
}
}
/// Process-wide database: the bundled fonts, then the installed system fonts, cataloged the
/// first time a lookup by family name needs them (or ahead of time by
/// [`FontDb::scan_in_background`]).
+9
View File
@@ -90,6 +90,15 @@ fn parse_list(source: &str) -> Exceptions {
/// case-insensitive. Empty clears the list.
pub fn set_hyphenation_exceptions(source: &str) {
*store() = parse_list(source);
EXCEPTIONS_SET.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
/// How many times [`set_hyphenation_exceptions`] has run.
static EXCEPTIONS_SET: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// A number that changes whenever [`set_hyphenation_exceptions`] runs.
pub(crate) fn exceptions_generation() -> u64 {
EXCEPTIONS_SET.load(std::sync::atomic::Ordering::Relaxed)
}
/// The last string passed to [`set_hyphenation_exceptions`] (empty when none).
+318
View File
@@ -0,0 +1,318 @@
//! The layouts of a document that is opening, kept for its first frame: opening lays out every type
//! object for its bounds and baselines, and the first frame needs the same glyphs. A kept layout is
//! found by comparing the whole text object, and only while the fonts and the hyphenation exceptions
//! are the ones it was made with. Each is taken once, the frame that takes one drops the rest, a run of
//! [`KEPT_LAYOUTS_FRAMES`] frames that take none drops them all, and together they hold at most
//! [`KEPT_LAYOUTS_BUDGET`] bytes.
use std::collections::{HashMap, VecDeque};
use std::hash::{DefaultHasher, Hash, Hasher};
use std::mem::{size_of, size_of_val};
use std::sync::{LazyLock, Mutex, MutexGuard, PoisonError};
use vectorcraft_doc::color::Paint;
use vectorcraft_doc::{CharStyle, ParaStyle, TextKind, TextObject, TextRun};
use vectorcraft_geom::{Anchor, PathData};
use crate::{FontDb, PositionedGlyph, TextLayout};
/// The most bytes of layouts kept at once; the oldest are dropped first.
const KEPT_LAYOUTS_BUDGET: usize = 128 << 20;
/// How many frames may end with kept layouts on the shelf: the frames of other documents, or of a view
/// that shows no type, take none.
const KEPT_LAYOUTS_FRAMES: u32 = 8;
/// The most layouts kept under one hash ([`text_hash`]): taking one compares it with those.
const KEPT_LAYOUTS_PER_HASH: usize = 32;
/// What a layout reads besides its text object, in the global font database. Take it before laying
/// out: a layout that loads a font ends with another stamp, and is not kept.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LayoutStamp {
fonts: u64,
system_fallback: bool,
hyphenation: u64,
}
impl LayoutStamp {
pub fn now() -> Self {
let db = FontDb::global();
Self { fonts: db.generation(), system_fallback: db.system_fallback_on(), hyphenation: crate::hyphen::exceptions_generation() }
}
}
struct Kept {
/// Tells this one from the others under its hash, in [`Shelf::order`].
number: u64,
text: TextObject,
layout: TextLayout,
bytes: usize,
}
#[derive(Default)]
struct Shelf {
stamp: Option<LayoutStamp>,
kept: HashMap<u64, Vec<Kept>>,
/// Hash and number of each kept layout, the oldest first. Layouts already taken stay listed
/// until they reach the front.
order: VecDeque<(u64, u64)>,
next: u64,
bytes: usize,
taken: bool,
/// Frames that have ended since the last keep.
frames: u32,
/// Frames being drawn now, on any thread.
drawing: u32,
}
impl Shelf {
fn clear(&mut self) {
self.kept.clear();
self.order.clear();
self.bytes = 0;
self.taken = false;
self.frames = 0;
}
fn remove(&mut self, hash: u64, found: impl Fn(&Kept) -> bool) -> Option<Kept> {
let list = self.kept.get_mut(&hash)?;
let kept = list.swap_remove(list.iter().position(found)?);
if list.is_empty() {
self.kept.remove(&hash);
}
self.bytes -= kept.bytes;
Some(kept)
}
/// Keep `layout` of `text`, laid out at `stamp`, when that is still `now`. Layouts of an older
/// stamp are dropped. A layout whose memory is not counted ([`counts_its_memory`]) and one past
/// [`KEPT_LAYOUTS_PER_HASH`] are not kept.
fn keep(&mut self, text: &TextObject, stamp: LayoutStamp, now: LayoutStamp, layout: TextLayout, budget: usize) {
if stamp != now {
return;
}
if self.stamp != Some(now) {
self.clear();
self.stamp = Some(now);
}
let bytes = layout_bytes(&layout) + text_bytes(text);
if bytes > budget || !counts_its_memory(text, &layout) {
return;
}
let (hash, number) = (text_hash(text), self.next);
if self.kept.get(&hash).is_some_and(|list| list.len() >= KEPT_LAYOUTS_PER_HASH) {
return;
}
while self.bytes + bytes > budget {
let Some((hash, number)) = self.order.pop_front() else { break };
self.remove(hash, |k| k.number == number);
}
self.frames = 0;
self.next += 1;
self.bytes += bytes;
self.order.push_back((hash, number));
self.kept.entry(hash).or_default().push(Kept { number, text: text.clone(), layout, bytes });
}
/// The layout kept for a text object equal to `text`, no longer kept after this.
fn take(&mut self, text: &TextObject, now: LayoutStamp) -> Option<TextLayout> {
if self.kept.is_empty() {
return None;
}
if self.stamp != Some(now) {
self.clear();
return None;
}
let kept = self.remove(text_hash(text), |k| k.text == *text)?;
if self.kept.is_empty() {
self.order.clear();
}
self.taken = true;
Some(kept.layout)
}
fn frame_begin(&mut self) {
self.drawing += 1;
}
/// A frame ended: when no other frame is being drawn and a frame took a layout, or
/// [`KEPT_LAYOUTS_FRAMES`] frames have ended since the last keep, the others are dropped.
fn frame_done(&mut self) {
self.drawing = self.drawing.saturating_sub(1);
self.frames = self.frames.saturating_add(1);
if self.drawing == 0 && (self.taken || self.frames >= KEPT_LAYOUTS_FRAMES) {
self.clear();
}
}
}
fn text_hash(t: &TextObject) -> u64 {
let mut h = DefaultHasher::new();
t.runs.iter().for_each(|r| {
r.text.hash(&mut h);
r.style.font_family.hash(&mut h);
r.style.font_style.hash(&mut h);
r.style.size.to_bits().hash(&mut h);
});
t.xf.as_coeffs().iter().for_each(|c| c.to_bits().hash(&mut h));
h.finish()
}
/// Does [`text_bytes`] and [`layout_bytes`] count everything `t` and `layout` hold? They do not count
/// a gradient's stops, or a glyph's bitmap or color art.
fn counts_its_memory(t: &TextObject, layout: &TextLayout) -> bool {
let gradient = |p: &Paint| matches!(p, Paint::Gradient(_));
!t.runs.iter().any(|r| gradient(&r.style.fill) || gradient(&r.style.stroke))
&& !layout.glyphs.iter().chain(&layout.ruby).any(|g| g.image.is_some() || g.colour.is_some())
}
fn paint_bytes(p: &Paint) -> usize {
match p {
Paint::Solid { swatch: Some(name), .. } | Paint::Pattern { pattern: name, .. } => name.len(),
_ => 0,
}
}
fn path_bytes(p: &PathData) -> usize {
size_of_val(p.subpaths.as_slice()) + p.anchor_count() * size_of::<Anchor>()
}
fn style_bytes(s: &CharStyle) -> usize {
s.font_family.len()
+ s.font_style.len()
+ s.font_version.as_ref().map_or(0, String::len)
+ s.style_name.as_ref().map_or(0, String::len)
+ s.language.as_ref().map_or(0, String::len)
+ size_of_val(s.features.as_slice())
+ s.features.iter().map(String::len).sum::<usize>()
+ s.font_axes.keys().map(|k| size_of::<(String, f64)>() + k.len()).sum::<usize>()
+ paint_bytes(&s.fill)
+ paint_bytes(&s.stroke)
+ s.stroke_dash.as_ref().map_or(0, |d| size_of_val(d.pattern.as_slice()))
+ s.glyph_alternate.as_ref().map_or(0, |g| g.font.len() + g.version.len())
}
fn run_bytes(r: &TextRun) -> usize {
size_of::<TextRun>() + r.text.len() + style_bytes(&r.style) + r.inline.as_ref().map_or(0, |i| i.symbol.len())
}
fn para_bytes(p: &ParaStyle) -> usize {
size_of_val(p.tabs.as_slice()) + p.tabs.iter().map(|t| t.leader.len()).sum::<usize>() + p.style_name.as_ref().map_or(0, String::len)
}
fn text_bytes(t: &TextObject) -> usize {
let kind = match &t.kind {
TextKind::Point => 0,
TextKind::Area { frame } => path_bytes(frame),
TextKind::OnPath { path, .. } => path_bytes(path),
};
size_of::<TextObject>()
+ kind
+ t.runs.iter().map(run_bytes).sum::<usize>()
+ t.ruby.iter().map(|r| size_of_val(r) + r.text.len()).sum::<usize>()
+ para_bytes(&t.para)
+ t.paras.iter().map(|p| size_of_val(p) + para_bytes(p)).sum::<usize>()
+ size_of_val(t.cached_baselines.as_slice())
+ t.wrap.iter().map(|w| size_of_val(w) + path_bytes(&w.path)).sum::<usize>()
}
fn glyph_bytes(glyphs: &[PositionedGlyph]) -> usize {
size_of_val(glyphs) + glyphs.iter().map(|g| size_of_val(g.outline.elements()) + size_of_val(g.carets.as_slice())).sum::<usize>()
}
fn layout_bytes(l: &TextLayout) -> usize {
size_of::<TextLayout>()
+ glyph_bytes(&l.glyphs)
+ glyph_bytes(&l.ruby)
+ size_of_val(l.lines.as_slice())
+ size_of_val(l.frames.as_slice())
+ size_of_val(l.inlines.as_slice())
+ l.markers.iter().map(|m| size_of_val(m) + m.text.len()).sum::<usize>()
}
fn shelf() -> MutexGuard<'static, Shelf> {
static SHELF: LazyLock<Mutex<Shelf>> = LazyLock::new(Mutex::default);
SHELF.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Keep `layout` of `t`, made from the global font database after `stamp` was taken, for the first
/// frame ([`take_kept_layout`]). `t` is the object as the document holds it.
pub fn keep_layout(t: &TextObject, stamp: LayoutStamp, layout: TextLayout) {
let now = LayoutStamp::now();
shelf().keep(t, stamp, now, layout, KEPT_LAYOUTS_BUDGET);
}
/// The layout [`keep_layout`] kept for a text object equal to `t`, when a frame is being drawn
/// ([`KeptLayoutsFrame`]) and the fonts and the hyphenation exceptions have not changed since. Taking
/// it removes it. A thumbnail, which draws outside a frame, leaves the layouts for the first frame.
pub fn take_kept_layout(t: &TextObject) -> Option<TextLayout> {
let now = LayoutStamp::now();
let mut shelf = shelf();
if shelf.drawing == 0 {
return None;
}
shelf.take(t, now)
}
/// A frame being drawn. When the last frame being drawn ends, the kept layouts are dropped if a frame
/// took one, or after a run of frames that took none.
pub struct KeptLayoutsFrame(());
impl KeptLayoutsFrame {
#[must_use]
pub fn begin() -> Self {
shelf().frame_begin();
Self(())
}
}
impl Drop for KeptLayoutsFrame {
fn drop(&mut self) {
shelf().frame_done();
}
}
/// Drop every kept layout (tests in other crates, to lay out again what a frame would have taken).
#[cfg(any(test, feature = "test-fonts"))]
pub(crate) fn drop_kept_layouts() {
shelf().clear();
}
#[cfg(test)]
pub(crate) mod test_shelf {
use super::*;
/// A shelf of its own, so that tests do not share the process's or its font database's stamp.
#[derive(Default)]
pub(crate) struct TestShelf(Shelf);
impl TestShelf {
pub(crate) fn keep(&mut self, t: &TextObject, stamp: LayoutStamp, now: LayoutStamp, layout: TextLayout, budget: usize) {
self.0.keep(t, stamp, now, layout, budget);
}
pub(crate) fn take(&mut self, t: &TextObject, now: LayoutStamp) -> Option<TextLayout> {
self.0.take(t, now)
}
pub(crate) fn frame_begin(&mut self) {
self.0.frame_begin();
}
pub(crate) fn frame_done(&mut self) {
self.0.frame_done();
}
pub(crate) fn bytes(&self) -> usize {
self.0.bytes
}
}
pub(crate) const FRAMES: u32 = KEPT_LAYOUTS_FRAMES;
pub(crate) const PER_HASH: usize = KEPT_LAYOUTS_PER_HASH;
pub(crate) fn stamp(fonts: u64, system_fallback: bool, hyphenation: u64) -> LayoutStamp {
LayoutStamp { fonts, system_fallback, hyphenation }
}
pub(crate) fn bytes_of(t: &TextObject, layout: &TextLayout) -> usize {
layout_bytes(layout) + text_bytes(t)
}
}
+2
View File
@@ -119,6 +119,8 @@ const SHRINK_PASSES: usize = 11;
/// fit, composer, OpenType features). Each run is set at the em size its font height reference
/// gives it ([`FontDb::font_heights_resolved`]).
pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout {
#[cfg(any(test, feature = "test-fonts"))]
crate::test_fonts::count_layout();
let resolved = db.font_heights_resolved(t);
let t = &*resolved;
let first = layout_once(db, t, opts);
+2
View File
@@ -22,6 +22,7 @@ mod fontdb;
pub mod hyphen;
mod justify;
mod kashida;
mod kept;
mod layout;
mod shape;
#[cfg(not(target_arch = "wasm32"))]
@@ -43,6 +44,7 @@ pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions};
use std::sync::Arc;
pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader};
pub use kept::{KeptLayoutsFrame, LayoutStamp, keep_layout, take_kept_layout};
use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2};
pub use layout::{decorations, fit_first_line, layout, layout_with, scaled};
pub use vectorcraft_doc::TextObject;
+19
View File
@@ -26,6 +26,25 @@ pub(crate) fn keeps_shape_plans() -> bool {
KEEP_SHAPE_PLANS.load(Ordering::SeqCst)
}
std::thread_local! {
/// How many layouts this thread has started ([`layouts_on_this_thread`]).
static LAYOUTS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
pub(crate) fn count_layout() {
LAYOUTS.with(|n| n.set(n.get() + 1));
}
/// How many layouts this thread has started.
pub fn layouts_on_this_thread() -> u64 {
LAYOUTS.with(std::cell::Cell::get)
}
/// Drop every layout kept for a first frame, so that a test can draw what laying out again draws.
pub fn drop_kept_layouts() {
crate::kept::drop_kept_layouts();
}
/// The family of [`variable_font`].
pub const VARIABLE_FAMILY: &str = "Varitest Sans";
+1
View File
@@ -16,6 +16,7 @@ mod hang;
mod inline;
mod joiners;
mod justify;
mod kept_layouts;
mod ligature_carets;
mod path_joins;
mod ruby;
+316
View File
@@ -0,0 +1,316 @@
//! Layouts kept for the first frame: found only for an equal text object at the same stamp, taken
//! once, and limited in bytes.
use kurbo::{Affine, Point};
use vectorcraft_doc::color::{Color, Gradient, GradientPaint, Paint};
use vectorcraft_doc::{CharStyle, Dash, RubySpan, TabAlign, TabStop, TextKind, TextObject, TextWrap, WrapShape};
use vectorcraft_geom::PathData;
use crate::kept::test_shelf::{FRAMES, PER_HASH, TestShelf, bytes_of, stamp};
use crate::{FontDb, ListMarker, TextLayout, layout};
const BUDGET: usize = 1 << 30;
fn object(text: &str) -> TextObject {
TextObject::point(Point::ZERO, text, CharStyle::default())
}
fn laid_out(t: &TextObject) -> TextLayout {
layout(FontDb::global(), t)
}
#[test]
fn a_kept_layout_is_taken_once() {
let at = stamp(1, true, 1);
let t = object("Type");
let mut shelf = TestShelf::default();
shelf.keep(&t, at, at, laid_out(&t), BUDGET);
let taken = shelf.take(&t.clone(), at).unwrap();
assert_eq!(taken.glyphs.len(), 4);
assert!(shelf.take(&t, at).is_none());
assert_eq!(shelf.bytes(), 0);
}
#[test]
fn equal_objects_take_a_layout_each() {
let at = stamp(1, true, 1);
let t = object("Type");
let mut shelf = TestShelf::default();
shelf.keep(&t, at, at, laid_out(&t), BUDGET);
shelf.keep(&t, at, at, laid_out(&t), BUDGET);
assert!(shelf.take(&t, at).is_some() && shelf.take(&t, at).is_some());
assert!(shelf.take(&t, at).is_none());
}
#[test]
fn a_changed_object_does_not_find_the_layout() {
let at = stamp(1, true, 1);
let t = object("Type");
fn frame(w: f64) -> TextKind {
TextKind::Area { frame: PathData::from_bezpath(&kurbo::Shape::to_path(&kurbo::Rect::new(0.0, 0.0, w, 50.0), 0.1)) }
}
let changes: [fn(&mut TextObject); 11] = [
|t| t.runs[0].text = "Typo".into(),
|t| t.runs[0].style.size += 1.0,
|t| t.runs[0].style.font_family = "Inter".into(),
|t| t.runs[0].style.font_style = "Bold".into(),
|t| t.runs[0].style.language = Some("tr".into()),
|t| t.xf = Affine::translate((1.0, 0.0)),
|t| t.vertical = true,
|t| t.para.first_line_indent = 3.0,
|t| t.first_para_continues = true,
|t| t.area.columns = 2,
|t| t.kind = frame(100.0),
];
for (i, change) in changes.iter().enumerate() {
let mut shelf = TestShelf::default();
shelf.keep(&t, at, at, laid_out(&t), BUDGET);
let mut other = t.clone();
change(&mut other);
assert!(shelf.take(&other, at).is_none(), "change {i}");
assert!(shelf.take(&t, at).is_some(), "change {i}: the object itself still finds it");
}
// Two frame shapes of the same size, each a text object of its own.
let mut area = t.clone();
area.kind = frame(100.0);
let mut wider = t.clone();
wider.kind = frame(120.0);
let mut shelf = TestShelf::default();
shelf.keep(&area, at, at, laid_out(&area), BUDGET);
assert!(shelf.take(&wider, at).is_none());
assert!(shelf.take(&area, at).is_some());
}
#[test]
fn a_changed_stamp_drops_the_layouts() {
let t = object("Type");
let at = stamp(1, true, 1);
for later in [stamp(2, true, 1), stamp(1, false, 1), stamp(1, true, 2)] {
let mut shelf = TestShelf::default();
shelf.keep(&t, at, at, laid_out(&t), BUDGET);
assert!(shelf.take(&t, later).is_none(), "{later:?}");
assert_eq!(shelf.bytes(), 0, "{later:?}");
assert!(shelf.take(&t, at).is_none(), "{later:?}: gone for good");
}
}
#[test]
fn a_layout_made_while_the_stamp_changed_is_not_kept() {
let t = object("Type");
let mut shelf = TestShelf::default();
shelf.keep(&t, stamp(1, true, 1), stamp(2, true, 1), laid_out(&t), BUDGET);
assert_eq!(shelf.bytes(), 0);
assert!(shelf.take(&t, stamp(1, true, 1)).is_none() && shelf.take(&t, stamp(2, true, 1)).is_none());
}
#[test]
fn keeping_at_a_new_stamp_lets_the_older_layouts_go() {
let (old, new) = (stamp(1, true, 1), stamp(2, true, 1));
let (a, b) = (object("Old"), object("New"));
let mut shelf = TestShelf::default();
shelf.keep(&a, old, old, laid_out(&a), BUDGET);
shelf.keep(&b, new, new, laid_out(&b), BUDGET);
assert!(shelf.take(&a, new).is_none());
assert!(shelf.take(&b, new).is_some());
}
#[test]
fn the_budget_lets_the_oldest_layouts_go() {
let at = stamp(1, true, 1);
let (a, b, c) = (object("Abc"), object("Def"), object("Ghi"));
let (la, lb, lc) = (laid_out(&a), laid_out(&b), laid_out(&c));
let budget = bytes_of(&b, &lb) + bytes_of(&c, &lc);
assert!(bytes_of(&a, &la) + budget > budget);
let mut shelf = TestShelf::default();
shelf.keep(&a, at, at, la, budget);
shelf.keep(&b, at, at, lb, budget);
shelf.keep(&c, at, at, lc, budget);
assert!(shelf.bytes() <= budget);
assert!(shelf.take(&a, at).is_none(), "the oldest went");
assert!(shelf.take(&b, at).is_some() && shelf.take(&c, at).is_some());
assert_eq!(shelf.bytes(), 0);
}
#[test]
fn a_layout_larger_than_the_budget_is_not_kept_and_lets_nothing_go() {
let at = stamp(1, true, 1);
let (small, large) = (object("A"), object(&"Long text ".repeat(50)));
let (ls, ll) = (laid_out(&small), laid_out(&large));
let budget = bytes_of(&small, &ls);
assert!(bytes_of(&large, &ll) > budget);
let mut shelf = TestShelf::default();
shelf.keep(&small, at, at, ls, budget);
shelf.keep(&large, at, at, ll, budget);
assert!(shelf.take(&large, at).is_none());
assert!(shelf.take(&small, at).is_some());
}
#[test]
fn a_frame_that_took_a_layout_lets_the_others_go() {
let at = stamp(1, true, 1);
let (a, b) = (object("Seen"), object("Out of view"));
let mut shelf = TestShelf::default();
shelf.keep(&a, at, at, laid_out(&a), BUDGET);
shelf.keep(&b, at, at, laid_out(&b), BUDGET);
shelf.frame_done();
assert!(shelf.bytes() > 0, "a frame that took none lets none go");
assert!(shelf.take(&a, at).is_some());
shelf.frame_done();
assert_eq!(shelf.bytes(), 0);
assert!(shelf.take(&b, at).is_none());
}
#[test]
fn a_layout_with_color_art_is_not_kept() {
let at = stamp(1, true, 1);
let t = object("Type");
let mut l = laid_out(&t);
l.glyphs[0].colour = Some(std::sync::Arc::new(vectorcraft_doc::Node::group(vectorcraft_doc::NodeId(1), vec![])));
let mut shelf = TestShelf::default();
shelf.keep(&t, at, at, l, BUDGET);
assert_eq!(shelf.bytes(), 0);
assert!(shelf.take(&t, at).is_none());
}
#[test]
fn an_object_with_a_gradient_is_not_kept() {
let at = stamp(1, true, 1);
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
for stroke in [false, true] {
let mut t = object("Type");
if stroke {
t.runs[0].style.stroke = gradient.clone();
} else {
t.runs[0].style.fill = gradient.clone();
}
let mut shelf = TestShelf::default();
shelf.keep(&t, at, at, laid_out(&t), BUDGET);
assert_eq!(shelf.bytes(), 0, "stroke: {stroke}");
}
}
/// Objects that differ only past the hash stay few enough to compare.
#[test]
fn one_hash_holds_a_limited_number_of_layouts() {
let at = stamp(1, true, 1);
let objects: Vec<TextObject> = (0..PER_HASH + 2)
.map(|i| {
let mut t = object("Type");
t.para.first_line_indent = i as f64;
t
})
.collect();
let mut shelf = TestShelf::default();
for t in &objects {
shelf.keep(t, at, at, laid_out(t), BUDGET);
}
let taken = objects.iter().filter(|t| shelf.take(t, at).is_some()).count();
assert_eq!(taken, PER_HASH);
assert_eq!(shelf.bytes(), 0);
}
#[test]
fn a_frame_still_being_drawn_keeps_the_layouts_it_may_take() {
let at = stamp(1, true, 1);
let (a, b) = (object("Canvas"), object("Other pane"));
let mut shelf = TestShelf::default();
shelf.keep(&a, at, at, laid_out(&a), BUDGET);
shelf.keep(&b, at, at, laid_out(&b), BUDGET);
shelf.frame_begin();
shelf.frame_begin();
assert!(shelf.take(&a, at).is_some());
shelf.frame_done();
assert!(shelf.take(&b, at).is_some(), "the other frame takes its layout after the first ends");
shelf.keep(&a, at, at, laid_out(&a), BUDGET);
shelf.frame_done();
assert_eq!(shelf.bytes(), 0, "the last frame to end lets the rest go");
}
#[test]
fn frames_that_take_nothing_let_the_layouts_go() {
let at = stamp(1, true, 1);
let t = object("Out of view");
let mut shelf = TestShelf::default();
shelf.keep(&t, at, at, laid_out(&t), BUDGET);
for _ in 1..FRAMES {
shelf.frame_done();
}
assert!(shelf.bytes() > 0, "the last frame has not ended");
// Another open starts the count again.
shelf.keep(&t, at, at, laid_out(&t), BUDGET);
shelf.frame_done();
assert!(shelf.bytes() > 0, "a keep starts the count again");
for _ in 1..FRAMES {
shelf.frame_done();
}
assert_eq!(shelf.bytes(), 0);
assert!(shelf.take(&t, at).is_none());
}
#[test]
fn the_budget_counts_frames_paths_and_markers() {
let t = object("Type");
let mut l = laid_out(&t);
let plain = bytes_of(&t, &l);
l.frames = vec![kurbo::Rect::ZERO; 1000];
let with_frames = bytes_of(&t, &l);
l.markers = vec![ListMarker { glyphs: 0..0, tab: 0, text: "x".repeat(1000) }];
let with_markers = bytes_of(&t, &l);
let mut area = t.clone();
let wide = kurbo::BezPath::from_vec(
std::iter::once(kurbo::PathEl::MoveTo(kurbo::Point::ZERO))
.chain((1..1000).map(|i| kurbo::PathEl::LineTo(kurbo::Point::new(f64::from(i), 0.0))))
.collect(),
);
area.kind = TextKind::Area { frame: PathData::from_bezpath(&wide) };
let mut wrapped = t.clone();
wrapped.wrap = vec![WrapShape { path: PathData::from_bezpath(&wide), wrap: TextWrap::default() }];
let mut paragraphs = t.clone();
paragraphs.paras = vec![paragraphs.para.clone(); 100];
let mut styled = t.clone();
styled.runs[0].style.font_family = "x".repeat(1000);
let mut featured = t.clone();
featured.runs[0].style.features = vec!["x".repeat(1000)];
let mut swatched = t.clone();
swatched.runs[0].style.fill = Paint::Solid { color: Color::BLACK, swatch: Some("x".repeat(1000)), tint: 1.0 };
let mut dashed = t.clone();
dashed.runs[0].style.stroke_dash = Some(Dash { pattern: vec![1.0; 1000], ..Dash::default() });
let mut tabbed = t.clone();
tabbed.para.tabs = vec![TabStop { position: 10.0, align: TabAlign::default(), leader: "x".repeat(1000), align_on: '.' }];
let mut with_ruby = t.clone();
with_ruby.ruby = vec![RubySpan::new(0, 1, "x".repeat(1000))];
let mut with_carets = laid_out(&t);
let no_carets = bytes_of(&t, &with_carets);
with_carets.glyphs[0].carets = vec![(0, 0.0); 1000];
let anchors = 999 * size_of::<vectorcraft_geom::Anchor>();
let counted = [
("frames", with_frames >= plain + 1000 * size_of::<kurbo::Rect>()),
("markers", with_markers >= with_frames + 1000),
("the frame's path", bytes_of(&area, &l) >= with_markers + anchors),
("the wrap's path", bytes_of(&wrapped, &l) >= with_markers + anchors),
("paragraphs", bytes_of(&paragraphs, &l) >= with_markers + 100 * size_of::<vectorcraft_doc::ParaStyle>()),
("a run's family", bytes_of(&styled, &l) >= with_markers + 900),
("a run's features", bytes_of(&featured, &l) >= with_markers + 1000),
("a swatch's name", bytes_of(&swatched, &l) >= with_markers + 1000),
("a stroke's dashes", bytes_of(&dashed, &l) >= with_markers + 1000 * size_of::<f64>()),
("a tab's leader", bytes_of(&tabbed, &l) >= with_markers + 1000),
("ruby text", bytes_of(&with_ruby, &l) >= with_markers + 1000),
("carets", bytes_of(&t, &with_carets) >= no_carets + 1000 * size_of::<(usize, f64)>()),
];
let missed: Vec<&str> = counted.iter().filter(|(_, ok)| !ok).map(|(name, _)| *name).collect();
assert!(missed.is_empty(), "not counted: {missed:?}");
}
#[test]
fn a_taken_layout_stays_within_the_budget_after_many_keeps() {
// Taken layouts leave nothing behind that a later keep has to count.
let at = stamp(1, true, 1);
let t = object("Type");
let budget = bytes_of(&t, &laid_out(&t));
let mut shelf = TestShelf::default();
for _ in 0..50 {
shelf.keep(&t, at, at, laid_out(&t), budget);
assert!(shelf.take(&t, at).is_some());
assert_eq!(shelf.bytes(), 0);
}
}
+1
View File
@@ -1354,6 +1354,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
When a path object moves without changing shape, the renderer draws its stroke from the outline it made before the move, moved by the same offset. It outlines the stroke again when the path is scaled, rotated or edited, when the stroke changes, and when the move takes the view past the part of a long path it outlined. The pixels are the same. At 1600 × 1200, the frame after a 1 pt nudge takes 123 ms instead of 403 ms with an open path of 200,000 anchors in view (103 ms with nothing changed), and 13 ms instead of 26 ms with a closed path of 50,000 anchors whose stroke is dashed and has a width profile.
When the stroke of a path moved this way has a gradient along or across it, the renderer also draws the gradient from the slices it made before the move, moved by the same offset. It makes the slices again when the path is scaled, rotated or edited, and when the stroke or its gradient changes. The pixels are the same. On Windows at 1600 × 1000, the frame after a 1 pt nudge of an open path of 200,000 anchors takes 75 ms instead of 1,237 ms with the gradient along the stroke and 69 ms instead of 1,420 ms with it across, and with an open path of 50,000 anchors 22 ms instead of 313 ms and 21 ms instead of 352 ms (medians of five interleaved runs).
The renderer keeps the path of a path or compound path of 1,024 anchors or more by object, with the bounds of its segments and of its miter spikes in runs of 128 segments. When anchors move and the subpaths and anchor counts stay the same, the next frame changes only the segments that start or end at a moved anchor and measures again only the runs that hold them or the segments with length before them, instead of making the whole path again and measuring it whole for culling. The pixels and the cull bounds are the same. On Windows at 1600 × 1200, the frame after one anchor moves takes 8.2 ms instead of 30.1 ms with an open path of 200,000 anchors, 4.3 ms instead of 10.4 ms with a closed path of 50,000 anchors, and 6.6 ms instead of 11.9 ms with the filled closed path of 50,000 anchors and a dashed stroke (medians of five runs of `anchor_bench`).
Opening a document keeps the layouts it makes for its type objects, up to 128 MiB, and the first frame uses them instead of laying those objects out again. It does not keep a layout with bitmap or color glyphs or a text object with a gradient. The frame uses a layout only for a text object equal to the one laid out, and only while the fonts and the hyphenation exceptions have not changed. It uses each layout once. The renderer drops the rest when a frame that used one ends, and all of them after eight frames that use none. The frames of a threaded story are laid out while the file is read, and the first frame lays them out again. The pixels are the same. On Windows at 1600 × 1200 with other builds running, in medians of interleaved rounds of a local example, with 5,000 point type objects in view the first frame takes 242 ms instead of 280 ms (103 ms instead of 212 ms in the fastest round) and the open takes 237 ms instead of 178 ms (170 ms instead of 143 ms); with one area type object of 500 paragraphs the first frame takes 211 ms instead of 464 ms.
Type › Show Hidden Characters keeps the marks it places on each text object from one frame to the next. The canvas lays a text object out again when an edit changes it or its threaded story, or when the last frame that showed the marks did not draw it, and it lays out all type in view again when the fonts or the hyphenation exceptions change. With 1,000 two-line labels in view, drawing the marks takes 0.8 ms per frame in a release build instead of 19.7 ms.
A selection with more than 100,000 points to outline shows its bounds instead of each object's outline and anchors. Paths count their anchors. Area type and type on a path count the anchors of their frame or path, and point type counts six: its box's corners and the ends of its baseline marker. Images, symbol instances, placed documents, and area type or type on a path in perspective count the four corners of their box, and gradient meshes and envelopes count their points and the ends of their mesh lines. The hover highlight leaves out an object past that count, and such an object shows its bounds as the key object. With 32,768 type objects, images, symbol instances, gradient meshes or envelopes selected, the selection's outlines and anchors add fewer than 100 vertices to the canvas's mesh (#2460).
The canvas keeps the selected objects' outlines, anchors and handles from one frame to the next as one mesh. It builds an object's marks again when the object, its layer color or its selected anchors change. A pan moves the kept outlines and handles and places the anchor squares again. Each subpath and handle gets its triangles the first time it is in view. A frame with a new zoom or rotation draws the marks one shape at a time and keeps nothing, and the next frame at that view builds them. The pixels are the same, apart from two small differences: a kept outline's corner of exactly 90 degrees in a rotated view can keep its miter or cut corner after a pan, and kept vertices can move by rounding errors. On Windows at 1440 × 900, in a release build, with 2,000 selected circles of 50 anchors each in 10 fill colors, an idle frame takes 12.8 ms instead of 59.9 ms with the Selection tool and 29.8 ms instead of 192.1 ms with Direct Selection (medians of five runs). A frame during a zoom takes about what it did: 86.7 ms instead of 80.0 ms with the Selection tool.