Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 783b35eff9 Fonts: shape long items in bounded pieces
The shaper lets one call grow to 256 glyphs per input character, as an
embedded font's substitutions decide, and every glyph was then copied out.
Items longer than 1 KiB are now shaped in pieces (cut at a space where one
is near, never before a combining mark or after a joiner), each with the
rest of the paragraph as context and the item's script, and a piece that
expands past 32 glyphs per character falls back to one glyph per character
from the cmap.
2026-11-02 20:38:21 -05:00
6 changed files with 58 additions and 239 deletions
+29 -59
View File
@@ -1,7 +1,5 @@
//! Morph: matching shapes between two slides, interpolating their boxes and building the frames.
use std::collections::{HashMap, HashSet, VecDeque};
use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::{ColorRef, Fill, Geom, Presentation, Rgba, Shape, ShapeId, ShapeKind, Slide, Xfrm, walk};
@@ -17,66 +15,48 @@ fn text_of(s: &Shape) -> String {
/// kind and preset geometry if neither carries text. A text box with neither a name nor its text on
/// the other slide stays unpaired and fades in place, as in PowerPoint (#119). Each shape is used
/// once; top-level shapes only (groups move whole).
///
/// Each pass compares a key of the two shapes, so the shapes of `b` are indexed by key once per
/// pass: matching costs the sum of the two shape lists, not their product.
pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> {
let mut pairs: Vec<(ShapeId, ShapeId)> = Vec::new();
let mut used_a = vec![false; a.shapes.len()];
let mut used_b = vec![false; b.shapes.len()];
// Each shape's text, worked out once rather than inside every comparison.
let text_a: Vec<String> = a.shapes.iter().map(text_of).collect();
let text_b: Vec<String> = b.shapes.iter().map(text_of).collect();
let forced = |s: &Shape, _: &str| (s.name.starts_with("!!")).then(|| MorphKey::Name(s.name.clone()));
let named = |s: &Shape, _: &str| (!s.name.is_empty()).then(|| MorphKey::Name(s.name.clone()));
let same_text = |s: &Shape, t: &str| (!t.trim().is_empty()).then(|| MorphKey::Text(s.kind_name(), t.to_string()));
let same_geom = |s: &Shape, t: &str| match s.geom.preset_name() {
Some(g) if t.trim().is_empty() => Some(MorphKey::Geom(s.kind_name(), g.to_string())),
_ => None,
};
let passes: [&dyn Fn(&Shape, &str) -> Option<MorphKey>; 4] = [&forced, &named, &same_text, &same_geom];
for key in passes {
// Unused shapes of `b` by key, in slide order: the front of each queue is the first unused
// match, which is the one a search through `b` would find.
let mut index: HashMap<MorphKey, VecDeque<usize>> = HashMap::new();
for (j, sb) in b.shapes.iter().enumerate() {
if used_b.get(j).copied().unwrap_or(true) {
continue;
let passes: [&dyn Fn(&Shape, &Shape) -> bool; 4] = [
&|x, y| x.name.starts_with("!!") && x.name == y.name,
&|x, y| !x.name.is_empty() && x.name == y.name,
&|x, y| {
if x.kind_name() != y.kind_name() {
return false;
}
if let Some(k) = key(sb, text_b.get(j).map_or("", String::as_str)) {
index.entry(k).or_default().push_back(j);
}
}
if index.is_empty() {
continue;
}
let tx = text_of(x);
!tx.trim().is_empty() && tx == text_of(y)
},
&|x, y| {
x.kind_name() == y.kind_name()
&& x.geom.preset_name().is_some()
&& x.geom.preset_name() == y.geom.preset_name()
&& text_of(x).trim().is_empty()
&& text_of(y).trim().is_empty()
},
];
for pass in passes {
for (i, sa) in a.shapes.iter().enumerate() {
if used_a.get(i).copied().unwrap_or(true) {
continue;
}
let Some(k) = key(sa, text_a.get(i).map_or("", String::as_str)) else { continue };
let Some(j) = index.get_mut(&k).and_then(VecDeque::pop_front) else { continue };
let Some(sb) = b.shapes.get(j) else { continue };
if let Some(u) = used_a.get_mut(i) {
*u = true;
let found = b.shapes.iter().enumerate().find(|(j, sb)| !used_b.get(*j).copied().unwrap_or(true) && pass(sa, sb));
if let Some((j, sb)) = found {
if let Some(u) = used_a.get_mut(i) {
*u = true;
}
if let Some(u) = used_b.get_mut(j) {
*u = true;
}
pairs.push((sa.id, sb.id));
}
if let Some(u) = used_b.get_mut(j) {
*u = true;
}
pairs.push((sa.id, sb.id));
}
}
pairs
}
/// What one [`morph_pairs`] pass matches shapes on.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
enum MorphKey {
Name(String),
Text(&'static str, String),
Geom(&'static str, String),
}
fn lerp(a: f64, b: f64, t: f64) -> f64 {
let v = a + (b - a) * t;
if v.is_finite() {
@@ -276,23 +256,13 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b:
f.shapes.push((sh, old));
id
};
// Lookups by id, built once (the first shape or pair with an id wins, as a search would).
let paired_a: HashSet<ShapeId> = pairs.iter().map(|(ia, _)| *ia).collect();
let mut partner: HashMap<ShapeId, ShapeId> = HashMap::with_capacity(pairs.len());
for (ia, ib) in &pairs {
partner.entry(*ib).or_insert(*ia);
}
let mut a_by_id: HashMap<ShapeId, &Shape> = HashMap::with_capacity(a.shapes.len());
for x in &a.shapes {
a_by_id.entry(x.id).or_insert(x);
}
for sa in &a.shapes {
if !paired_a.contains(&sa.id) {
if !pairs.iter().any(|(ia, _)| *ia == sa.id) {
push(sa.clone(), true, 1.0 - s);
}
}
for sb in &b.shapes {
let pair = partner.get(&sb.id).and_then(|ia| a_by_id.get(ia).copied());
let pair = pairs.iter().find(|(_, ib)| *ib == sb.id).and_then(|(ia, _)| a.shapes.iter().find(|x| x.id == *ia));
let Some(sa) = pair else {
if !hidden_b(sb.id) {
push(sb.clone(), false, s);
-55
View File
@@ -1155,58 +1155,3 @@ fn many_by_paragraph_animations_on_one_shape_share_paragraph_targets() {
assert_eq!(tl.effects().len(), 6_000);
assert_eq!(tl.paragraph_targets(ShapeId(1)), vec![0, 1, 2]);
}
#[test]
fn morph_pairs_take_the_first_unused_match_in_slide_order() {
let mut a = Slide::default();
let mut b = Slide::default();
a.shapes = vec![shape(1, "Same"), shape(2, "Same"), shape(3, "Other")];
b.shapes = vec![shape(11, "Same"), shape(12, "Same"), shape(13, "Same")];
// By name first, in slide order; the shape of `a` left over pairs by kind and geometry with
// the shape of `b` left over.
assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(11)), (ShapeId(2), ShapeId(12)), (ShapeId(3), ShapeId(13))]);
}
#[test]
fn morph_of_large_slides_stays_linear() {
let n = 20_000u32;
let text = |i: u32, side: &str| Some(TextBody::from_text(&format!("{side} {i}")));
let mut a = Slide::default();
let mut b = Slide::default();
// Text no shape on the other slide shares: every pass looks for a partner and finds none.
a.shapes = (0..n).map(|i| Shape { text: text(i, "a"), ..shape(i + 1, "") }).collect();
b.shapes = (0..n).map(|i| Shape { text: text(i, "b"), ..shape(n + i + 1, "") }).collect();
// One named pair at opposite ends.
a.shapes[0].name = "Logo".into();
b.shapes[n as usize - 1].name = "Logo".into();
// No wall-clock bound (it would flake on a loaded runner): at this size a quadratic pairing
// takes minutes, so a regression shows as a test that hangs rather than a failed assertion.
assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(2 * n))]);
let p = deckcraft_model::defaults::new_presentation(None);
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
// Every shape fades in place; the named pair differs in text, so it cross-fades as two.
assert_eq!(f.shapes.len(), 2 * n as usize);
}
#[test]
fn paragraph_states_of_a_large_build_stay_linear() {
let n = 20_000usize;
let mut s = slide_with(vec![]);
let mut body = TextBody::from_text(&vec!["line"; n].join("\n"));
// One first-level paragraph owning every other paragraph.
for p in body.paragraphs.iter_mut().skip(1) {
p.level = 1;
}
s.shapes[0].text = Some(body);
let mut a = anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick);
a.text_build = TextBuild::ByParagraph;
s.animations = vec![a];
let tl = Timeline::new(&s, W, H);
// No wall-clock bound, as above: a per-paragraph scan of the slide would take minutes here.
assert_eq!(tl.paragraph_targets(ShapeId(1)).len(), n);
for p in 0..n {
assert!(!tl.state(ShapeId(1), Some(p), 0, 0.0).visible);
assert!(tl.state(ShapeId(1), Some(p), 1, 0.0).visible);
}
assert_eq!(tl.animated_shapes(), [ShapeId(1)]);
}
+12 -47
View File
@@ -103,41 +103,6 @@ pub struct Timeline {
/// By-paragraph builds animate first-level paragraphs; deeper paragraphs follow their parent:
/// (shape, member paragraph, owner paragraph).
groups: Vec<(ShapeId, usize, usize)>,
/// Lookups built with the timeline, so evaluating a shape or paragraph costs its own effects
/// rather than a scan of every effect, group and colour on the slide.
index: TimelineIndex,
}
#[derive(Clone, Debug, Default)]
struct TimelineIndex {
/// Effects (indices into `effects`, in order) by target shape and paragraph.
by_target: HashMap<(ShapeId, Option<usize>), Vec<usize>>,
/// Effects (indices into `effects`, in order) by target shape.
by_shape: HashMap<ShapeId, Vec<usize>>,
/// Owner paragraph of each member paragraph (the first group recorded wins).
owner: HashMap<(ShapeId, usize), usize>,
/// Groups (member, owner) by shape, in order.
groups_by_shape: HashMap<ShapeId, Vec<(usize, usize)>>,
/// Base colour by shape (the first recorded wins).
color: HashMap<ShapeId, Rgba>,
}
impl TimelineIndex {
fn build(effects: &[Effect], groups: &[(ShapeId, usize, usize)], colors: &[(ShapeId, Rgba)]) -> TimelineIndex {
let mut ix = TimelineIndex::default();
for (i, e) in effects.iter().enumerate() {
ix.by_target.entry((e.timing.shape, e.timing.paragraph)).or_default().push(i);
ix.by_shape.entry(e.timing.shape).or_default().push(i);
}
for &(shape, member, owner) in groups {
ix.owner.entry((shape, member)).or_insert(owner);
ix.groups_by_shape.entry(shape).or_default().push((member, owner));
}
for &(shape, c) in colors {
ix.color.entry(shape).or_insert(c);
}
ix
}
}
enum Status {
@@ -359,7 +324,6 @@ impl Timeline {
};
tl.colors.push((id, c));
}
tl.index = TimelineIndex::build(&tl.effects, &tl.groups, &tl.colors);
tl
}
@@ -449,12 +413,8 @@ impl Timeline {
/// Paragraph indices of `shape` animated separately (by-paragraph builds).
pub fn paragraph_targets(&self, shape: ShapeId) -> Vec<usize> {
let mine = self.index.by_shape.get(&shape).map_or(&[][..], Vec::as_slice);
let mut v: Vec<usize> = mine.iter().filter_map(|&i| self.effects.get(i)).filter_map(|e| e.timing.paragraph).collect();
let owners: HashSet<usize> = v.iter().copied().collect();
if let Some(groups) = self.index.groups_by_shape.get(&shape) {
v.extend(groups.iter().filter(|(_, owner)| owners.contains(owner)).map(|(member, _)| *member));
}
let mut v: Vec<usize> = self.effects.iter().filter(|e| e.timing.shape == shape).filter_map(|e| e.timing.paragraph).collect();
v.extend(self.groups.iter().filter(|g| g.0 == shape && v.contains(&g.2)).map(|g| g.1).collect::<Vec<_>>());
v.sort_unstable();
v.dedup();
v
@@ -462,8 +422,13 @@ impl Timeline {
/// Shapes with any effect.
pub fn animated_shapes(&self) -> Vec<ShapeId> {
let mut seen: HashSet<ShapeId> = HashSet::new();
self.effects.iter().map(|e| e.timing.shape).filter(|s| seen.insert(*s)).collect()
let mut v: Vec<ShapeId> = Vec::new();
for e in &self.effects {
if !v.contains(&e.timing.shape) {
v.push(e.timing.shape);
}
}
v
}
/// State of `shape` (or of one of its paragraphs, `para`) when `step` main steps have been
@@ -491,9 +456,9 @@ impl Timeline {
triggers: &[TriggerPlay],
stamps: &[StepStamp],
) -> AnimState {
let para = para.map(|p| self.index.owner.get(&(shape, p)).copied().unwrap_or(p));
let mine = self.index.by_target.get(&(shape, para)).map_or(&[][..], Vec::as_slice).iter().filter_map(|&i| self.effects.get(i));
let base_color = self.index.color.get(&shape).copied().unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
let para = para.map(|p| self.groups.iter().find(|g| g.0 == shape && g.1 == p).map(|g| g.2).unwrap_or(p));
let mine = self.effects.iter().filter(|e| e.timing.shape == shape && e.timing.paragraph == para);
let base_color = self.colors.iter().find(|(s, _)| *s == shape).map(|(_, c)| *c).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
let mut st = AnimState::default();
// Initial visibility comes from the first effect in the Animation Pane order.
let mut ordered: Vec<&Effect> = mine.collect();
+17 -20
View File
@@ -663,55 +663,52 @@ fn read_slides(
let pkg = imp.pkg;
let fallback_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();
let mut file_slide_ids: HashMap<u32, SlideId> = HashMap::new();
// A notes or comments part belongs to one slide; one shared by many must not be read (and
// copied) once per slide that names it.
let mut owned_parts: HashSet<String> = HashSet::new();
let mut notes_by_target: HashMap<String, TextBody> = HashMap::new();
for (file_id, path, id) in slide_ids {
let Some(doc) = docs.get(&opc::norm(path)) else { continue };
let part = Part::new(pkg, path, doc);
let layout = part.rels.first_of("slideLayout").and_then(|r| layout_by_part.get(&opc::norm(&r.target)).copied()).unwrap_or(fallback_layout);
let mut slide = read_slide(imp, &part, doc, *id, layout);
if let Some(notes) = slide_notes(imp, &part, &mut owned_parts) {
if let Some(notes) = slide_notes(imp, &part, &mut notes_by_target) {
slide.notes = notes;
}
read_slide_comments(pkg, pres_part, &part, &mut slide, &mut owned_parts);
read_slide_comments(pkg, pres_part, &part, &mut slide);
file_slide_ids.insert(*file_id, *id);
p.slides.push(Arc::new(slide));
}
file_slide_ids
}
/// The slide's notes text. A notes part is the notes of the first slide that names it.
fn slide_notes(imp: &mut Imp, part: &Part, owned: &mut HashSet<String>) -> Option<TextBody> {
/// The slide's notes text, read once per notes part.
fn slide_notes(imp: &mut Imp, part: &Part, cache: &mut HashMap<String, TextBody>) -> Option<TextBody> {
let n = part.rels.first_of("notesSlide")?;
if !owned.insert(opc::norm(&n.target)) {
log::warn!("pptx: notes part {} is named by more than one slide; kept on the first", n.target);
return None;
let key = opc::norm(&n.target);
if let Some(cached) = cache.get(&key) {
return Some(cached.clone());
}
let pkg = imp.pkg;
let nd = load(pkg, &n.target)?;
let np = Part::new(pkg, &n.target, &nd);
Some(read_notes(imp, &np, &nd.root))
let notes = read_notes(imp, &np, &nd.root);
cache.insert(key, notes.clone());
Some(notes)
}
/// Comments. A modern part and the legacy part can describe the same threads (PowerPoint
/// writes both): the modern one wins, its flattened legacy copy is skipped.
/// Each comments part is read once, for the first slide that names it.
fn read_slide_comments(pkg: &Package, pres_part: &Part, part: &Part, slide: &mut Slide, owned: &mut HashSet<String>) {
let mut parts: Vec<(Doc, bool, bool)> = vec![];
fn read_slide_comments(pkg: &Package, pres_part: &Part, part: &Part, slide: &mut Slide) {
let mut parts: Vec<(Doc, bool)> = vec![];
for c in part.rels.all_of("comments") {
// An already-owned part is not re-read, but its kind still decides modern precedence.
let fresh = owned.insert(opc::norm(&c.target));
if let Some(cd) = pkg.xml(&c.target) {
// The content type names the modern part; a package that types it only through a
// Default entry instead of an Override is still modern when its root is p188.
let modern = pkg.content_type(&c.target).as_deref() == Some(crate::write::CT_COMMENTS_MODERN) || cd.root.prefix() == "p188";
parts.push((cd, modern, fresh));
parts.push((cd, modern));
}
}
let has_modern = parts.iter().any(|(_, m, _)| *m);
for (cd, modern, fresh) in parts {
if !fresh || (has_modern && !modern) {
let has_modern = parts.iter().any(|(_, m)| *m);
for (cd, modern) in parts {
if has_modern && !modern {
continue;
}
slide.comments.extend(read_comments(pkg, pres_part, &cd.root, modern));
-27
View File
@@ -146,33 +146,6 @@ fn comments_on_several_slides_round_trip() {
assert_eq!(q.slides[2].comments[0].text, "Slide three");
}
/// A slide that names a modern part another slide already owns, plus its own legacy part, must
/// not import the legacy copy: modern precedence holds even when the modern part is not re-read.
#[test]
fn owned_modern_part_still_suppresses_later_legacy() {
let mut p = deck();
let layout = p.slides[0].layout;
let s2 = defaults::new_slide(&mut p, layout).expect("slide");
p.slides.push(Arc::new(s2));
Arc::make_mut(&mut p.slides[1]).comments = vec![Comment { author: "Cara".into(), text: "Slide two".into(), ..Default::default() }];
let bytes = deckcraft_pptx::export(&p).expect("export");
let rels2 = part(&bytes, "ppt/slides/_rels/slide2.xml.rels").replace("modernComment2.xml", "modernComment1.xml");
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).expect("zip");
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..z.len() {
let mut f = z.by_index(i).expect("entry");
out.start_file(f.name(), zip::write::SimpleFileOptions::default()).expect("start");
if f.name() == "ppt/slides/_rels/slide2.xml.rels" {
std::io::Write::write_all(&mut out, rels2.as_bytes()).expect("write");
} else {
std::io::copy(&mut f, &mut out).expect("copy");
}
}
let q = deckcraft_pptx::import(&out.finish().expect("finish").into_inner()).expect("import");
assert_eq!(q.slides[0].comments.len(), 2);
assert!(q.slides[1].comments.is_empty(), "{:?}", q.slides[1].comments);
}
/// A legacy-only package (what older tools read and write) still imports: 1/8 pt positions,
/// flattened replies.
#[test]
-31
View File
@@ -287,34 +287,3 @@ fn unreadable_slide_is_reported() {
assert_eq!(lost, vec!["ppt/slides/slide2.xml".to_string()]);
assert_eq!(deckcraft_pptx::import(&broken).expect("import").slides.len(), 1);
}
/// Many slides naming one notes part: it is read for the first slide only, not copied onto every
/// slide that names it.
#[test]
fn a_shared_notes_part_is_read_once() {
use std::sync::Arc;
let mut p = defaults::new_presentation(None);
let layout = p.slides[0].layout;
for _ in 0..3 {
let s = defaults::new_slide(&mut p, layout).expect("slide");
p.slides.push(Arc::new(s));
}
for (i, s) in p.slides.iter_mut().enumerate() {
Arc::make_mut(s).notes = deckcraft_model::text::TextBody::from_text(&format!("notes {i}"));
}
let mut es = entries(&deckcraft_pptx::export(&p).expect("export"));
for (n, d) in es.iter_mut() {
if n.starts_with("ppt/slides/_rels/") {
let x = String::from_utf8(d.clone()).expect("utf8");
let x = x
.replace("notesSlide2.xml", "notesSlide1.xml")
.replace("notesSlide3.xml", "notesSlide1.xml")
.replace("notesSlide4.xml", "notesSlide1.xml");
*d = x.into_bytes();
}
}
let q = deckcraft_pptx::import(&rezip(&es)).expect("import");
assert_eq!(q.slides.len(), 4);
assert_eq!(q.slides[0].notes.text(), "notes 0");
assert!(q.slides[1..].iter().all(|s| s.notes.text().is_empty()), "a shared notes part is not copied");
}