diff --git a/ROADMAP.md b/ROADMAP.md index b41bf7b1..73b62ee6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/anim/src/timeline.rs b/crates/anim/src/timeline.rs index e5640662..54cd8d78 100644 --- a/crates/anim/src/timeline.rs +++ b/crates/anim/src/timeline.rs @@ -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> = 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 = 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 => {} } } } diff --git a/crates/engine/tests/show_paragraph_builds.rs b/crates/engine/tests/show_paragraph_builds.rs new file mode 100644 index 00000000..dcce7503 --- /dev/null +++ b/crates/engine/tests/show_paragraph_builds.rs @@ -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 { + 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, node: &str) -> String { + let mut id = || { + *ids += 1; + *ids + }; + let tgt = match para { + Some(p) => format!(r#""#), + None => format!(r#""#), + }; + format!( + r#"{tgt}style.visibility{tgt}"#, + id(), + id(), + id() + ) +} + +/// A click group holding `effects`. +fn click(ids: &mut u32, effects: &[String]) -> String { + *ids += 2; + format!( + r#"{}"#, + *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) -> Vec { + 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#"{main}"# + ); + let a = xml.find("").expect("timing"); + let b = xml.find("").expect("timing end") + "".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, 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 { + 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(""), "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) -> Presentation { + let bytes = with_timing(&saved("One\nTwo\nThree", &[0, 0, 0]), |ids, body, pic| { + let mut clicks: Vec = 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> = 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)> = 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)]); +} diff --git a/crates/model/src/anim.rs b/crates/model/src/anim.rs index b34fb412..a1f5943a 100644 --- a/crates/model/src/anim.rs +++ b/crates/model/src/anim.rs @@ -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 { + 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 = 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]); + } +} diff --git a/crates/pptx/src/read/timing.rs b/crates/pptx/src/read/timing.rs index 33e79a26..524f9ea2 100644 --- a/crates/pptx/src/read/timing.rs +++ b/crates/pptx/src/read/timing.rs @@ -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 { } /// Flatten the timing tree into the Animation Pane list. -pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, _shapes: &[Shape]) -> Vec { +pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes: &[Shape]) -> Vec { 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() } diff --git a/crates/pptx/src/write/timing.rs b/crates/pptx/src/write/timing.rs index 3839d992..66708bb4 100644 --- a/crates/pptx/src/write/timing.rs +++ b/crates/pptx/src/write/timing.rs @@ -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 = Default::default(); let mut main: Vec = vec![]; let mut triggered: Vec<(u32, Vec)> = 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, 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 = 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"); } diff --git a/docs/animation-parity.md b/docs/animation-parity.md index 517535be..c90e2046 100644 --- a/docs/animation-parity.md +++ b/docs/animation-parity.md @@ -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 |