Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 6b83c712da Animations: drop wall-clock bounds from the large morph and paragraph tests
On a loaded runner a ten-second bound can fail after every functional
assertion passed. The inputs stay large enough that a quadratic regression
hangs the test instead.
2026-11-02 20:56:50 -05:00
Dylan M. Taylor f7fe74cecd Animations: index morph matching and timeline lookups by shape
Morph pairing searched every shape of the next slide for each shape of the
current one, four times over, rebuilding shape text inside each comparison.
Timeline state looked up a shape's effects, paragraph owner and base colour
by scanning every effect, group and colour on the slide, once per shape and
paragraph drawn. Both are now indexed once, keeping the same match order.
2026-11-02 20:20:38 -05:00
3 changed files with 41 additions and 205 deletions
+3 -25
View File
@@ -811,32 +811,10 @@ fn clamp_selection(st: &mut DocState) {
/// Default for [`History::byte_limit`].
pub const UNDO_BYTE_BUDGET: usize = 256 << 20;
/// A rough size of a text body in memory: its paragraphs and runs, at their inline struct sizes.
fn text_cost(t: &deckcraft_model::text::TextBody) -> usize {
use deckcraft_model::text::{Paragraph, Run};
let (para, run) = (std::mem::size_of::<Paragraph>(), std::mem::size_of::<Run>());
t.paragraphs.iter().map(|p| para + p.runs.iter().map(|r| run + r.text.len()).sum::<usize>()).sum()
}
/// A rough size of comments in memory, replies included.
fn comments_cost(cs: &[deckcraft_model::Comment], depth: usize) -> usize {
let kids = |c: &deckcraft_model::Comment| if depth < 64 { comments_cost(&c.replies, depth + 1) } else { 0 };
cs.iter().map(|c| 256 + c.author.len() + c.initials.len() + c.text.len() + c.date.len() + kids(c)).sum()
}
/// A rough size of a slide's own data besides its shapes: notes, comments, tags and kept XML,
/// all copied with the slide when an edit changes it.
fn slide_extra_cost(s: &deckcraft_model::Slide) -> usize {
text_cost(&s.notes)
+ comments_cost(&s.comments, 0)
+ s.name.len()
+ s.tags.iter().map(|(k, v)| 48 + k.len() + v.len()).sum::<usize>()
+ s.raw_ext.as_ref().map_or(0, String::len)
}
/// A rough size of a shape in memory: the struct, its text and kept XML, its children.
fn shape_cost(sh: &deckcraft_model::Shape, depth: usize) -> usize {
let text: usize = sh.text.as_ref().map_or(0, text_cost);
let text: usize =
sh.text.as_ref().map_or(0, |t| t.paragraphs.iter().map(|p| 96 + p.runs.iter().map(|r| 128 + r.text.len()).sum::<usize>()).sum());
let kids: usize = if depth < 64 { sh.children().iter().map(|c| shape_cost(c, depth + 1)).sum() } else { 0 };
768 + text + sh.raw_ext.as_ref().map_or(0, String::len) + kids
}
@@ -854,7 +832,7 @@ pub fn undo_cost(doc: &Presentation, newer: &Presentation) -> usize {
let media: HashSet<*const Vec<u8>> = newer.media.iter().map(|m| Arc::as_ptr(&m.data)).collect();
let mut n = 64 * doc.slides.len();
for s in doc.slides.iter().filter(|s| !slides.contains(&Arc::as_ptr(s))) {
n += 512 + shapes_cost(&s.shapes) + slide_extra_cost(s);
n += 512 + shapes_cost(&s.shapes);
}
for m in doc.masters.iter().filter(|m| !masters.contains(&Arc::as_ptr(m))) {
n += 4096 + shapes_cost(&m.shapes) + m.layouts.iter().map(|l| 512 + shapes_cost(&l.shapes)).sum::<usize>();
-19
View File
@@ -3338,25 +3338,6 @@ fn undo_history_is_capped_by_memory_as_well_as_steps() {
assert_eq!(s.doc().unwrap().history.undo.len(), 120);
}
/// A slide's notes count toward the undo budget: small edits to a slide with large notes keep a
/// copy of those notes per step, and must not slip past the budget as if the slide were empty.
#[test]
fn undo_history_counts_slide_notes() {
let mut s = Session::with_new();
let notes = "n".repeat(1 << 20);
s.edit(|doc, _| {
std::sync::Arc::make_mut(&mut doc.slides[0]).notes = deckcraft_model::text::TextBody::from_text(&notes);
Ok(())
})
.unwrap();
s.doc_mut().unwrap().history = History { limit: 500, byte_limit: 8 << 20, ..Default::default() };
for i in 0..40 {
s.execute("slide.rename", &json!({"name": format!("v{i}"), "index": 0})).unwrap();
}
let n = s.doc().unwrap().history.undo.len();
assert!((1..=8).contains(&n), "{n} steps of 1 MB notes kept under an 8 MB budget");
}
#[test]
fn picture_styles_replace_each_other_in_one_step() {
const PNG: &[u8] = &[
+38 -161
View File
@@ -37,146 +37,56 @@ pub struct ShapeParams<'a> {
pub language: Option<&'a str>,
}
/// Most bytes of text given to the shaper at once. The shaper lets one call grow its buffers to
/// 256 glyphs per character (a font's substitutions decide how many), so a long item is shaped in
/// pieces of this size, each with the rest of the paragraph as context.
const MAX_SHAPE_CHUNK: usize = 1024;
/// Most glyphs kept per character of a piece: a piece the font expands further is laid out one
/// glyph per character from the cmap instead.
const MAX_GLYPHS_PER_CHAR: usize = 32;
/// Shape `para[range]` with `face`, the rest of `para` as context. Glyphs are in visual order
/// (left to right) and clusters are byte offsets into `para`. `map` substitutes characters (All
/// Caps) without moving clusters. An empty or out-of-bounds range shapes nothing.
pub fn shape_range(face: &FontFace, para: &str, range: Range<usize>, params: &ShapeParams, map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
let (Some(_), Some(text), Some(_)) = (para.get(..range.start), para.get(range.clone()), para.get(range.end..)) else {
let (Some(pre), Some(text), Some(post)) = (para.get(..range.start), para.get(range.clone()), para.get(range.end..)) else {
return Vec::new();
};
let chunks = chunks(text, MAX_SHAPE_CHUNK);
let mut pieces: Vec<Vec<ShapedGlyph>> = Vec::with_capacity(chunks.len());
let mut out = Vec::with_capacity(text.len());
let shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
// Pieces of one item share the script the whole item would be guessed to have.
let item_script = (chunks.len() > 1 && params.script.is_none()).then(|| {
let mut probe = UnicodeBuffer::new();
for c in text.chars() {
probe.add(map(c), 0);
}
probe.guess_segment_properties();
probe.script()
});
for c in &chunks {
let r = (range.start + c.start)..(range.start + c.end);
let (Some(pre), Some(piece), Some(post)) = (para.get(..r.start), para.get(r.clone()), para.get(r.end..)) else { continue };
let mut buf = UnicodeBuffer::new();
buf.set_pre_context(pre);
buf.set_post_context(post);
for (i, c) in piece.char_indices() {
buf.add(map(c), (r.start + i) as u32);
}
buf.guess_segment_properties();
if let Some(s) = params.script.and_then(|t| Script::from_iso15924_tag(Tag::new(&t))) {
buf.set_script(s);
} else if let Some(s) = item_script {
buf.set_script(s);
}
if let Some(l) =
params.language.or_else(|| params.script.and_then(crate::script::default_language)).and_then(|l| l.parse::<Language>().ok())
{
buf.set_language(l);
}
buf.set_direction(if params.rtl { Direction::RightToLeft } else { Direction::LeftToRight });
let gb = shaper.shape(buf, ShapeOptions::new().features(params.features));
let budget = piece.chars().count().saturating_mul(MAX_GLYPHS_PER_CHAR);
if gb.len() > budget {
// Font-controlled expansion past anything real text needs: keep it bounded.
pieces.push(cmap_glyphs(face, para, r, params.rtl, &map));
continue;
}
pieces.push(
gb.glyph_infos()
.iter()
.zip(gb.glyph_positions())
.map(|(info, pos)| ShapedGlyph {
gid: info.glyph_id,
cluster: info.cluster as usize,
x_advance: pos.x_advance,
x_offset: pos.x_offset,
y_offset: pos.y_offset,
})
.collect(),
);
let mut buf = UnicodeBuffer::new();
buf.set_pre_context(pre);
buf.set_post_context(post);
for (i, c) in text.char_indices() {
buf.add(map(c), (range.start + i) as u32);
}
buf.guess_segment_properties();
if let Some(s) = params.script.and_then(|t| Script::from_iso15924_tag(Tag::new(&t))) {
buf.set_script(s);
}
if let Some(l) = params.language.or_else(|| params.script.and_then(crate::script::default_language)).and_then(|l| l.parse::<Language>().ok())
{
buf.set_language(l);
}
buf.set_direction(if params.rtl { Direction::RightToLeft } else { Direction::LeftToRight });
let gb = shaper.shape(buf, ShapeOptions::new().features(params.features));
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
out.push(ShapedGlyph {
gid: info.glyph_id,
cluster: info.cluster as usize,
x_advance: pos.x_advance,
x_offset: pos.x_offset,
y_offset: pos.y_offset,
});
}
});
if shaped.is_none() {
if shaped.is_none()
&& let Some(f) = face.skrifa()
{
// No shaper for this face: one glyph per character from the cmap, in visual order.
return cmap_glyphs(face, para, range, params.rtl, &map);
}
// Each piece is in visual order; right to left, the later pieces come first.
if params.rtl {
pieces.reverse();
}
let mut out = Vec::with_capacity(pieces.iter().map(Vec::len).sum());
for p in pieces {
out.extend(p);
}
out
}
/// One glyph per character of `para[range]` from the cmap, in visual order.
fn cmap_glyphs(face: &FontFace, para: &str, range: Range<usize>, rtl: bool, map: &impl Fn(char) -> char) -> Vec<ShapedGlyph> {
let (Some(text), Some(f)) = (para.get(range.clone()), face.skrifa()) else { return Vec::new() };
let cmap = f.charmap();
let gm = f.glyph_metrics(Size::unscaled(), face.location());
let mut out = Vec::with_capacity(text.len());
for (i, c) in text.char_indices() {
let g = cmap.map(map(c)).unwrap_or_default();
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
out.push(ShapedGlyph { gid: g.to_u32(), cluster: range.start + i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
}
if rtl {
out.reverse();
}
out
}
/// `text` cut into byte ranges of at most `max` bytes (or one character, if longer), at the last
/// space of a piece where there is one, else before a character that starts a cluster (not a
/// combining mark or joiner, not after a joiner). Text that fits is one piece.
fn chunks(text: &str, max: usize) -> Vec<Range<usize>> {
use unicode_bidi::BidiClass::{BN, NSM};
let mut out = Vec::new();
let mut start = 0;
while text.len() - start > max {
let window = text.get(start..).unwrap_or_default();
let mut space = None;
let mut boundary = None;
let mut any = None;
let mut prev: Option<char> = None;
for (i, c) in window.char_indices() {
if i > max {
break;
}
if i > 0 {
any = Some(i);
let joined = prev.is_some_and(|p| matches!(unicode_bidi::bidi_class(p), BN));
if !joined && !matches!(unicode_bidi::bidi_class(c), NSM | BN) {
boundary = Some(i);
if prev.is_some_and(char::is_whitespace) {
space = Some(i);
}
}
}
prev = Some(c);
let cmap = f.charmap();
let gm = f.glyph_metrics(Size::unscaled(), face.location());
for (i, c) in text.char_indices() {
let g = cmap.map(map(c)).unwrap_or_default();
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
out.push(ShapedGlyph { gid: g.to_u32(), cluster: range.start + i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
}
if params.rtl {
out.reverse();
}
// A piece always moves forward by at least one character.
let first = window.chars().next().map_or(window.len(), char::len_utf8);
let cut = space.filter(|&i| i >= max / 2).or(boundary).or(any).unwrap_or(first).max(first);
out.push(start..start + cut);
start += cut;
}
if start < text.len() || out.is_empty() {
out.push(start..text.len());
}
out
}
@@ -211,37 +121,4 @@ mod tests {
// Not a char boundary.
assert!(shape_range(&face, "سنة", 1..2, &ShapeParams::default(), |c| c).is_empty());
}
#[test]
fn long_items_are_shaped_in_bounded_pieces() {
for text in ["word ".repeat(2_000), "e\u{301}".repeat(2_000), "x".repeat(5_000), "\u{1f469}\u{200d}\u{1f4bb} ".repeat(500)] {
let pieces = chunks(&text, MAX_SHAPE_CHUNK);
assert_eq!(pieces.first().map(|r| r.start), Some(0));
assert_eq!(pieces.last().map(|r| r.end), Some(text.len()));
for w in pieces.windows(2) {
assert_eq!(w[0].end, w[1].start);
}
for r in &pieces {
assert!(!r.is_empty() && r.len() <= MAX_SHAPE_CHUNK);
// Never before a combining mark, never after a joiner.
let rest = &text[r.end..];
assert!(!rest.starts_with('\u{301}'));
assert!(!text[..r.end].ends_with('\u{200d}') || rest.is_empty());
}
}
assert_eq!(chunks("short", MAX_SHAPE_CHUNK), vec![0..5]);
assert_eq!(chunks("", MAX_SHAPE_CHUNK), vec![0..0]);
}
#[test]
fn long_items_keep_every_cluster_in_visual_order() {
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
let text = "ab cd ".repeat(1_000);
let g = shape_range(&face, &text, 0..text.len(), &ShapeParams::default(), |c| c);
assert_eq!(g.len(), text.len());
assert!(g.windows(2).all(|w| w[0].cluster < w[1].cluster));
let rtl = shape_range(&face, &text, 0..text.len(), &ShapeParams { rtl: true, ..Default::default() }, |c| c);
assert_eq!(rtl.len(), text.len());
assert!(rtl.windows(2).all(|w| w[0].cluster > w[1].cluster));
}
}