Merge PR #883: PPTX: paragraph builds edited in the reference app keep their clicks

ROADMAP.md: union of log rows. docs/animation-parity.md: main's header; the by-paragraph row from the PR, the triggers row from main; both revision rows.
This commit is contained in:
aiongg
2026-10-24 06:27:19 +00:00
7 changed files with 432 additions and 58 deletions
+2
View File
@@ -172,6 +172,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) |
| 2026-10-16 | Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns to the slide and several to each other until the other choice is ticked; the choice holds for that selection and also drives Distribute |
| 2026-10-16 | Fitted canvas: discrete mouse-wheel steps navigate immediately, including rapid and batched input, while trackpad gestures retain their cooldown and zoomed slides still pan. |
| 2026-10-16 | PPTX import: a paragraph build edited in PowerPoint (paragraphs with their own start, other shapes' effects in between, builds by 2nd-level paragraphs) takes PowerPoint's clicks in the show; it used to be rebuilt as one build, 34 clicks instead of 10 on the reporter's slide. Paragraph builds with sub-points save as PowerPoint writes them (#844) |
| 2026-10-15 | CLI: .txt and .md export includes table cell text, which it used to skip silently (#814) |
| 2026-10-15 | A `.pptx` slide that can't be read is reported, not dropped in silence: `lostSlides` from `file.open`, a message and a repaired copy in the app, a warning from `deckcraft-cli` (#203) |
| 2026-10-16 | AutoFormat as you type: ordinals (1st, 22nd, 103rd) get superscript suffixes after a space or punctuation; Tools ▸ AutoCorrect and `text.autocorrect {ordinals}` turn it off |
@@ -295,6 +296,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-22 | trivial | Progress log: chart series pattern and no fills |
| 2026-10-15 | trivial | Progress log: Browse for Themes applies a template's master and layouts (#807) |
| 2026-10-15 | trivial | Progress log: Compare Reject and Reject All |
| 2026-10-16 | trivial | Progress log: PPTX paragraph builds edited in PowerPoint keep their clicks (#844) |
| 2026-10-14 | trivial | remote MCP checks reply IDs, envelopes and bounded newline framing (#718) |
| 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing |
| 2026-10-14 | minor | Community PR wave folded: headline breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) and the feature-area breadths from `cargo xtask parity`; one row per area; Upcoming item 2 merged; per-PR count notes dropped from the progress log. Ready stays ~55%, stage alpha |
+10 -13
View File
@@ -2,7 +2,7 @@
use std::collections::{HashMap, HashSet};
use deckcraft_model::anim::TextBuild;
use deckcraft_model::anim::{BuildRole, TextBuild, paragraph_build_roles};
use deckcraft_model::style::Fill;
use deckcraft_model::{AnimClass, AnimStart, Animation, ColorScheme, Rgba, SchemeSlot, ShapeId, Slide, Xfrm};
@@ -12,7 +12,6 @@ use crate::effects::{EffectSpec, exit_base, motion_path};
/// Delay between paragraphs of a by-paragraph build that isn't on click (seconds).
pub const PARAGRAPH_STAGGER: f64 = 0.5;
const MAX_EFFECTS: usize = 100_000;
const MAX_PARAGRAPHS: usize = 1_000;
const MAX_ITERATIONS: f64 = 1.0e6;
/// Which sequence an effect belongs to.
@@ -198,17 +197,15 @@ impl Timeline {
(None, TextBuild::ByParagraph) => {
let mut v: Vec<Option<usize>> = vec![];
if let Some(t) = shape.and_then(|s| s.text.as_ref()) {
let filled = |p: &deckcraft_model::text::Paragraph| p.runs.iter().any(|r| !r.text.trim().is_empty());
let top = t.paragraphs.iter().filter(|p| filled(p)).map(|p| p.level).min().unwrap_or(0);
let mut owner: Option<usize> = None;
for (i, p) in t.paragraphs.iter().enumerate() {
if p.level <= top && filled(p) && v.len() < MAX_PARAGRAPHS {
owner = Some(i);
v.push(Some(i));
} else if let Some(o) = owner
&& grouped.insert((a.shape, i, o))
{
tl.groups.push((a.shape, i, o));
for (i, role) in paragraph_build_roles(t).into_iter().enumerate() {
match role {
BuildRole::Top => v.push(Some(i)),
BuildRole::Member(o) => {
if grouped.insert((a.shape, i, o)) {
tl.groups.push((a.shape, i, o));
}
}
BuildRole::Unowned => {}
}
}
}
@@ -0,0 +1,262 @@
//! #844: a paragraph-by-paragraph build read from a PPTX file takes the clicks PowerPoint gives
//! it. The file lists one effect per paragraph, each with its own start (On Click or With
//! Previous); the show must not ask for more clicks, or fewer, than those effects.
use std::io::{Cursor, Read, Write};
use std::sync::Arc;
use deckcraft_anim::{ShowAction, ShowState};
use deckcraft_geom::Xfrm;
use deckcraft_model::anim::{AnimStart, Animation, TextBuild};
use deckcraft_model::text::TextBody;
use deckcraft_model::{LayoutType, Presentation, Shape, ShapeId, defaults};
use zip::write::SimpleFileOptions;
fn shape(p: &mut Presentation, name: &str, text: &str, y: f64) -> Shape {
Shape {
id: ShapeId(p.alloc_id().expect("id")),
name: name.into(),
xfrm: Some(Xfrm::new(100.0, y, 600.0, 150.0)),
text: Some(TextBody::from_text(text)),
..Default::default()
}
}
/// Two slides: "Body" (paragraphs `body`, at `levels`) and "Picture" on the first, nothing on
/// the second, saved as PPTX.
fn saved(body: &str, levels: &[u8]) -> Vec<u8> {
let mut p = defaults::new_presentation(None);
p.slides.clear();
let mut b = shape(&mut p, "Body", body, 50.0);
if let Some(t) = b.text.as_mut() {
for (para, lvl) in t.paragraphs.iter_mut().zip(levels) {
para.level = *lvl;
}
}
let pic = shape(&mut p, "Picture", "Picture", 300.0);
let anims = vec![
Animation { shape: b.id, text_build: TextBuild::ByParagraph, start: AnimStart::OnClick, duration_ms: 500, ..Default::default() },
Animation { shape: pic.id, start: AnimStart::WithPrevious, duration_ms: 500, ..Default::default() },
];
for (shapes, animations) in [(vec![b, pic], anims), (vec![], vec![])] {
let layout = defaults::layout_of_kind(&p, LayoutType::Blank).unwrap_or_default();
let mut s = defaults::new_slide(&mut p, layout).expect("slide");
s.shapes = shapes;
s.animations = animations;
p.slides.push(Arc::new(s));
}
deckcraft_pptx::export(&p).expect("export")
}
fn spid(xml: &str, name: &str) -> String {
let at = xml.find(&format!(r#"name="{name}""#)).expect("shape");
let head = &xml[..at];
let id = head.rfind(r#"id=""#).expect("id") + 4;
head[id..].split('"').next().expect("spid").to_string()
}
/// One effect node as PowerPoint writes it: Fade in on `spid` (paragraph `para`).
fn effect(ids: &mut u32, spid: &str, para: Option<u32>, node: &str) -> String {
let mut id = || {
*ids += 1;
*ids
};
let tgt = match para {
Some(p) => format!(r#"<p:spTgt spid="{spid}"><p:txEl><p:pRg st="{p}" end="{p}"/></p:txEl></p:spTgt>"#),
None => format!(r#"<p:spTgt spid="{spid}"/>"#),
};
format!(
r#"<p:par><p:cTn id="{}" presetID="10" presetClass="entr" presetSubtype="0" fill="hold" grpId="0" nodeType="{node}"><p:stCondLst><p:cond delay="0"/></p:stCondLst><p:childTnLst><p:set><p:cBhvr><p:cTn id="{}" dur="1" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst></p:cTn><p:tgtEl>{tgt}</p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="visible"/></p:to></p:set><p:animEffect transition="in" filter="fade"><p:cBhvr><p:cTn id="{}" dur="500"/><p:tgtEl>{tgt}</p:tgtEl></p:cBhvr></p:animEffect></p:childTnLst></p:cTn></p:par>"#,
id(),
id(),
id()
)
}
/// A click group holding `effects`.
fn click(ids: &mut u32, effects: &[String]) -> String {
*ids += 2;
format!(
r#"<p:par><p:cTn id="{}" fill="hold"><p:stCondLst><p:cond delay="indefinite"/></p:stCondLst><p:childTnLst><p:par><p:cTn id="{}" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst><p:childTnLst>{}</p:childTnLst></p:cTn></p:par></p:childTnLst></p:cTn></p:par>"#,
*ids - 1,
*ids,
effects.concat()
)
}
/// `bytes` with the first slide's timing replaced by `clicks` (built with `effect` and `click`
/// from the spids of Body and Picture), Body's build "By paragraph".
fn with_timing(bytes: &[u8], clicks: impl Fn(&mut u32, &str, &str) -> Vec<String>) -> Vec<u8> {
let mut src = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
for i in 0..src.len() {
let mut f = src.by_index(i).expect("entry");
let name = f.name().to_string();
let mut data = Vec::new();
f.read_to_end(&mut data).expect("read");
if name == "ppt/slides/slide1.xml" {
let xml = String::from_utf8(data).expect("utf8");
let (body, pic) = (spid(&xml, "Body"), spid(&xml, "Picture"));
let mut ids = 2;
let main = clicks(&mut ids, &body, &pic).concat();
let timing = format!(
r#"<p:timing><p:tnLst><p:par><p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot"><p:childTnLst><p:seq concurrent="1" nextAc="seek"><p:cTn id="2" dur="indefinite" nodeType="mainSeq"><p:childTnLst>{main}</p:childTnLst></p:cTn><p:prevCondLst><p:cond evt="onPrev" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:prevCondLst><p:nextCondLst><p:cond evt="onNext" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:nextCondLst></p:seq></p:childTnLst></p:cTn></p:par></p:tnLst><p:bldLst><p:bldP spid="{body}" grpId="0" build="p"/><p:bldP spid="{pic}" grpId="0" animBg="1"/></p:bldLst></p:timing>"#
);
let a = xml.find("<p:timing>").expect("timing");
let b = xml.find("</p:timing>").expect("timing end") + "</p:timing>".len();
data = format!("{}{timing}{}", &xml[..a], &xml[b..]).into_bytes();
}
out.start_file(name, SimpleFileOptions::default()).expect("start");
out.write_all(&data).expect("write");
}
out.finish().expect("finish").into_inner()
}
/// Clicks (Next with nothing playing) from the start of the first slide to the second one.
fn clicks_to_second_slide(pres: &Presentation) -> usize {
let mut s = ShowState::new(pres);
for n in 1..=50 {
match s.next(pres) {
ShowAction::PlayStep(_) => {
// The step clock runs out before the next click.
s.step_done(pres);
}
ShowAction::Slide(1) => return n,
a => panic!("unexpected {a:?}"),
}
}
usize::MAX
}
#[test]
fn a_paragraph_build_split_by_another_shapes_effect_takes_powerpoints_clicks() {
// As PowerPoint writes "Body: By paragraph, Picture: With Previous after the first
// paragraph": click 1 = Body ¶0 + Picture, click 2 = Body ¶1, click 3 = Body ¶2.
let bytes = with_timing(&saved("One\nTwo\nThree", &[0, 0, 0]), |ids, body, pic| {
let e0 = effect(ids, body, Some(0), "clickEffect");
let ep = effect(ids, pic, None, "withEffect");
let e1 = effect(ids, body, Some(1), "clickEffect");
let e2 = effect(ids, body, Some(2), "clickEffect");
vec![click(ids, &[e0, ep]), click(ids, &[e1]), click(ids, &[e2])]
});
let pres = deckcraft_pptx::import(&bytes).expect("import");
assert_eq!(ShowState::steps(&pres, 0), 3, "three click steps, as in the file");
assert_eq!(clicks_to_second_slide(&pres), 4, "three builds and one click to move on");
}
#[test]
fn a_build_by_second_level_paragraphs_takes_a_click_per_paragraph() {
// "By 2nd level paragraphs": the heading and each sub-point have their own click.
// Picture comes in with the last one, so nothing splits Body's build.
let bytes = with_timing(&saved("Question\nFirst\nSecond", &[0, 1, 1]), |ids, body, pic| {
let e0 = effect(ids, body, Some(0), "clickEffect");
let e1 = effect(ids, body, Some(1), "clickEffect");
let e2 = effect(ids, body, Some(2), "clickEffect");
let ep = effect(ids, pic, None, "withEffect");
vec![click(ids, &[e0]), click(ids, &[e1]), click(ids, &[e2, ep])]
});
let pres = deckcraft_pptx::import(&bytes).expect("import");
assert_eq!(ShowState::steps(&pres, 0), 3, "three click steps, as in the file");
}
/// The first slide's XML in a saved PPTX.
fn slide_xml(bytes: &[u8]) -> String {
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut xml = String::new();
zip.by_name("ppt/slides/slide1.xml").expect("slide").read_to_string(&mut xml).expect("read");
xml
}
/// Paragraph and start of each animation on the first slide.
fn effects(pres: &Presentation) -> Vec<(Option<u32>, AnimStart, TextBuild)> {
pres.slides[0].animations.iter().map(|a| (a.paragraph, a.start, a.text_build)).collect()
}
/// "By 1st level paragraphs" as PowerPoint writes it: a sub-point comes in with its parent.
fn first_level_build() -> Vec<u8> {
with_timing(&saved("One\nSub\nTwo", &[0, 1, 0]), |ids, body, pic| {
let e0 = effect(ids, body, Some(0), "clickEffect");
let e1 = effect(ids, body, Some(1), "withEffect");
let e2 = effect(ids, body, Some(2), "clickEffect");
let ep = effect(ids, pic, None, "withEffect");
vec![click(ids, &[e0, e1]), click(ids, &[e2, ep])]
})
}
#[test]
fn a_whole_first_level_build_still_reads_as_one_by_paragraph_animation() {
let pres = deckcraft_pptx::import(&first_level_build()).expect("import");
assert_eq!(effects(&pres)[0], (None, AnimStart::OnClick, TextBuild::ByParagraph));
assert_eq!(pres.slides[0].animations.len(), 2, "Body's build and Picture");
assert_eq!(ShowState::steps(&pres, 0), 2);
}
#[test]
fn a_by_paragraph_build_with_sub_points_survives_saving() {
let pres = deckcraft_pptx::import(&first_level_build()).expect("import");
let again = deckcraft_pptx::import(&deckcraft_pptx::export(&pres).expect("export")).expect("reimport");
assert_eq!(effects(&again), effects(&pres));
assert_eq!(ShowState::steps(&again, 0), 2, "the sub-point still comes in with its parent");
}
#[test]
fn paragraph_effects_save_as_powerpoint_writes_them_and_read_back_unchanged() {
let bytes = with_timing(&saved("One\nTwo\nThree", &[0, 0, 0]), |ids, body, pic| {
let e0 = effect(ids, body, Some(0), "clickEffect");
let ep = effect(ids, pic, None, "withEffect");
let e1 = effect(ids, body, Some(1), "clickEffect");
let e2 = effect(ids, body, Some(2), "clickEffect");
vec![click(ids, &[e0, ep]), click(ids, &[e1]), click(ids, &[e2])]
});
let pres = deckcraft_pptx::import(&bytes).expect("import");
let saved = deckcraft_pptx::export(&pres).expect("export");
let xml = slide_xml(&saved);
let body = spid(&xml, "Body");
let blds: Vec<&str> = xml.split("<p:bldP ").skip(1).filter(|b| b.starts_with(&format!(r#"spid="{body}""#))).collect();
// ¶0 is its own build (Picture sits between), ¶1 and ¶2 share one.
assert_eq!(blds.len(), 2, "{blds:?}");
assert!(blds.iter().all(|b| b.contains(r#"build="p""#) && !b.contains("animBg")), "{blds:?}");
let again = deckcraft_pptx::import(&saved).expect("reimport");
assert_eq!(effects(&again), effects(&pres));
assert_eq!(ShowState::steps(&again, 0), 3);
}
#[test]
fn a_by_paragraph_build_on_blank_text_saves_as_a_whole_shape_effect() {
let xml = slide_xml(&saved(" ", &[0]));
assert!(!xml.contains(r#"build="p""#), "no paragraph build without a paragraph to animate");
assert!(!xml.contains("<p:txEl>"), "the effect targets the whole shape");
}
/// Body's three paragraphs under `build="p"`, effects in the order `effects` gives them, then
/// Picture with the last one.
fn three_paragraphs(effects: impl Fn(&mut u32, &str) -> Vec<String>) -> Presentation {
let bytes = with_timing(&saved("One\nTwo\nThree", &[0, 0, 0]), |ids, body, pic| {
let mut clicks: Vec<String> = effects(ids, body).iter().map(|e| click(ids, std::slice::from_ref(e))).collect();
let ep = effect(ids, pic, None, "clickEffect");
clicks.push(click(ids, &[ep]));
clicks
});
deckcraft_pptx::import(&bytes).expect("import")
}
#[test]
fn a_reversed_build_keeps_its_order() {
let pres = three_paragraphs(|ids, body| (0..3).rev().map(|p| effect(ids, body, Some(p), "clickEffect")).collect());
let paras: Vec<Option<u32>> = pres.slides[0].animations.iter().map(|a| a.paragraph).collect();
assert_eq!(paras, vec![Some(2), Some(1), Some(0), None], "last paragraph first, as in the file");
}
#[test]
fn a_paragraph_with_its_own_duration_keeps_its_own_effect() {
let pres = three_paragraphs(|ids, body| {
(0..3)
.map(|p| {
let e = effect(ids, body, Some(p), "clickEffect");
if p == 1 { e.replace(r#"dur="500""#, r#"dur="900""#) } else { e }
})
.collect()
});
let durs: Vec<(Option<u32>, u32)> = pres.slides[0].animations.iter().map(|a| (a.paragraph, a.duration_ms)).collect();
assert_eq!(durs, vec![(Some(0), 500), (Some(1), 900), (Some(2), 500), (None, 500)]);
}
+56
View File
@@ -248,6 +248,46 @@ pub enum TextBuild {
ByLetter,
}
/// Most paragraphs a by-paragraph build animates separately; later ones follow the last of them.
pub const MAX_BUILD_PARAGRAPHS: usize = 1_000;
/// What a paragraph does in a by-paragraph build.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BuildRole {
/// Animated on its own: a filled paragraph at the shallowest filled level.
Top,
/// Follows the top paragraph with this index (deeper, empty or blank paragraphs after it).
Member(usize),
/// Before the first top paragraph: not animated.
Unowned,
}
/// Does the paragraph show any text? A by-paragraph build doesn't animate blank ones on their own.
pub fn build_filled(p: &crate::text::Paragraph) -> bool {
p.runs.iter().any(|r| !r.text.trim().is_empty())
}
/// The role of every paragraph of `text` in a by-paragraph build, by paragraph index: what the
/// timeline plays, and what the PPTX reader and writer map to one file effect per paragraph.
pub fn paragraph_build_roles(text: &crate::text::TextBody) -> Vec<BuildRole> {
let top = text.paragraphs.iter().filter(|p| build_filled(p)).map(|p| p.level).min().unwrap_or(0);
let mut tops = 0;
let mut owner: Option<usize> = None;
text.paragraphs
.iter()
.enumerate()
.map(|(i, p)| {
if p.level <= top && build_filled(p) && tops < MAX_BUILD_PARAGRAPHS {
tops += 1;
owner = Some(i);
BuildRole::Top
} else {
owner.map_or(BuildRole::Unowned, BuildRole::Member)
}
})
.collect()
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Animation {
@@ -606,3 +646,19 @@ mod option_label_tests {
}
}
}
#[cfg(test)]
mod build_role_tests {
use super::*;
use crate::text::TextBody;
#[test]
fn blank_and_deeper_paragraphs_follow_the_top_one_before_them() {
let mut t = TextBody::from_text("Before\nLead\n \nSub\nNext");
for (p, lvl) in t.paragraphs.iter_mut().zip([1, 0, 0, 1, 0]) {
p.level = lvl;
}
use BuildRole::*;
assert_eq!(paragraph_build_roles(&t), vec![Unowned, Top, Member(1), Member(1), Top]);
}
}
+55 -14
View File
@@ -1,7 +1,7 @@
//! Slide transitions (`p:transition`) and animations (`p:timing`).
use deckcraft_model::Shape;
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, TextBuild, Transition};
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, BuildRole, TextBuild, Transition, build_filled, paragraph_build_roles};
use deckcraft_model::{Shape, TextBody, find_shape};
use super::shapes::IdCtx;
use super::{Imp, Part, dml};
@@ -95,7 +95,7 @@ fn max_dur(e: &El, depth: usize) -> Option<u32> {
}
/// Flatten the timing tree into the Animation Pane list.
pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, _shapes: &[Shape]) -> Vec<Animation> {
pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes: &[Shape]) -> Vec<Animation> {
let _ = (imp, part);
let Some(tn) = timing.child("tnLst") else { return vec![] };
let mut found = vec![];
@@ -110,7 +110,7 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, _shapes:
.collect()
})
.unwrap_or_default();
let mut out: Vec<(Animation, u32, String)> = vec![];
let mut out: Vec<(Animation, u32, String, bool)> = vec![];
for f in found {
let c = f.ctn;
let class = AnimClass::from_xml(c.attr("presetClass").unwrap_or("entr"));
@@ -172,15 +172,56 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, _shapes:
a.color = dml::color(clr);
}
let grp = c.attr("grpId").unwrap_or("0").to_string();
// Collapse a paragraph-by-paragraph build into one animation.
if paragraph.is_some() && by_para.iter().any(|(s, g)| *s == file_id && *g == grp) {
if out.last().is_some_and(|(l, lf, lg)| *lf == file_id && *lg == grp && l.text_build == TextBuild::ByParagraph) {
continue;
}
a.text_build = TextBuild::ByParagraph;
a.paragraph = None;
}
out.push((a, file_id, grp));
let build = paragraph.is_some() && by_para.iter().any(|(s, g)| *s == file_id && *g == grp);
out.push((a, file_id, grp, build));
}
out.into_iter().map(|(a, _, _)| a).collect()
// A paragraph build's effects, one per paragraph, become one by-paragraph animation only when
// the timeline plays that back exactly: PowerPoint lets each paragraph keep its own start and
// effect, and other shapes' effects can sit between them (#844).
let same_build =
|x: &(Animation, u32, String, bool), y: &(Animation, u32, String, bool)| x.3 && y.3 && x.1 == y.1 && x.2 == y.2 && x.0.trigger == y.0.trigger;
let mut anims = Vec::with_capacity(out.len());
for run in out.chunk_by(same_build) {
let effects: Vec<&Animation> = run.iter().map(|(a, ..)| a).collect();
let build = run.first().is_some_and(|r| r.3);
let text = effects.first().and_then(|a| find_shape(shapes, a.shape)).and_then(|s| s.text.as_ref());
match (effects.first(), text) {
(Some(first), Some(text)) if build && plays_as_build(&effects, text) => {
anims.push(Animation { text_build: TextBuild::ByParagraph, paragraph: None, ..(*first).clone() });
}
_ => anims.extend(effects.into_iter().cloned()),
}
}
anims
}
/// Are `effects` (paragraph effects of one shape, in file order) what a by-paragraph build of the
/// first one plays: one effect per paragraph the build animates, in paragraph order, later top
/// paragraphs on click (after previous when the build doesn't start on click), deeper ones with
/// their parent, all with the same settings?
fn plays_as_build(effects: &[&Animation], text: &TextBody) -> bool {
let roles = paragraph_build_roles(text);
let expected = roles.iter().enumerate().filter(|(i, r)| match r {
BuildRole::Top => true,
BuildRole::Member(_) => text.paragraphs.get(*i).is_some_and(build_filled),
BuildRole::Unowned => false,
});
let Some(first) = effects.first() else { return false };
let settings = |a: &Animation| Animation { start: AnimStart::OnClick, paragraph: None, ..a.clone() };
let base = settings(first);
let mut matched = 0;
for (k, (i, role)) in expected.enumerate() {
let Some(a) = effects.get(k) else { return false };
let start_ok = match role {
BuildRole::Top if k == 0 => true,
BuildRole::Top if first.start == AnimStart::OnClick => a.start == AnimStart::OnClick,
BuildRole::Top => a.start == AnimStart::AfterPrevious,
_ => a.start == AnimStart::WithPrevious,
};
if a.paragraph != u32::try_from(i).ok() || !start_ok || settings(a) != base {
return false;
}
matched = k + 1;
}
matched == effects.len()
}
+45 -30
View File
@@ -1,7 +1,7 @@
//! Transitions and the animation timing tree.
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, TextBuild, Transition};
use deckcraft_model::{Shape, ShapeKind, find_shape};
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, BuildRole, TextBuild, Transition, build_filled, paragraph_build_roles};
use deckcraft_model::{Shape, ShapeId, ShapeKind, find_shape};
use super::shapes::IdMap;
use super::{Exp, Out};
@@ -565,53 +565,68 @@ fn click_pars(w: &mut W, x: &mut Exp, ids: &mut Ids, clicks: &Groups, interactiv
}
}
fn para_count(s: &Shape) -> u32 {
s.text.as_ref().map(|t| t.paragraphs.iter().filter(|p| !p.is_empty()).count()).unwrap_or(0).min(10_000) as u32
}
/// Write `p:timing` for a slide's animations.
pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idmap: &IdMap) {
let mut grp_count: std::collections::HashMap<u32, u32> = Default::default();
let mut main: Vec<Eff> = vec![];
let mut triggered: Vec<(u32, Vec<Eff>)> = vec![];
let mut bld: Vec<(u32, u32, bool)> = vec![];
// The last paragraph effect written: (shape, class, trigger, grpId).
let mut prev_para: Option<(ShapeId, AnimClass, Option<ShapeId>, u32)> = None;
for a in anims.iter().take(10_000) {
let Some(spid) = idmap.get(a.shape) else {
log::warn!("pptx: animation on missing shape {} skipped", a.shape);
continue;
};
let Some(shape) = find_shape(shapes, a.shape) else { continue };
let grp = {
let c = grp_count.entry(spid).or_insert(0);
let g = *c;
*c += 1;
g
};
let text_sp = matches!(shape.kind, ShapeKind::Shape) && shape.text.is_some();
let is_text_sp = matches!(shape.kind, ShapeKind::Shape) && shape.text.as_ref().is_some_and(|t| !t.is_empty());
let by_para = a.text_build == TextBuild::ByParagraph && is_text_sp && para_count(shape) > 0;
let text = shape.text.as_ref().filter(|_| a.text_build == TextBuild::ByParagraph && is_text_sp);
let roles = text.map(paragraph_build_roles).unwrap_or_default();
let by_para = roles.contains(&BuildRole::Top);
// PowerPoint keeps a shape's consecutive paragraph effects in one build (`grpId`) marked
// `build="p"`; the reader turns them back into a by-paragraph build only if that plays them
// the same way.
let para_effect = !by_para && text_sp && a.paragraph.is_some();
let joins = para_effect && prev_para.is_some_and(|(s, c, t, _)| s == a.shape && c == a.class && t == a.trigger);
let grp = match prev_para {
Some((.., g)) if joins => g,
_ => {
let c = grp_count.entry(spid).or_insert(0);
let g = *c;
*c += 1;
g
}
};
prev_para = para_effect.then_some((a.shape, a.class, a.trigger, grp));
let mut effs = vec![];
if by_para {
// Paragraph indices in the file count empty paragraphs too.
let idxs: Vec<u32> = shape
.text
.as_ref()
.map(|t| t.paragraphs.iter().enumerate().filter(|(_, p)| !p.is_empty()).map(|(i, _)| i as u32).collect())
.unwrap_or_default();
for (k, pi) in idxs.into_iter().enumerate() {
let start = if k == 0 {
a.start
} else if a.start == AnimStart::OnClick {
AnimStart::OnClick
} else {
AnimStart::AfterPrevious
// One effect per paragraph the build animates on its own, deeper ones with their
// parent; indices in the file count empty paragraphs too.
let mut tops = 0;
for (i, role) in roles.iter().enumerate() {
let start = match role {
BuildRole::Top => {
tops += 1;
if tops == 1 {
a.start
} else if a.start == AnimStart::OnClick {
AnimStart::OnClick
} else {
AnimStart::AfterPrevious
}
}
BuildRole::Member(_) if text.and_then(|t| t.paragraphs.get(i)).is_some_and(build_filled) => AnimStart::WithPrevious,
_ => continue,
};
let Ok(pi) = u32::try_from(i) else { break };
effs.push(Eff { a, spid, start, paragraph: Some(pi), grp });
}
} else {
effs.push(Eff { a, spid, start: a.start, paragraph: a.paragraph, grp });
}
if matches!(shape.kind, ShapeKind::Shape) && shape.text.is_some() {
bld.push((spid, grp, by_para));
if text_sp && !joins {
bld.push((spid, grp, by_para || para_effect));
}
match a.trigger.and_then(|t| idmap.get(t)) {
Some(t) => match triggered.iter_mut().find(|(s, _)| *s == t) {
@@ -698,8 +713,8 @@ pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idm
w.close("p:tnLst");
if !bld.is_empty() {
w.open0("p:bldLst");
for (spid, grp, by_para) in bld {
w.empty("p:bldP", A::new().a("spid", spid).a("grpId", grp).o("build", by_para.then_some("p")).t("animBg", !by_para));
for (spid, grp, build_p) in bld {
w.empty("p:bldP", A::new().a("spid", spid).a("grpId", grp).o("build", build_p.then_some("p")).t("animBg", !build_p));
}
w.close("p:bldLst");
}
+2 -1
View File
@@ -66,7 +66,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
| Start On Click / With Previous / After Previous, duration, delay | yes | yes |
| Effect Options (direction, sequence by paragraph) | yes | yes |
| Effect dialog: sound, after-animation dim/hide, animate text by word/letter, smooth start/end, bounce end, repeat, rewind | yes | partly; repeat and bounce end round-trip; rewind lost on PPTX save (#77) |
| By-paragraph text builds, sub-bullets follow parents | yes | yes |
| By-paragraph text builds, sub-bullets follow parents | yes | yes; builds edited in PowerPoint (own start per paragraph, other effects in between, by 2nd-level paragraphs) import as one effect per paragraph and keep their clicks (#844) |
| Triggers (on click of a shape) | yes | yes; played in the show: a step per click, the sequence starts over after its last step, a click completes a playing step. Separate click steps, mixed starts and paragraph builds survive PPTX save (`trigger_steps.rs`) |
| Triggers on a media bookmark | yes | no |
| Animation Painter | yes | yes (#54) |
@@ -96,6 +96,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
|---|---|---|
| 2026-10-22 | minor | Animation Pane ▸ Play From: with an effect selected, the preview starts at that effect on the play-all clock; the row says yes and the estimate drops to ~14–26 h |
| 2026-10-15 | trivial | PPTX interactive sequences preserve later On Click effects and one click per paragraph; no change to playback or feature scores |
| 2026-10-16 | trivial | PPTX paragraph builds edited in PowerPoint keep their clicks; sub-points save With Previous of their parent (#844) |
| 2026-10-14 | trivial | Community PR wave folded: the Animation Pane timeline row says yes (it landed), preview split into canvas (yes) and from the selected effect (no); estimates drop the timeline and transition sounds (~15–27 h) |
| 2026-10-14 | trivial | Repeat and bounce end round-trip through PPTX; rewind is still lost (#77) |
| 2026-10-13 | minor | Animations ▸ Preview and Animation Pane ▸ Play All play the current slide's animations on the editor canvas; preview from the selected effect is still open |