diff --git a/Cargo.lock b/Cargo.lock index b6b4afa9..3924f343 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1657,6 +1657,16 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-pptx" +version = "0.1.0" +dependencies = [ + "craft-drawingml", + "kurbo 0.13.1", + "serde", + "serde_json", +] + [[package]] name = "craft-prc" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 30072815..c9c524fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,6 +87,7 @@ craft-metafile = { path = "crates/metafile" } craft-offcrypto = { path = "crates/offcrypto" } craft-opc = { path = "crates/opc" } craft-ora = { path = "crates/ora" } +craft-pptx = { path = "crates/pptx" } craft-penpot = { path = "crates/penpot" } craft-pcraft-format = { path = "crates/pcraft-format" } craft-plotter = { path = "crates/plotter" } diff --git a/README.md b/README.md index 6c6f169f..5df97746 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,9 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = ", + /// Embedded sound media. + pub sound: Option, + /// Loop Until Next Sound (`p:stSnd loop="1"`). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub sound_loop: bool, + /// [Stop Previous Sound] (`p:endSnd`): the slide stops a sound still playing. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub stop_sound: bool, + /// Unmodelled transition XML kept for round-trip. + #[serde(skip_serializing_if = "Option::is_none")] + pub raw: Option, +} + +impl Default for Transition { + fn default() -> Self { + Transition { + kind: "none".into(), + option: String::new(), + duration_ms: 1000, + advance_on_click: true, + advance_after_ms: None, + sound: None, + sound_loop: false, + stop_sound: false, + raw: None, + } + } +} + +/// (id, label, category, default duration ms, effect options) +pub static TRANSITIONS: &[(&str, &str, &str, u32, &[&str])] = &[ + ("none", "None", "Subtle", 0, &[]), + ("morph", "Morph", "Subtle", 2000, &["objects", "words", "characters"]), + ("fade", "Fade", "Subtle", 700, &["smoothly", "throughBlack"]), + ("push", "Push", "Subtle", 1000, &["u", "d", "l", "r"]), + ("wipe", "Wipe", "Subtle", 1000, &["r", "l", "u", "d", "ru", "lu", "rd", "ld"]), + ("split", "Split", "Subtle", 1500, &["vertOut", "vertIn", "horzOut", "horzIn"]), + ("reveal", "Reveal", "Subtle", 2000, &["smoothlyLeft", "smoothlyRight", "blackLeft", "blackRight"]), + ("cut", "Cut", "Subtle", 0, &["", "throughBlack"]), + ("randomBar", "Random Bars", "Subtle", 1000, &["vert", "horz"]), + ("shape", "Shape", "Subtle", 2000, &["circle", "diamond", "plus", "in", "out"]), + ("uncover", "Uncover", "Subtle", 1000, &["l", "r", "u", "d", "lu", "ru", "ld", "rd"]), + ("cover", "Cover", "Subtle", 1000, &["l", "r", "u", "d", "lu", "ru", "ld", "rd"]), + ("flash", "Flash", "Subtle", 1000, &[]), + ("fallOver", "Fall Over", "Exciting", 2000, &["l", "r"]), + ("drape", "Drape", "Exciting", 2000, &["l", "r"]), + ("curtains", "Curtains", "Exciting", 2000, &[]), + ("wind", "Wind", "Exciting", 2000, &["l", "r"]), + ("prestige", "Prestige", "Exciting", 2000, &[]), + ("fracture", "Fracture", "Exciting", 2000, &[]), + ("crush", "Crush", "Exciting", 2000, &[]), + ("peelOff", "Peel Off", "Exciting", 1500, &["l", "r"]), + ("pageCurlDouble", "Page Curl", "Exciting", 1500, &["l", "r", "singleL", "singleR"]), + ("airplane", "Airplane", "Exciting", 2000, &["l", "r"]), + ("origami", "Origami", "Exciting", 2000, &["l", "r"]), + ("dissolve", "Dissolve", "Exciting", 1200, &[]), + ("checker", "Checkerboard", "Exciting", 1500, &["horz", "vert"]), + ("blinds", "Blinds", "Exciting", 1600, &["vert", "horz"]), + ("clock", "Clock", "Exciting", 2000, &["clockwise", "counterClockwise", "wedge"]), + ("ripple", "Ripple", "Exciting", 1400, &["center", "lu", "ru", "ld", "rd"]), + ("honeycomb", "Honeycomb", "Exciting", 3000, &[]), + ("glitter", "Glitter", "Exciting", 2500, &["l", "r", "u", "d", "hexL", "hexR", "hexU", "hexD"]), + ("vortex", "Vortex", "Exciting", 3000, &["l", "r", "u", "d"]), + ("shred", "Shred", "Exciting", 2000, &["stripsIn", "stripsOut", "particlesIn", "particlesOut"]), + ("switch", "Switch", "Exciting", 1250, &["l", "r"]), + ("flip", "Flip", "Exciting", 1250, &["l", "r"]), + ("gallery", "Gallery", "Exciting", 1500, &["l", "r"]), + ("cube", "Cube", "Exciting", 1250, &["l", "r", "u", "d"]), + ("doors", "Doors", "Exciting", 1400, &["vert", "horz"]), + ("box", "Box", "Exciting", 1250, &["l", "r", "u", "d"]), + ("comb", "Comb", "Exciting", 1000, &["horz", "vert"]), + ("zoom", "Zoom", "Exciting", 1000, &["in", "out", "rotate"]), + ("random", "Random", "Exciting", 2000, &[]), + ("pan", "Pan", "Dynamic Content", 1000, &["u", "d", "l", "r"]), + ("ferris", "Ferris Wheel", "Dynamic Content", 2000, &["l", "r"]), + ("conveyor", "Conveyor", "Dynamic Content", 2000, &["l", "r"]), + ("rotate", "Rotate", "Dynamic Content", 2000, &["l", "r", "u", "d"]), + ("window", "Window", "Dynamic Content", 1250, &["vert", "horz"]), + ("orbit", "Orbit", "Dynamic Content", 2000, &["l", "r", "u", "d"]), + ("flythrough", "Fly Through", "Dynamic Content", 1000, &["in", "out", "inBounce", "outBounce"]), +]; + +/// The Effect Options label PowerPoint shows for a transition option. Options are stored as the +/// file format's values: for moving transitions (Push, Wipe, Cover, Pan, Cube…) the direction +/// things move, so "l" is the effect coming "From Right"; Glitter and the preset effects (Fall +/// Over, Wind…) name the side itself; Ripple names where the wave heads. +pub fn transition_option_label(kind: &str, option: &str) -> String { + let side = |c: char| match c { + 'l' => "Left", + 'r' => "Right", + 'u' => "Top", + _ => "Bottom", + }; + let opposite = |c: char| match c { + 'l' => 'r', + 'r' => 'l', + 'u' => 'd', + _ => 'u', + }; + let corner = |o: &str, flip: bool| -> Option { + let mut cs = o.chars(); + let (h, v) = (cs.next()?, cs.next()?); + let (h, v) = if flip { (opposite(h), opposite(v)) } else { (h, v) }; + Some(format!("From {}-{}", side(v), side(h))) + }; + let moving = matches!( + kind, + "push" + | "wipe" + | "cover" + | "uncover" + | "pan" + | "vortex" + | "cube" + | "box" + | "rotate" + | "orbit" + | "gallery" + | "conveyor" + | "ferris" + | "switch" + | "flip" + ); + match (kind, option) { + (_, "horz") => "Horizontal".into(), + (_, "vert") => "Vertical".into(), + ("split", o) => format!("{} {}", if o.starts_with("horz") { "Horizontal" } else { "Vertical" }, if o.ends_with("In") { "In" } else { "Out" }), + ("fade" | "cut", "throughBlack") => "Through Black".into(), + ("fade", _) => "Smoothly".into(), + ("cut", _) => "Cut".into(), + ("clock", "counterClockwise") => "Counterclockwise".into(), + ("clock", "wedge") => "Wedge".into(), + ("clock", _) => "Clockwise".into(), + ("zoom", "rotate") => "Zoom and Rotate".into(), + ("ripple", "center") => "Center".into(), + ("ripple", o) => corner(o, true).unwrap_or_else(|| "Center".into()), + ("glitter", o) => { + let (pat, d) = match o.strip_prefix("hex") { + Some(d) => ("Hexagons", d.to_ascii_lowercase()), + None => ("Diamonds", o.to_string()), + }; + format!("{pat} From {}", side(d.chars().next().unwrap_or('l'))) + } + ("pageCurlDouble", o) => match o { + "singleL" => "Single Left".into(), + "singleR" => "Single Right".into(), + "r" => "Double Right".into(), + _ => "Double Left".into(), + }, + ("shred", o) => { + format!("{} {}", if o.starts_with("particles") { "Particles" } else { "Strips" }, if o.ends_with("Out") { "Out" } else { "In" }) + } + ("reveal", o) => format!( + "{} {}", + if o.starts_with("black") { "Through Black" } else { "Smoothly" }, + if o.ends_with("Right") { "From Right" } else { "From Left" } + ), + ("flythrough", o) => { + format!("{}{}", if o.starts_with("out") { "Out" } else { "In" }, if o.ends_with("Bounce") { " with Bounce" } else { "" }) + } + ("morph", o) => match o { + "words" => "Words".into(), + "characters" => "Characters".into(), + _ => "Objects".into(), + }, + (_, o) if o.len() == 2 && moving => corner(o, true).unwrap_or_default(), + (_, o) if o.len() == 1 && moving => format!("From {}", side(opposite(o.chars().next().unwrap_or('l')))), + (_, "l") => "Left".into(), + (_, "r") => "Right".into(), + (_, "u") => "Top".into(), + (_, "d") => "Bottom".into(), + (_, "in") => "In".into(), + (_, "out") => "Out".into(), + (_, o) => o.to_string(), + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AnimClass { + #[default] + Entrance, + Emphasis, + Exit, + Path, + Media, +} + +impl AnimClass { + pub fn xml(self) -> &'static str { + match self { + AnimClass::Entrance => "entr", + AnimClass::Emphasis => "emph", + AnimClass::Exit => "exit", + AnimClass::Path => "path", + AnimClass::Media => "mediacall", + } + } + pub fn from_xml(s: &str) -> Self { + match s { + "emph" => AnimClass::Emphasis, + "exit" => AnimClass::Exit, + "path" => AnimClass::Path, + "mediacall" => AnimClass::Media, + _ => AnimClass::Entrance, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AnimStart { + #[default] + OnClick, + WithPrevious, + AfterPrevious, +} + +/// Text build: as one object, all at once by paragraph, or by paragraph level. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TextBuild { + #[default] + AsOne, + ByParagraph, + ByWord, + 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: &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: &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 { + pub shape: ShapeId, + pub class: AnimClass, + /// Effect id from [`ANIMATIONS`] (e.g. `fade`, `fly`, `zoom`, `spin`, `pulse`, `lines`…). + pub effect: String, + /// Effect option (direction etc.). + pub option: String, + pub start: AnimStart, + pub duration_ms: u32, + pub delay_ms: u32, + /// Repeat count (0/1 = once); `u32::MAX` = until next click. + pub repeat: u32, + pub rewind: bool, + pub text_build: TextBuild, + /// Paragraph index when the effect animates one paragraph of a text body. + pub paragraph: Option, + /// Motion path in slide-relative units (SVG-like `M 0 0 L 0.25 0 E`). + pub path: Option, + /// Triggered by a click on another shape. + pub trigger: Option, + /// Emphasis amount: spin degrees, grow/shrink scale (1.5 = 150%), transparency… + pub amount: Option, + /// Emphasis colour (fill/line/font colour change). + pub color: Option, + /// PowerPoint preset id/subtype read from or written to files. + pub preset_id: Option, + pub preset_subtype: Option, + /// After animation: dim to colour, hide, hide on next click. + pub after: Option, + pub sound: Option, + pub smooth_start: f64, + pub smooth_end: f64, + pub bounce_end: f64, + pub auto_reverse: bool, +} + +impl Default for Animation { + fn default() -> Self { + Animation { + shape: ShapeId(0), + class: AnimClass::Entrance, + effect: "fade".into(), + option: String::new(), + start: AnimStart::OnClick, + duration_ms: 500, + delay_ms: 0, + repeat: 1, + rewind: false, + text_build: TextBuild::AsOne, + paragraph: None, + path: None, + trigger: None, + amount: None, + color: None, + preset_id: None, + preset_subtype: None, + after: None, + sound: None, + smooth_start: 0.0, + smooth_end: 0.0, + bounce_end: 0.0, + auto_reverse: false, + } + } +} + +/// One [`ANIMATIONS`] row: (id, label, class, PowerPoint preset id, default duration ms, options). +pub type AnimationInfo = (&'static str, &'static str, AnimClass, u32, u32, &'static [&'static str]); + +/// (id, label, class, PowerPoint preset id, default duration ms, options) +pub static ANIMATIONS: &[AnimationInfo] = &[ + ("appear", "Appear", AnimClass::Entrance, 1, 0, &[]), + ("fade", "Fade", AnimClass::Entrance, 10, 500, &[]), + ("fly", "Fly In", AnimClass::Entrance, 2, 500, &["b", "lb", "l", "lt", "t", "rt", "r", "rb"]), + ("float", "Float In", AnimClass::Entrance, 42, 1000, &["u", "d"]), + ("split", "Split", AnimClass::Entrance, 16, 500, &["horzIn", "horzOut", "vertIn", "vertOut"]), + ("wipe", "Wipe", AnimClass::Entrance, 22, 500, &["b", "l", "t", "r"]), + ("shape", "Shape", AnimClass::Entrance, 6, 2000, &["circleIn", "circleOut", "boxIn", "boxOut", "diamondIn", "diamondOut", "plusIn", "plusOut"]), + ("wheel", "Wheel", AnimClass::Entrance, 21, 2000, &["1", "2", "3", "4", "8"]), + ("randomBars", "Random Bars", AnimClass::Entrance, 14, 500, &["horz", "vert"]), + ("grow", "Grow & Turn", AnimClass::Entrance, 31, 1000, &[]), + ("zoom", "Zoom", AnimClass::Entrance, 53, 500, &["in", "out"]), + ("swivel", "Swivel", AnimClass::Entrance, 45, 2000, &["horz", "vert"]), + ("bounce", "Bounce", AnimClass::Entrance, 26, 2000, &[]), + ("blinds", "Blinds", AnimClass::Entrance, 3, 500, &["horz", "vert"]), + ("box", "Box", AnimClass::Entrance, 4, 2000, &["in", "out"]), + ("checkerboard", "Checkerboard", AnimClass::Entrance, 5, 500, &["across", "down"]), + ("circle", "Circle", AnimClass::Entrance, 6, 2000, &["in", "out"]), + ("diamond", "Diamond", AnimClass::Entrance, 8, 2000, &["in", "out"]), + ("dissolve", "Dissolve In", AnimClass::Entrance, 9, 500, &[]), + ("peek", "Peek In", AnimClass::Entrance, 12, 500, &["b", "l", "t", "r"]), + ("strips", "Strips", AnimClass::Entrance, 18, 500, &["lu", "ru", "ld", "rd"]), + ("expand", "Expand", AnimClass::Entrance, 55, 1000, &[]), + ("rise", "Rise Up", AnimClass::Entrance, 37, 1000, &[]), + ("spinner", "Spinner", AnimClass::Entrance, 49, 500, &[]), + ("pulse", "Pulse", AnimClass::Emphasis, 26, 500, &[]), + ("colorPulse", "Color Pulse", AnimClass::Emphasis, 27, 250, &[]), + ("teeter", "Teeter", AnimClass::Emphasis, 32, 1000, &[]), + ("spin", "Spin", AnimClass::Emphasis, 8, 2000, &["clockwise", "counterClockwise"]), + ("growShrink", "Grow/Shrink", AnimClass::Emphasis, 6, 2000, &["both", "horz", "vert"]), + ("desaturate", "Desaturate", AnimClass::Emphasis, 25, 500, &[]), + ("darken", "Darken", AnimClass::Emphasis, 24, 500, &[]), + ("lighten", "Lighten", AnimClass::Emphasis, 30, 500, &[]), + ("transparency", "Transparency", AnimClass::Emphasis, 9, 500, &[]), + ("objectColor", "Object Color", AnimClass::Emphasis, 19, 500, &[]), + ("complementaryColor", "Complementary Color", AnimClass::Emphasis, 21, 500, &[]), + ("lineColor", "Line Color", AnimClass::Emphasis, 7, 2000, &[]), + ("fillColor", "Fill Color", AnimClass::Emphasis, 1, 2000, &[]), + ("fontColor", "Font Color", AnimClass::Emphasis, 3, 2000, &[]), + ("underline", "Underline", AnimClass::Emphasis, 18, 500, &[]), + ("boldFlash", "Bold Flash", AnimClass::Emphasis, 10, 2000, &[]), + ("wave", "Wave", AnimClass::Emphasis, 34, 500, &[]), + ("disappear", "Disappear", AnimClass::Exit, 1, 0, &[]), + ("fadeOut", "Fade", AnimClass::Exit, 10, 500, &[]), + ("flyOut", "Fly Out", AnimClass::Exit, 2, 500, &["b", "lb", "l", "lt", "t", "rt", "r", "rb"]), + ("floatOut", "Float Out", AnimClass::Exit, 42, 1000, &["u", "d"]), + ("splitOut", "Split", AnimClass::Exit, 16, 500, &["horzIn", "horzOut", "vertIn", "vertOut"]), + ("wipeOut", "Wipe", AnimClass::Exit, 22, 500, &["b", "l", "t", "r"]), + ("shapeOut", "Shape", AnimClass::Exit, 6, 2000, &["circleIn", "circleOut"]), + ("wheelOut", "Wheel", AnimClass::Exit, 21, 2000, &["1", "2", "3", "4", "8"]), + ("randomBarsOut", "Random Bars", AnimClass::Exit, 14, 500, &["horz", "vert"]), + ("shrinkTurn", "Shrink & Turn", AnimClass::Exit, 31, 1000, &[]), + ("zoomOut", "Zoom", AnimClass::Exit, 53, 500, &["in", "out"]), + ("swivelOut", "Swivel", AnimClass::Exit, 45, 2000, &["horz", "vert"]), + ("bounceOut", "Bounce", AnimClass::Exit, 26, 2000, &[]), + ("dissolveOut", "Dissolve Out", AnimClass::Exit, 9, 500, &[]), + ("blindsOut", "Blinds", AnimClass::Exit, 3, 500, &["horz", "vert"]), + ("boxOut", "Box", AnimClass::Exit, 4, 2000, &["in", "out"]), + ("checkerboardOut", "Checkerboard", AnimClass::Exit, 5, 500, &["across", "down"]), + ("diamondOut", "Diamond", AnimClass::Exit, 8, 2000, &["in", "out"]), + ("peekOut", "Peek Out", AnimClass::Exit, 12, 500, &["b", "l", "t", "r"]), + ("stripsOut", "Strips", AnimClass::Exit, 18, 500, &["lu", "ru", "ld", "rd"]), + ("sinkDown", "Sink Down", AnimClass::Exit, 37, 1000, &[]), + ("spinnerOut", "Spinner", AnimClass::Exit, 49, 500, &[]), + ("contract", "Contract", AnimClass::Exit, 55, 1000, &[]), + ("lines", "Lines", AnimClass::Path, 64, 2000, &["down", "up", "right", "left"]), + ("arcs", "Arcs", AnimClass::Path, 37, 2000, &["down", "up", "right", "left"]), + ("turns", "Turns", AnimClass::Path, 43, 2000, &["down", "up", "right", "left"]), + ( + "shapes", + "Shapes", + AnimClass::Path, + 1, + 2000, + &["circle", "diamond", "hexagon", "triangle", "square", "trapezoid", "octagon", "parallelogram", "pentagon"], + ), + ("loops", "Loops", AnimClass::Path, 61, 2000, &[]), + ("diagonalDownRight", "Diagonal Down Right", AnimClass::Path, 49, 2000, &[]), + ("diagonalUpRight", "Diagonal Up Right", AnimClass::Path, 56, 2000, &[]), + ("zigzag", "Zigzag", AnimClass::Path, 38, 2000, &[]), + ("sineWave", "Sine Wave", AnimClass::Path, 40, 2000, &[]), + ("pathWave", "Wave", AnimClass::Path, 47, 2000, &[]), + ("decayingWave", "Decaying Wave", AnimClass::Path, 60, 2000, &[]), + ("heartbeat", "Heartbeat", AnimClass::Path, 45, 2000, &[]), + ("sCurve1", "S Curve 1", AnimClass::Path, 59, 2000, &[]), + ("sCurve2", "S Curve 2", AnimClass::Path, 39, 2000, &[]), + ("stairsDown", "Stairs Down", AnimClass::Path, 62, 2000, &[]), + ("bounceRight", "Bounce Right", AnimClass::Path, 54, 2000, &[]), + ("bounceLeft", "Bounce Left", AnimClass::Path, 41, 2000, &[]), + ("spiralRight", "Spiral Right", AnimClass::Path, 46, 2000, &[]), + ("spiralLeft", "Spiral Left", AnimClass::Path, 55, 2000, &[]), + ("spring", "Spring", AnimClass::Path, 53, 2000, &[]), + ("funnel", "Funnel", AnimClass::Path, 52, 2000, &[]), + ("swoosh", "Swoosh", AnimClass::Path, 30, 2000, &[]), + ("star4", "4 Point Star", AnimClass::Path, 16, 2000, &[]), + ("star5", "5 Point Star", AnimClass::Path, 5, 2000, &[]), + ("star6", "6 Point Star", AnimClass::Path, 11, 2000, &[]), + ("star8", "8 Point Star", AnimClass::Path, 17, 2000, &[]), + ("heart", "Heart", AnimClass::Path, 9, 2000, &[]), + ("crescentMoon", "Crescent Moon", AnimClass::Path, 6, 2000, &[]), + ("teardrop", "Teardrop", AnimClass::Path, 18, 2000, &[]), + ("football", "Football", AnimClass::Path, 12, 2000, &[]), + ("plusPath", "Plus", AnimClass::Path, 32, 2000, &[]), + ("rightTriangle", "Right Triangle", AnimClass::Path, 2, 2000, &[]), + ("curvedSquare", "Curved Square", AnimClass::Path, 20, 2000, &[]), + ("figure8Horizontal", "Horizontal Figure 8", AnimClass::Path, 26, 2000, &[]), + ("figure8Vertical", "Vertical Figure 8", AnimClass::Path, 22, 2000, &[]), + ("loopDeLoop", "Loop de Loop", AnimClass::Path, 24, 2000, &[]), + ("bean", "Bean", AnimClass::Path, 31, 2000, &[]), + ("peanut", "Peanut", AnimClass::Path, 27, 2000, &[]), + ("neutron", "Neutron", AnimClass::Path, 29, 2000, &[]), + ("customPath", "Custom Path", AnimClass::Path, 0, 2000, &[]), + ("play", "Play", AnimClass::Media, 1, 0, &[]), + ("pause", "Pause", AnimClass::Media, 2, 0, &[]), + ("stop", "Stop", AnimClass::Media, 3, 0, &[]), +]; + +pub fn animation_info(effect: &str, class: AnimClass) -> Option<&'static AnimationInfo> { + ANIMATIONS.iter().find(|a| a.0 == effect && a.2 == class).or_else(|| ANIMATIONS.iter().find(|a| a.0 == effect)) +} + +/// The path of a motion-path preset beyond Lines, Arcs, Turns, Shapes and Loops, in slide +/// fractions from the shape's position (our path syntax: `M`, `L`, `Z`, ending in `E`). +/// `None` for an effect that isn't one of these presets. +pub fn preset_path(effect: &str) -> Option { + use std::f64::consts::{PI, TAU}; + use std::fmt::Write; + // Sample `f` over 0..=1 into a polyline starting at (0, 0). + fn poly(n: usize, closed: bool, f: impl Fn(f64) -> (f64, f64)) -> String { + let (x0, y0) = f(0.0); + let mut s = String::from("M 0 0"); + for k in 1..=n { + let (x, y) = f(k as f64 / n as f64); + let _ = write!(s, " L {:.4} {:.4}", x - x0, y - y0); + } + if closed { + s.push_str(" Z"); + } + s.push_str(" E"); + s + } + fn pts(p: &[(f64, f64)], closed: bool) -> String { + let mut s = String::from("M 0 0"); + for (x, y) in p { + let _ = write!(s, " L {x:.4} {y:.4}"); + } + if closed { + s.push_str(" Z"); + } + s.push_str(" E"); + s + } + // A closed curve in polar form r(θ), starting at its top. + fn polar(scale: f64, r: impl Fn(f64) -> f64) -> String { + poly(96, true, |t| { + let a = -PI / 2.0 + TAU * t; + let rr = r(a + PI / 2.0) * scale; + (rr * a.cos(), rr * a.sin()) + }) + } + fn star(n: usize, outer: f64, inner: f64) -> String { + let mut p = Vec::new(); + let start = (0.0, -outer); + for k in 1..=2 * n { + let a = -PI / 2.0 + PI * k as f64 / n as f64; + let r = if k % 2 == 1 { inner } else { outer }; + p.push((r * a.cos() - start.0, r * a.sin() - start.1)); + } + pts(&p, true) + } + let spiral = |dir: f64| { + poly(120, false, move |t| { + let r = 0.12 * (1.0 - t); + let a = dir * 3.0 * TAU * t; + (dir * (0.12 - r * a.cos()), -r * a.sin()) + }) + }; + let bounce = |dir: f64| poly(90, false, move |t| (dir * 0.3 * t, 0.15 * t - 0.1 * (1.0 - t) * (3.0 * PI * t).sin().abs())); + Some(match effect { + "diagonalDownRight" => pts(&[(0.2, 0.2)], false), + "diagonalUpRight" => pts(&[(0.2, -0.2)], false), + "zigzag" => pts(&[(0.05, 0.04), (0.1, 0.0), (0.15, 0.04), (0.2, 0.0), (0.25, 0.04), (0.3, 0.0)], false), + "sineWave" => poly(64, false, |t| (0.3 * t, 0.04 * (2.0 * TAU * t).sin())), + "pathWave" => poly(48, false, |t| (0.3 * t, 0.06 * (TAU * t).sin())), + "decayingWave" => poly(96, false, |t| (0.3 * t, 0.07 * (-3.0 * t).exp() * (3.0 * TAU * t).sin())), + "heartbeat" => pts(&[(0.08, 0.0), (0.1, -0.08), (0.13, 0.06), (0.15, 0.0), (0.25, 0.0)], false), + "sCurve1" => poly(48, false, |t| (0.2 * t, 0.1 - 0.1 * (PI * t).cos())), + "sCurve2" => poly(48, false, |t| (0.1 - 0.1 * (PI * t).cos(), 0.2 * t)), + "stairsDown" => pts(&[(0.06, 0.0), (0.06, 0.06), (0.12, 0.06), (0.12, 0.12), (0.18, 0.12), (0.18, 0.18)], false), + "bounceRight" => bounce(1.0), + "bounceLeft" => bounce(-1.0), + "spiralRight" => spiral(1.0), + "spiralLeft" => spiral(-1.0), + "spring" => poly(160, false, |t| (0.3 * t + 0.03 * (5.0 * TAU * t).cos(), 0.03 * (5.0 * TAU * t).sin())), + "funnel" => poly(160, false, |t| { + let r = 0.12 * (1.0 - t); + (r * (3.0 * TAU * t).cos(), 0.25 * t + 0.03 * (3.0 * TAU * t).sin() * (1.0 - t)) + }), + "swoosh" => poly(48, false, |t| (0.3 * t, -0.12 * (PI * t).sin() + 0.1 * t * t)), + "star4" => star(4, 0.1, 0.04), + "star5" => star(5, 0.1, 0.04), + "star6" => star(6, 0.1, 0.05), + "star8" => star(8, 0.1, 0.06), + "heart" => poly(96, true, |t| { + let a = TAU * t; + (0.006 * 16.0 * a.sin().powi(3), -0.006 * (13.0 * a.cos() - 5.0 * (2.0 * a).cos() - 2.0 * (3.0 * a).cos() - (4.0 * a).cos())) + }), + "crescentMoon" => { + // The outer arc of a circle, back along a smaller, offset one. + let mut p: Vec<(f64, f64)> = (1..=32) + .map(|k| { + let a = -PI / 2.0 - PI * k as f64 / 32.0; + (0.1 * a.cos(), 0.1 + 0.1 * a.sin()) + }) + .collect(); + p.extend((1..=32).map(|k| { + let a = PI / 2.0 - PI * k as f64 / 32.0; + (0.06 * a.cos() - 0.03, 0.1 + 0.1 * a.sin()) + })); + pts(&p, true) + } + "teardrop" => polar(0.1, |a| 0.6 + 0.4 * (a / 2.0).cos().abs().powf(3.0)), + "football" => poly(64, true, |t| { + let a = TAU * t; + (0.12 * a.sin(), 0.06 * (1.0 - a.cos()) * (1.0 - 0.3 * (2.0 * a).cos().abs())) + }), + "plusPath" => pts( + &[ + (0.04, 0.0), + (0.04, 0.04), + (0.08, 0.04), + (0.08, 0.08), + (0.04, 0.08), + (0.04, 0.12), + (0.0, 0.12), + (0.0, 0.08), + (-0.04, 0.08), + (-0.04, 0.04), + (0.0, 0.04), + ], + true, + ), + "rightTriangle" => pts(&[(0.0, 0.15), (0.15, 0.15)], true), + "curvedSquare" => poly(96, true, |t| { + let a = -PI / 2.0 + TAU * t; + let sg = |v: f64| v.signum() * v.abs().sqrt(); + (0.1 * sg(a.cos()), 0.1 + 0.1 * sg(a.sin())) + }), + "figure8Horizontal" => poly(96, true, |t| { + let a = TAU * t; + (0.15 * a.sin(), 0.06 * (2.0 * a).sin()) + }), + "figure8Vertical" => poly(96, true, |t| { + let a = TAU * t; + (0.06 * (2.0 * a).sin(), 0.15 * (1.0 - a.cos())) + }), + "loopDeLoop" => poly(120, false, |t| (0.3 * t - 0.05 * (2.0 * TAU * t).sin(), -0.05 * (1.0 - (2.0 * TAU * t).cos()))), + "bean" => polar(0.1, |a| 0.75 + 0.25 * a.cos() + 0.15 * (2.0 * a).cos()), + "peanut" => polar(0.1, |a| 0.7 + 0.3 * (2.0 * a).cos().abs()), + "neutron" => polar(0.1, |a| 0.35 + 0.65 * (3.0 * a).cos().abs()), + _ => return None, + }) +} + +#[cfg(test)] +mod option_label_tests { + use super::*; + + #[test] + fn transition_option_labels() { + assert_eq!(transition_option_label("push", "l"), "From Right"); + assert_eq!(transition_option_label("push", "u"), "From Bottom"); + assert_eq!(transition_option_label("wipe", "r"), "From Left"); + assert_eq!(transition_option_label("cover", "ru"), "From Bottom-Left"); + assert_eq!(transition_option_label("ripple", "ru"), "From Bottom-Left"); + assert_eq!(transition_option_label("glitter", "hexU"), "Hexagons From Top"); + assert_eq!(transition_option_label("glitter", "l"), "Diamonds From Left"); + assert_eq!(transition_option_label("wind", "l"), "Left"); + assert_eq!(transition_option_label("pageCurlDouble", "singleR"), "Single Right"); + assert_eq!(transition_option_label("shred", "particlesOut"), "Particles Out"); + assert_eq!(transition_option_label("zoom", "rotate"), "Zoom and Rotate"); + assert_eq!(transition_option_label("split", "vertIn"), "Vertical In"); + for (id, _, _, _, opts) in TRANSITIONS { + for o in opts.iter() { + assert!(!transition_option_label(id, o).is_empty(), "{id}/{o}"); + } + } + } +} + +#[cfg(test)] +mod build_role_tests { + use super::*; + use 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/lib.rs b/crates/pptx/src/lib.rs new file mode 100644 index 00000000..ac935300 --- /dev/null +++ b/crates/pptx/src/lib.rs @@ -0,0 +1,1079 @@ +//! PresentationML (ECMA-376 part 1, chapter 19): the model of a `.pptx` presentation. +//! +//! A [`Presentation`] has slide masters (each with a theme and layouts) and slides. Each slide +//! uses a layout; placeholders on a slide inherit position, formatting and text styles from the +//! matching placeholder on its layout and master ([`placeholder::parents`]). Slides and masters +//! sit behind `Arc`s, so a snapshot of the whole deck costs one clone of the slide list. +//! +//! DrawingML (fills, lines, text bodies, tables, charts, themes) comes from [`craft_drawingml`]; +//! [`anim`] holds transitions and animations. Media bytes live in [`Presentation::media`] and +//! everything else names them by [`MediaId`]. The types derive serde with camelCase names, so a +//! presentation can be stored as JSON. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod anim; +pub mod placeholder; + +use std::sync::Arc; + +pub use craft_drawingml::color::{ColorScheme, ColorTransform, Rgba, SchemeSlot}; +pub use craft_drawingml::custgeom::CustomPath; +pub use craft_drawingml::diagram::{Diagram, DiagramNode}; +pub use craft_drawingml::style::{self, ColorBase, ColorRef, Effects, Fill, Line, ShapeStyle}; +pub use craft_drawingml::table::{self, Cell, Table}; +pub use craft_drawingml::text::{self, Paragraph, Run, RunProps, TextBody}; +pub use craft_drawingml::theme::{self, Theme}; +pub use craft_drawingml::{MediaId, SlideId, Xfrm, chart, chart::Chart}; +pub use kurbo::{Rect, Size}; +use serde::{Deserialize, Serialize}; + +pub use anim::{AnimClass, AnimStart, Animation, Transition}; + +macro_rules! id_type { + ($(#[$doc:meta])* $name:ident) => { + $(#[$doc])* + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] + #[serde(transparent)] + pub struct $name(pub u32); + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } + } + }; +} +id_type!( + /// A shape, unique across the presentation. + ShapeId +); +id_type!( + /// A slide layout. + LayoutId +); +id_type!( + /// A slide master. + MasterId +); + +/// Placeholder types. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PhType { + Title, + CtrTitle, + SubTitle, + #[default] + Body, + Obj, + Chart, + Table, + ClipArt, + Diagram, + Media, + Picture, + Date, + Footer, + SlideNum, + Header, + SlideImage, +} + +impl PhType { + pub fn xml(self) -> &'static str { + match self { + PhType::Title => "title", + PhType::CtrTitle => "ctrTitle", + PhType::SubTitle => "subTitle", + PhType::Body => "body", + PhType::Obj => "obj", + PhType::Chart => "chart", + PhType::Table => "tbl", + PhType::ClipArt => "clipArt", + PhType::Diagram => "dgm", + PhType::Media => "media", + PhType::Picture => "pic", + PhType::Date => "dt", + PhType::Footer => "ftr", + PhType::SlideNum => "sldNum", + PhType::Header => "hdr", + PhType::SlideImage => "sldImg", + } + } + pub fn from_xml(s: &str) -> Self { + match s { + "title" => PhType::Title, + "ctrTitle" => PhType::CtrTitle, + "subTitle" => PhType::SubTitle, + "obj" => PhType::Obj, + "chart" => PhType::Chart, + "tbl" => PhType::Table, + "clipArt" => PhType::ClipArt, + "dgm" => PhType::Diagram, + "media" => PhType::Media, + "pic" => PhType::Picture, + "dt" => PhType::Date, + "ftr" => PhType::Footer, + "sldNum" => PhType::SlideNum, + "hdr" => PhType::Header, + "sldImg" => PhType::SlideImage, + _ => PhType::Body, + } + } + pub fn is_title(self) -> bool { + matches!(self, PhType::Title | PhType::CtrTitle) + } + /// The prompt shown in an empty placeholder in Normal view. + pub fn prompt(self) -> &'static str { + match self { + PhType::Title | PhType::CtrTitle => "Click to add title", + PhType::SubTitle => "Click to add subtitle", + PhType::Body | PhType::Obj => "Click to add text", + PhType::Chart => "Click icon to add chart", + PhType::Table => "Click icon to add table", + PhType::Diagram => "Click icon to add SmartArt graphic", + PhType::Media => "Click icon to add media", + PhType::Picture | PhType::ClipArt => "Click icon to add picture", + PhType::Date => "Date", + PhType::Footer => "Footer", + PhType::SlideNum => "‹#›", + PhType::Header => "Header", + PhType::SlideImage => "", + } + } + /// Footer-row placeholders are not shown on slides unless the deck turns them on. + pub fn is_footer_kind(self) -> bool { + matches!(self, PhType::Date | PhType::Footer | PhType::SlideNum | PhType::Header) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Placeholder { + pub kind: PhType, + pub idx: u32, + /// `vert` orientation flag. + pub vertical: bool, + /// `quarter`, `half`, `full` size hint. + pub size: Option, + pub has_custom_prompt: bool, +} + +/// Shape geometry: a preset name with adjust values, or a custom path. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum Geom { + Preset { + name: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + adj: Vec, + }, + /// Custom geometry: sub-paths in their own coordinate space (w, h) scaled to the shape. + Custom { paths: Vec }, +} + +impl Default for Geom { + fn default() -> Self { + Geom::Preset { name: "rect".into(), adj: vec![] } + } +} + +impl Geom { + pub fn preset(name: &str) -> Self { + Geom::Preset { name: name.into(), adj: vec![] } + } + pub fn preset_name(&self) -> Option<&str> { + match self { + Geom::Preset { name, .. } => Some(name), + Geom::Custom { .. } => None, + } + } +} + +/// Embedded audio or video on a slide. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MediaClip { + pub media: MediaId, + pub video: bool, + /// Poster frame image. + pub poster: Option, + /// Crop of the picture (left, top, right, bottom as fractions), PowerPoint's `srcRect` on the video. + pub crop: [f64; 4], + pub trim_start_ms: u32, + pub trim_end_ms: u32, + pub fade_in_ms: u32, + pub fade_out_ms: u32, + /// 0–1. + pub volume: f64, + pub autoplay: bool, + pub loop_play: bool, + pub rewind: bool, + pub play_across_slides: bool, + pub hide_while_not_playing: bool, + pub full_screen: bool, + /// Bookmarks (name, ms). + pub bookmarks: Vec<(String, u32)>, + /// Probed length in ms (0 = unknown). + pub duration_ms: u32, + /// Probed picture size of a video in pixels (0 = unknown). + pub width: u32, + pub height: u32, +} + +/// One ink stroke (Draw tab): points in slide coordinates with pressure (`0..=1`, +/// where `1` is [`InkStroke::width`]). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct InkStroke { + pub points: Vec<(f64, f64, f32)>, + pub color: Rgba, + pub width: f64, + pub highlighter: bool, +} + +/// True when every sample is the placeholder `0.5` written before a device reported pressure. +/// Those strokes were drawn at [`InkStroke::width`] and still are. +pub fn ink_pressure_is_legacy(points: &[(f64, f64, f32)]) -> bool { + !points.is_empty() && points.iter().all(|p| (p.2 - 0.5).abs() <= 1.0e-3) +} + +/// Width of one ink sample. `pressure` of `1` is the pen width. A legacy flat stroke +/// (`legacy`) draws at the pen width. A missing reading draws at the pen width too. +/// A light touch stays visible (at least 8% of the pen). +pub fn ink_sample_width(pen: f64, pressure: f32, legacy: bool) -> f64 { + let pen = if pen.is_finite() { pen.abs() } else { 0.0 }; + if legacy { + return pen; + } + let p = if pressure.is_finite() { pressure.clamp(0.08, 1.0) } else { 1.0 }; + pen * f64::from(p) +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum ShapeKind { + /// An autoshape or text box. + Shape, + Picture { + fill: style::PictureFill, + }, + Group { + children: Vec, + /// Child coordinate space. + child: Xfrm, + }, + Connector { + start: Option<(ShapeId, u32)>, + end: Option<(ShapeId, u32)>, + }, + Table(Table), + Chart(Box), + Media(MediaClip), + Ink { + strokes: Vec, + }, + /// A 3-D model (`p:contentPart` with a `model3d` relationship, usually a `.glb`). + /// `poster` is the fallback picture an `mc:AlternateContent` choice carried for older readers. + Model3d { + media: MediaId, + #[serde(default)] + poster: Option, + }, + /// Something we read but don't model (SmartArt without fallback, OLE…): its XML is kept and its + /// fallback picture, if any, is shown. + Opaque { + xml: String, + preview: Option, + label: String, + /// The parts its XML points to (SmartArt data, an OLE embedding…), so a save writes them + /// back: (relationship id used in `xml`, relationship type, the part as a media item). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + parts: Vec<(String, String, MediaId)>, + }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Shape { + pub id: ShapeId, + pub name: String, + /// `None`: placeholder inheriting its box from the layout/master. + pub xfrm: Option, + pub kind: ShapeKind, + pub geom: Geom, + pub ph: Option, + /// `None`: inherited (placeholder chain or style); `Some(Fill::None)`: no fill. + pub fill: Option, + pub line: Option, + pub effects: Option, + pub style: Option, + pub text: Option, + pub hidden: bool, + pub locked: bool, + /// Text box (`txBox="1"`): no fill, autosize, wrap off by default. + pub text_box: bool, + /// Alt text. + pub descr: String, + pub title: String, + pub decorative: bool, + pub click: Option, + pub hover: Option, + /// Add-in tags (`p:custDataLst` → `ppt/tags/tagN.xml`): name/value pairs kept for round trip. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tags: Vec<(String, String)>, + /// Unmodelled XML kept for round-trip (extension lists etc.). + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_ext: Option, + /// On a SmartArt group: the diagram's text outline, from which its shapes are regenerated. + #[serde(skip_serializing_if = "Option::is_none")] + pub diagram: Option, +} + +impl Default for Shape { + fn default() -> Self { + Shape { + id: ShapeId(0), + name: String::new(), + xfrm: None, + kind: ShapeKind::Shape, + geom: Geom::default(), + ph: None, + fill: None, + line: None, + effects: None, + style: None, + text: None, + hidden: false, + locked: false, + text_box: false, + descr: String::new(), + title: String::new(), + decorative: false, + click: None, + hover: None, + tags: vec![], + raw_ext: None, + diagram: None, + } + } +} + +impl Shape { + /// A stand-in shape for laying out one table cell's `body`: a table, so the cell's text takes + /// the master's other-text style as PowerPoint's does. + pub fn table_cell(body: text::TextBody) -> Shape { + Shape { text: Some(body), kind: ShapeKind::Table(Table::default()), ..Default::default() } + } + pub fn is_placeholder(&self) -> bool { + self.ph.is_some() + } + pub fn ph_type(&self) -> Option { + self.ph.as_ref().map(|p| p.kind) + } + pub fn is_group(&self) -> bool { + matches!(self.kind, ShapeKind::Group { .. }) + } + pub fn children(&self) -> &[Shape] { + match &self.kind { + ShapeKind::Group { children, .. } => children, + _ => &[], + } + } + pub fn children_mut(&mut self) -> Option<&mut Vec> { + match &mut self.kind { + ShapeKind::Group { children, .. } => Some(children), + _ => None, + } + } + /// Is this a line-like shape (no area)? + pub fn is_line(&self) -> bool { + matches!(self.kind, ShapeKind::Connector { .. }) || self.geom.preset_name().is_some_and(craft_drawingml::preset::is_line_like) + } + pub fn kind_name(&self) -> &'static str { + match &self.kind { + ShapeKind::Shape if self.ph.is_some() => "placeholder", + ShapeKind::Shape if self.text_box => "textBox", + ShapeKind::Shape => "shape", + ShapeKind::Picture { .. } => "picture", + ShapeKind::Group { .. } => "group", + ShapeKind::Connector { .. } => "connector", + ShapeKind::Table(_) => "table", + ShapeKind::Chart(_) => "chart", + ShapeKind::Media(m) if m.video => "video", + ShapeKind::Media(_) => "audio", + ShapeKind::Ink { .. } => "ink", + ShapeKind::Model3d { .. } => "model", + ShapeKind::Opaque { .. } => "object", + } + } +} + +/// Walk shapes depth-first (groups included). +pub fn walk<'a>(shapes: &'a [Shape], f: &mut dyn FnMut(&'a Shape, usize)) { + fn rec<'a>(shapes: &'a [Shape], depth: usize, f: &mut dyn FnMut(&'a Shape, usize)) { + if depth > 64 { + return; + } + for s in shapes { + f(s, depth); + rec(s.children(), depth + 1, f); + } + } + rec(shapes, 0, f); +} + +pub fn find_shape(shapes: &[Shape], id: ShapeId) -> Option<&Shape> { + fn rec(shapes: &[Shape], id: ShapeId, depth: usize) -> Option<&Shape> { + if depth > 64 { + return None; + } + for s in shapes { + if s.id == id { + return Some(s); + } + if let Some(f) = rec(s.children(), id, depth + 1) { + return Some(f); + } + } + None + } + rec(shapes, id, 0) +} + +pub fn find_shape_mut(shapes: &mut [Shape], id: ShapeId) -> Option<&mut Shape> { + fn rec(shapes: &mut [Shape], id: ShapeId, depth: usize) -> Option<&mut Shape> { + if depth > 64 { + return None; + } + for s in shapes.iter_mut() { + if s.id == id { + return Some(s); + } + if let ShapeKind::Group { children, .. } = &mut s.kind + && let Some(f) = rec(children, id, depth + 1) + { + return Some(f); + } + } + None + } + rec(shapes, id, 0) +} + +/// The list holding `id` among its siblings: the tree's top level, or a group's children. +pub fn sibling_list_mut(shapes: &mut Vec, id: ShapeId) -> Option<&mut Vec> { + fn rec(shapes: &mut Vec, id: ShapeId, depth: usize) -> Option<&mut Vec> { + if depth > 64 { + return None; + } + if shapes.iter().any(|s| s.id == id) { + return Some(shapes); + } + for s in shapes.iter_mut() { + if let ShapeKind::Group { children, .. } = &mut s.kind + && let Some(f) = rec(children, id, depth + 1) + { + return Some(f); + } + } + None + } + rec(shapes, id, 0) +} + +/// The group directly holding `id`: `Some(None)` for a top-level shape, `None` when absent. +pub fn parent_of(shapes: &[Shape], id: ShapeId) -> Option> { + fn rec(shapes: &[Shape], parent: Option, id: ShapeId, depth: usize) -> Option> { + if depth > 64 { + return None; + } + for s in shapes { + if s.id == id { + return Some(parent); + } + if let Some(f) = rec(s.children(), Some(s.id), id, depth + 1) { + return Some(f); + } + } + None + } + rec(shapes, None, id, 0) +} + +/// Background of a slide, layout or master. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +#[allow(clippy::large_enum_variant)] // the native JSON shape is fixed; boxing would not change it but would change the API +pub enum Background { + Fill { + fill: Fill, + }, + /// Theme background style reference (1001–1003) with a colour. + Ref { + idx: u32, + color: ColorRef, + }, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Comment { + pub author: String, + pub initials: String, + pub text: String, + /// ISO 8601. + pub date: String, + pub x: f64, + pub y: f64, + pub resolved: bool, + pub replies: Vec, + /// The shape the comment is attached to. + pub shape: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Slide { + pub id: SlideId, + pub layout: LayoutId, + pub name: String, + pub shapes: Vec, + pub background: Option, + pub hidden: bool, + pub show_master_shapes: bool, + pub transition: Option, + pub animations: Vec, + /// Speaker notes. + pub notes: TextBody, + pub comments: Vec, + /// `clrMapOvr`: a colour map replacing the layout's or master's (`bg1`→`dk1`…); None follows + /// the layout. + #[serde(skip_serializing_if = "Option::is_none")] + pub color_map: Option>, + /// Add-in tags (`p:custDataLst` → `ppt/tags/tagN.xml`): name/value pairs kept for round trip. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tags: Vec<(String, String)>, + /// Unmodelled XML kept for round-trip (custom data). + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_ext: Option, +} + +impl Default for Slide { + fn default() -> Self { + Slide { + id: SlideId(0), + layout: LayoutId(0), + name: String::new(), + shapes: vec![], + background: None, + hidden: false, + show_master_shapes: true, + transition: None, + animations: vec![], + notes: TextBody::default(), + comments: vec![], + color_map: None, + tags: vec![], + raw_ext: None, + } + } +} + +impl Slide { + pub fn shape(&self, id: ShapeId) -> Option<&Shape> { + find_shape(&self.shapes, id) + } + pub fn shape_mut(&mut self, id: ShapeId) -> Option<&mut Shape> { + find_shape_mut(&mut self.shapes, id) + } + /// The slide's title text (first title placeholder), for thumbnails, outline and navigation. + pub fn title(&self) -> String { + self.shapes + .iter() + .find(|s| s.ph_type().is_some_and(PhType::is_title)) + .and_then(|s| s.text.as_ref()) + .map(|t| t.text().replace('\u{b}', " ")) + .unwrap_or_default() + } + pub fn notes_text(&self) -> String { + self.notes.text() + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LayoutType { + Title, + #[default] + TitleAndContent, + SectionHeader, + TwoContent, + Comparison, + TitleOnly, + Blank, + ContentWithCaption, + PictureWithCaption, + TitleAndVerticalText, + VerticalTitleAndText, + Custom, +} + +impl LayoutType { + pub fn xml(self) -> &'static str { + match self { + LayoutType::Title => "title", + LayoutType::TitleAndContent => "obj", + LayoutType::SectionHeader => "secHead", + LayoutType::TwoContent => "twoObj", + LayoutType::Comparison => "twoTxTwoObj", + LayoutType::TitleOnly => "titleOnly", + LayoutType::Blank => "blank", + LayoutType::ContentWithCaption => "objTx", + LayoutType::PictureWithCaption => "picTx", + LayoutType::TitleAndVerticalText => "vertTx", + LayoutType::VerticalTitleAndText => "vertTitleAndTx", + LayoutType::Custom => "cust", + } + } + pub fn from_xml(s: &str) -> Self { + match s { + "title" => LayoutType::Title, + "obj" | "tx" => LayoutType::TitleAndContent, + "secHead" => LayoutType::SectionHeader, + "twoObj" | "twoColTx" => LayoutType::TwoContent, + "twoTxTwoObj" => LayoutType::Comparison, + "titleOnly" => LayoutType::TitleOnly, + "blank" => LayoutType::Blank, + "objTx" => LayoutType::ContentWithCaption, + "picTx" => LayoutType::PictureWithCaption, + "vertTx" => LayoutType::TitleAndVerticalText, + "vertTitleAndTx" => LayoutType::VerticalTitleAndText, + _ => LayoutType::Custom, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Layout { + pub id: LayoutId, + pub name: String, + pub kind: LayoutType, + pub shapes: Vec, + pub background: Option, + pub show_master_shapes: bool, + pub preserve: bool, + /// `clrMapOvr`: a colour map replacing the master's for this layout and the slides using it + /// (dark "section" layouts map `bg1` to `dk1`); None follows the master. + #[serde(skip_serializing_if = "Option::is_none")] + pub color_map: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_ext: Option, +} + +/// Which placeholders slides show in the footer row (Header & Footer dialog). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct HeaderFooter { + pub date: bool, + pub slide_number: bool, + pub footer: bool, + pub header: bool, + pub footer_text: String, + /// Fixed date text; empty = automatic date. + pub date_text: String, + /// `datetime1`…`datetime13` format for automatic dates. + pub date_format: String, + pub hide_on_title: bool, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Master { + pub id: MasterId, + pub name: String, + pub theme: Theme, + pub shapes: Vec, + pub background: Option, + pub title_style: text::ListStyle, + pub body_style: text::ListStyle, + pub other_style: text::ListStyle, + pub layouts: Vec, + /// `bg1`→`lt1` etc. Default mapping when empty. + pub color_map: Vec<(String, String)>, + pub preserve: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_ext: Option, +} + +impl Master { + pub fn layout(&self, id: LayoutId) -> Option<&Layout> { + self.layouts.iter().find(|l| l.id == id) + } + /// Resolve `bg1`/`tx1`/`bg2`/`tx2` through the colour map. + pub fn map_slot(&self, slot: SchemeSlot) -> SchemeSlot { + map_slot(&self.color_map, slot) + } + /// The theme's colour scheme with the colour map applied (`bg1`… resolve to mapped colours). + pub fn scheme(&self) -> ColorScheme { + self.scheme_with(&self.color_map) + } + /// The theme's colour scheme with `map` (a layout's or slide's override, or the master's own) + /// applied. + pub fn scheme_with(&self, map: &[(String, String)]) -> ColorScheme { + let mut s = self.theme.colors.clone(); + if map.is_empty() { + return s; + } + let base = self.theme.colors.clone(); + for (alias, slot) in [ + (SchemeSlot::Bg1, SchemeSlot::Lt1), + (SchemeSlot::Tx1, SchemeSlot::Dk1), + (SchemeSlot::Bg2, SchemeSlot::Lt2), + (SchemeSlot::Tx2, SchemeSlot::Dk2), + ] { + s.set(slot, base.get(map_slot(map, alias))); + } + s + } +} + +/// Resolve `bg1`/`tx1`/`bg2`/`tx2` through the colour map `map` (the default mapping when it has +/// no entry). +pub fn map_slot(map: &[(String, String)], slot: SchemeSlot) -> SchemeSlot { + let key = match slot { + SchemeSlot::Bg1 => "bg1", + SchemeSlot::Tx1 => "tx1", + SchemeSlot::Bg2 => "bg2", + SchemeSlot::Tx2 => "tx2", + other => return other, + }; + map.iter().find(|(k, _)| k == key).and_then(|(_, v)| SchemeSlot::from_xml(v)).unwrap_or(match slot { + SchemeSlot::Bg1 => SchemeSlot::Lt1, + SchemeSlot::Tx1 => SchemeSlot::Dk1, + SchemeSlot::Bg2 => SchemeSlot::Lt2, + _ => SchemeSlot::Dk2, + }) +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Section { + pub name: String, + pub slides: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CustomShow { + pub name: String, + pub slides: Vec, +} + +/// Slide Show ▸ Set Up Slide Show. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ShowSettings { + /// `speaker`, `browsed`, `kiosk`. + pub show_type: String, + pub loop_until_esc: bool, + pub without_narration: bool, + pub without_animation: bool, + pub use_timings: bool, + /// 1-based inclusive range; `None` = all. + pub range: Option<(u32, u32)>, + pub custom_show: Option, + pub pen_color: Rgba, + pub laser_color: Rgba, + pub show_media_controls: bool, +} + +impl Default for ShowSettings { + fn default() -> Self { + ShowSettings { + show_type: "speaker".into(), + loop_until_esc: false, + without_narration: false, + without_animation: false, + use_timings: true, + range: None, + custom_show: None, + pen_color: Rgba::rgb(0xFF, 0x00, 0x00), + laser_color: Rgba::rgb(0xFF, 0x00, 0x00), + show_media_controls: true, + } + } +} + +/// Document properties (File ▸ Properties). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Properties { + pub title: String, + pub subject: String, + pub author: String, + pub keywords: String, + pub comments: String, + pub category: String, + pub company: String, + pub created: String, + pub modified: String, + pub last_modified_by: String, + pub revision: u32, +} + +/// One typeface embedded in the presentation (`p:embeddedFont`). Each slot is a font file in +/// [`Presentation::media`]: regular, bold, italic, bold italic. A slot is absent when the file +/// didn't include that face. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct EmbeddedFont { + pub typeface: String, + /// `panose` on `p:font`, when the file gave the 20 hex digits. + #[serde(skip_serializing_if = "Option::is_none")] + pub panose: Option, + /// `pitchFamily` on `p:font`. + #[serde(skip_serializing_if = "Option::is_none")] + pub pitch_family: Option, + /// `charset` on `p:font`. + #[serde(skip_serializing_if = "Option::is_none")] + pub charset: Option, + pub regular: Option, + pub bold: Option, + pub italic: Option, + pub bold_italic: Option, +} + +impl EmbeddedFont { + /// The four face slots, in the order the package stores them. + pub fn faces(&self) -> [Option; 4] { + [self.regular, self.bold, self.italic, self.bold_italic] + } +} + +/// An embedded file (image, audio, video, font, workbook). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MediaItem { + pub id: MediaId, + /// Original file name, e.g. `image1.png`. + pub name: String, + pub content_type: String, + /// Bytes; kept out of the JSON manifest and stored as a separate file in native files. + #[serde(skip)] + pub data: Arc>, + /// External link instead of embedded bytes. + pub link: Option, +} + +/// A size whose sides may be missing, null or not numbers (NaN then); see `Presentation::slide_size`. +fn lenient_size<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result { + let v = serde_json::Value::deserialize(d)?; + let side = |k: &str| v.get(k).and_then(serde_json::Value::as_f64).unwrap_or(f64::NAN); + Ok(Size::new(side("width"), side("height"))) +} + +/// An XML package part the reader does not model, kept as written. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct KeptPart { + /// Part name from the package root (`customXml/item1.xml`). + pub name: String, + /// Content type; empty for a relationships part. + pub content_type: String, + pub xml: String, + /// Full relationship type linking it, empty when only its sibling parts point at it. + pub rel_type: String, + /// Linked from the package root rather than from the presentation part. + pub from_root: bool, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Presentation { + /// Slide size in points (default 16:9, 960 × 540). Read leniently: a missing, null or + /// non-numeric side becomes NaN for `the caller` to replace, instead of the + /// whole file failing to open. + #[serde(deserialize_with = "lenient_size")] + pub slide_size: Size, + #[serde(deserialize_with = "lenient_size")] + pub notes_size: Size, + pub slides: Vec>, + pub masters: Vec>, + pub notes_master: Option>, + pub handout_master: Option>, + pub sections: Vec
, + pub custom_shows: Vec, + pub show: ShowSettings, + pub header_footer: HeaderFooter, + pub props: Properties, + /// Presentation-wide default text style (shapes that aren't placeholders). + pub default_text_style: text::ListStyle, + pub media: Vec, + pub first_slide_number: u32, + /// Next free shape/slide/layout/media id. + pub next_id: u32, + /// Embedded fonts. The face bytes live in [`Self::media`]. + pub embedded_fonts: Vec, + /// Table styles defined in the file (`ppt/tableStyles.xml`). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub table_styles: Vec, + /// Add-in tags (`p:custDataLst` → `ppt/tags/tagN.xml`): name/value pairs kept for round trip. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tags: Vec<(String, String)>, + /// Package parts kept verbatim for round trip: custom XML data (SharePoint metadata), the + /// sensitivity label and custom document properties. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub kept_parts: Vec, + /// Drawing guides (View › Guides), shared by every slide (`p:guideLst` in viewProps). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub guides: Vec, + /// Unmodelled presentation-level XML kept for round-trip. + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_ext: Option, +} + +/// A drawing guide: a vertical line at `pos` points from the slide's left edge, or a horizontal +/// one from its top. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DrawGuide { + pub vertical: bool, + pub pos: f64, +} + +impl Default for Presentation { + /// An empty deck: 16:9 slides (960 × 540 points), no masters and no slides. An application + /// builds its own starting deck (masters, layouts, text styles) on top of this. + fn default() -> Self { + Presentation { + slide_size: Size::new(960.0, 540.0), + notes_size: Size::new(540.0, 720.0), + slides: vec![], + masters: vec![], + notes_master: None, + handout_master: None, + sections: vec![], + custom_shows: vec![], + show: ShowSettings::default(), + header_footer: HeaderFooter::default(), + props: Properties::default(), + default_text_style: text::ListStyle::default(), + media: vec![], + first_slide_number: 1, + next_id: 0, + embedded_fonts: vec![], + table_styles: vec![], + tags: vec![], + kept_parts: vec![], + guides: vec![], + raw_ext: None, + } + } +} + +impl Presentation { + /// A fresh id, unique across slides, shapes, layouts, masters and media in this deck. `None` + /// once every id is in use (a hostile file can arrive that way): the deck cannot grow + /// further, and callers must refuse the change rather than duplicate ids. + pub fn alloc_id(&mut self) -> Option { + let next = self.next_id.max(1).checked_add(1)?; + self.next_id = next; + Some(next) + } + /// Make sure `next_id` is above every id in use (after loading a file). + pub fn fix_next_id(&mut self) { + let mut m = self.next_id; + for s in &self.slides { + m = m.max(s.id.0); + walk(&s.shapes, &mut |sh, _| m = m.max(sh.id.0)); + } + for ma in &self.masters { + m = m.max(ma.id.0); + walk(&ma.shapes, &mut |sh, _| m = m.max(sh.id.0)); + for l in &ma.layouts { + m = m.max(l.id.0); + walk(&l.shapes, &mut |sh, _| m = m.max(sh.id.0)); + } + } + for md in &self.media { + m = m.max(md.id.0); + } + for extra in [self.notes_master.as_deref(), self.handout_master.as_deref()].into_iter().flatten() { + m = m.max(extra.id.0); + walk(&extra.shapes, &mut |sh, _| m = m.max(sh.id.0)); + } + self.next_id = m; + } + pub fn slide_index(&self, id: SlideId) -> Option { + self.slides.iter().position(|s| s.id == id) + } + pub fn slide(&self, id: SlideId) -> Option<&Slide> { + self.slides.iter().find(|s| s.id == id).map(AsRef::as_ref) + } + pub fn slide_mut(&mut self, id: SlideId) -> Option<&mut Slide> { + self.slides.iter_mut().find(|s| s.id == id).map(Arc::make_mut) + } + pub fn slide_at(&self, i: usize) -> Option<&Slide> { + self.slides.get(i).map(AsRef::as_ref) + } + /// Master and layout for a layout id. + pub fn layout(&self, id: LayoutId) -> Option<(&Master, &Layout)> { + self.masters.iter().find_map(|m| m.layout(id).map(|l| (m.as_ref(), l))) + } + /// The master (and layout) a slide uses, falling back to the first. + pub fn master_for(&self, slide: &Slide) -> Option<(&Master, Option<&Layout>)> { + if let Some((m, l)) = self.layout(slide.layout) { + return Some((m, Some(l))); + } + self.masters.first().map(|m| (m.as_ref(), m.layouts.first())) + } + pub fn first_master(&self) -> Option<&Master> { + self.masters.first().map(AsRef::as_ref) + } + pub fn media(&self, id: MediaId) -> Option<&MediaItem> { + self.media.iter().find(|m| m.id == id) + } + /// Add media bytes, reusing an identical existing item. + pub fn add_media(&mut self, name: &str, content_type: &str, data: Vec) -> Option { + if let Some(m) = self.media.iter().find(|m| m.data.len() == data.len() && *m.data == data) { + return Some(m.id); + } + let id = MediaId(self.alloc_id()?); + self.media.push(MediaItem { id, name: name.into(), content_type: content_type.into(), data: Arc::new(data), link: None }); + Some(id) + } + /// Section index containing a slide (by position), when sections exist. + pub fn section_of(&self, slide: SlideId) -> Option { + self.sections.iter().position(|s| s.slides.contains(&slide)) + } + /// 1-based slide number as displayed (respects `first_slide_number`). + pub fn slide_number(&self, index: usize) -> u32 { + self.first_slide_number.saturating_add(u32::try_from(index).unwrap_or(u32::MAX)) + } + /// Basic consistency check: unique slide/shape ids, layouts exist. Returns problems found. + pub fn validate(&self) -> Vec { + let mut out = vec![]; + let mut seen = std::collections::HashSet::new(); + // Indexed once: a per-slide scan of every layout is slides × layouts work. + let layouts: std::collections::HashSet<_> = self.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.id)).collect(); + for s in &self.slides { + if !seen.insert(s.id) { + out.push(format!("duplicate slide id {}", s.id)); + } + if !layouts.contains(&s.layout) { + out.push(format!("slide {} uses missing layout {}", s.id, s.layout)); + } + let mut ids = std::collections::HashSet::new(); + walk(&s.shapes, &mut |sh, _| { + if !ids.insert(sh.id) { + out.push(format!("slide {}: duplicate shape id {}", s.id, sh.id)); + } + }); + } + out + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/pptx/src/placeholder.rs b/crates/pptx/src/placeholder.rs new file mode 100644 index 00000000..3ee82846 --- /dev/null +++ b/crates/pptx/src/placeholder.rs @@ -0,0 +1,106 @@ +//! Placeholder inheritance: which layout and master placeholders a placeholder takes its +//! position, formatting and text styles from (`p:ph` `type` and `idx`, ECMA-376 part 1, 19.3.1.36). +//! +//! Only the matching lives here. What a caller does with the parents (effective position, fill, +//! text style) is its own business. + +use crate::{Layout, Master, PhType, Shape}; + +/// Where a shape lives. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Owner { + Slide, + Layout, + Master, +} + +/// Can a slide placeholder of type `slide` inherit from a layout placeholder of type `other`? +fn compatible(slide: PhType, other: PhType) -> bool { + use PhType::*; + match slide { + Title | CtrTitle => matches!(other, Title | CtrTitle), + SubTitle | Body | Obj | Chart | Table | ClipArt | Diagram | Media | Picture => { + !matches!(other, Title | CtrTitle | Date | Footer | SlideNum | Header) + } + k => k == other, + } +} + +/// The placeholder in `shapes` that `shape` matches: by `idx` (when `by_idx`) and a compatible +/// type, else the same type, else a compatible one. +pub fn find_ph<'s>(shapes: &'s [Shape], shape: &Shape, by_idx: bool) -> Option<&'s Shape> { + let ph = shape.ph.as_ref()?; + if by_idx + && ph.idx != 0 + && let Some(s) = shapes.iter().find(|s| s.ph.as_ref().is_some_and(|p| p.idx == ph.idx && compatible(ph.kind, p.kind))) + { + return Some(s); + } + // Same type first, then a compatible one. + shapes + .iter() + .find(|s| s.ph.as_ref().is_some_and(|p| p.kind == ph.kind && (!by_idx || ph.idx == 0 || p.idx == ph.idx || ph.kind.is_title()))) + .or_else(|| shapes.iter().find(|s| s.ph.as_ref().is_some_and(|p| p.kind == ph.kind))) + .or_else(|| shapes.iter().find(|s| s.ph.as_ref().is_some_and(|p| compatible(ph.kind, p.kind)))) +} + +/// The master placeholder a placeholder of type `kind` inherits from: the title, the matching +/// footer-row placeholder, or the body. +pub fn master_ph(master: &Master, kind: PhType) -> Option<&Shape> { + let want = match kind { + PhType::Title | PhType::CtrTitle => PhType::Title, + PhType::Date | PhType::Footer | PhType::SlideNum | PhType::Header => kind, + _ => PhType::Body, + }; + master.shapes.iter().find(|s| s.ph_type() == Some(want)) +} + +/// The layout and master placeholders `shape` inherits from. A slide's placeholder matches one on +/// `layout`, and the master placeholder follows the layout one's type; a layout's placeholder +/// inherits from the master only; a master's from nothing. +pub fn parents<'a>(master: &'a Master, layout: Option<&'a Layout>, owner: Owner, shape: &Shape) -> (Option<&'a Shape>, Option<&'a Shape>) { + let Some(ph) = shape.ph.as_ref() else { return (None, None) }; + match owner { + Owner::Slide => { + let lp = layout.and_then(|l| find_ph(&l.shapes, shape, true)); + let kind = lp.and_then(Shape::ph_type).unwrap_or(ph.kind); + (lp, master_ph(master, kind)) + } + Owner::Layout => (None, master_ph(master, ph.kind)), + Owner::Master => (None, None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Placeholder, ShapeId}; + + fn ph(id: u32, kind: PhType, idx: u32) -> Shape { + Shape { id: ShapeId(id), ph: Some(Placeholder { kind, idx, ..Default::default() }), ..Default::default() } + } + + #[test] + fn slide_placeholders_match_by_index_then_type() { + let master = Master { shapes: vec![ph(1, PhType::Title, 0), ph(2, PhType::Body, 1), ph(3, PhType::SlideNum, 12)], ..Default::default() }; + let layout = Layout { shapes: vec![ph(10, PhType::Title, 0), ph(11, PhType::Body, 1), ph(12, PhType::Obj, 2)], ..Default::default() }; + let id = |s: Option<&Shape>| s.map(|s| s.id.0); + // By idx: idx 2 is the layout's obj, whose master parent is the body. + let (l, m) = parents(&master, Some(&layout), Owner::Slide, &ph(20, PhType::Body, 2)); + assert_eq!((id(l), id(m)), (Some(12), Some(2))); + // A centred title takes the layout's title and the master's title. + let (l, m) = parents(&master, Some(&layout), Owner::Slide, &ph(21, PhType::CtrTitle, 0)); + assert_eq!((id(l), id(m)), (Some(10), Some(1))); + // An unknown idx falls back to the same type. + let (l, _) = parents(&master, Some(&layout), Owner::Slide, &ph(22, PhType::Body, 9)); + assert_eq!(id(l), Some(11)); + // A title never inherits from a body, nor a slide number from a title. + assert_eq!(find_ph(&layout.shapes[1..], &ph(23, PhType::Title, 0), true).map(|s| s.id.0), None); + let (l, m) = parents(&master, Some(&layout), Owner::Slide, &ph(24, PhType::SlideNum, 12)); + assert_eq!((id(l), id(m)), (None, Some(3))); + // Layouts inherit from the master only, masters from nothing, other shapes from nothing. + assert_eq!(parents(&master, None, Owner::Layout, &ph(25, PhType::SubTitle, 1)), (None, master.shapes.get(1))); + assert_eq!(parents(&master, None, Owner::Master, &ph(26, PhType::Title, 0)), (None, None)); + assert_eq!(parents(&master, Some(&layout), Owner::Slide, &Shape::default()), (None, None)); + } +} diff --git a/crates/pptx/src/tests.rs b/crates/pptx/src/tests.rs new file mode 100644 index 00000000..dececf12 --- /dev/null +++ b/crates/pptx/src/tests.rs @@ -0,0 +1,131 @@ +use super::*; + +#[test] +fn serde_roundtrip() { + let mut p = Presentation::default(); + p.masters.push(Arc::new(Master { layouts: vec![Layout { id: LayoutId(2), ..Default::default() }], ..Default::default() })); + p.slides.push(Arc::new(Slide { id: SlideId(3), layout: LayoutId(2), ..Default::default() })); + let j = serde_json::to_string(&p).unwrap(); + let q: Presentation = serde_json::from_str(&j).unwrap(); + assert_eq!(p, q); + // Unknown and missing fields are tolerated. + let q: Presentation = serde_json::from_str(r#"{"slides":[],"zzz":1}"#).unwrap(); + assert!(q.slides.is_empty()); + assert_eq!(q.slide_size, Size::new(960.0, 540.0)); + // A side that is missing, null or not a number reads as NaN instead of failing the file. + let q: Presentation = serde_json::from_str(r#"{"slideSize":{"width":720,"height":null}}"#).unwrap(); + assert_eq!(q.slide_size.width, 720.0); + assert!(q.slide_size.height.is_nan()); +} + +/// A deck whose ids are used up (a hostile file can arrive that way) yields no further ids: +/// allocating never repeats one. +#[test] +fn exhausted_id_space_allocates_nothing() { + let mut p = Presentation { next_id: u32::MAX, ..Default::default() }; + assert_eq!(p.alloc_id(), None); + assert_eq!(p.alloc_id(), None); + assert!(p.add_media("x.png", "image/png", vec![9]).is_none(), "fresh media bytes are refused too"); +} + +#[test] +fn ids_are_unique_and_fix_next_id() { + let mut p = Presentation::default(); + let shape = Shape { id: ShapeId(40), ..Default::default() }; + p.slides.push(Arc::new(Slide { id: SlideId(30), layout: LayoutId(50), shapes: vec![shape], ..Default::default() })); + p.masters.push(Arc::new(Master { id: MasterId(7), layouts: vec![Layout { id: LayoutId(50), ..Default::default() }], ..Default::default() })); + p.fix_next_id(); + assert_eq!(p.next_id, 50); + assert_eq!(p.alloc_id(), Some(51)); + let m = p.add_media("a.png", "image/png", vec![1, 2]).unwrap(); + assert_eq!(p.add_media("b.png", "image/png", vec![1, 2]), Some(m), "identical bytes are shared"); + assert!(p.validate().is_empty(), "{:?}", p.validate()); + Arc::make_mut(&mut p.slides[0]).layout = LayoutId(99); + assert_eq!(p.validate(), ["slide 30 uses missing layout 99"]); +} + +#[test] +fn group_find_and_walk() { + let child = Shape { id: ShapeId(900), ..Default::default() }; + let g = Shape { id: ShapeId(901), kind: ShapeKind::Group { children: vec![child], child: Xfrm::new(0.0, 0.0, 1.0, 1.0) }, ..Default::default() }; + let mut slide = Slide { shapes: vec![g], ..Default::default() }; + assert!(slide.shape(ShapeId(900)).is_some()); + assert!(slide.shape_mut(ShapeId(900)).is_some()); + let mut n = 0; + walk(&slide.shapes, &mut |_, _| n += 1); + assert_eq!(n, 2); +} + +#[test] +fn walks_stop_at_depth_64() { + let mut s = Shape { id: ShapeId(1), ..Default::default() }; + for i in 2..100 { + s = Shape { id: ShapeId(i), kind: ShapeKind::Group { children: vec![s], child: Xfrm::default() }, ..Default::default() }; + } + let tree = vec![s]; + let mut n = 0; + walk(&tree, &mut |_, _| n += 1); + assert_eq!(n, 65); + assert!(find_shape(&tree, ShapeId(1)).is_none()); + assert!(find_shape(&tree, ShapeId(40)).is_some()); +} + +#[test] +fn sibling_lists_and_parents_reach_into_groups() { + let leaf = |id: u32| Shape { id: ShapeId(id), ..Default::default() }; + let group = |id: u32, children: Vec| Shape { + id: ShapeId(id), + kind: ShapeKind::Group { children, child: Default::default() }, + ..Default::default() + }; + let mut tree = vec![leaf(1), group(2, vec![leaf(3), group(4, vec![leaf(5)])]), leaf(6)]; + assert_eq!(parent_of(&tree, ShapeId(1)), Some(None)); + assert_eq!(parent_of(&tree, ShapeId(3)), Some(Some(ShapeId(2)))); + assert_eq!(parent_of(&tree, ShapeId(5)), Some(Some(ShapeId(4)))); + assert_eq!(parent_of(&tree, ShapeId(9)), None); + let ids = |v: &Vec| v.iter().map(|s| s.id.0).collect::>(); + assert_eq!(sibling_list_mut(&mut tree, ShapeId(6)).map(|v| ids(v)), Some(vec![1, 2, 6])); + assert_eq!(sibling_list_mut(&mut tree, ShapeId(4)).map(|v| ids(v)), Some(vec![3, 4])); + assert_eq!(sibling_list_mut(&mut tree, ShapeId(5)).map(|v| ids(v)), Some(vec![5])); + assert!(sibling_list_mut(&mut tree, ShapeId(9)).is_none()); +} + +#[test] +fn ink_width_follows_pressure_and_keeps_the_old_placeholder() { + assert!(ink_pressure_is_legacy(&[(0.0, 0.0, 0.5), (1.0, 1.0, 0.5)])); + assert!(!ink_pressure_is_legacy(&[(0.0, 0.0, 0.5), (1.0, 1.0, 1.0)])); + assert!(!ink_pressure_is_legacy(&[])); + assert!((ink_sample_width(8.0, 0.5, true) - 8.0).abs() < 1e-9); + assert!((ink_sample_width(8.0, 1.0, false) - 8.0).abs() < 1e-9); + assert!((ink_sample_width(8.0, 0.25, false) - 2.0).abs() < 1e-9); + let floor = ink_sample_width(8.0, 0.0, false); + assert!((floor - 0.64).abs() < 1e-4, "a light touch stays visible, got {floor}"); + assert!((ink_sample_width(8.0, f32::NAN, false) - 8.0).abs() < 1e-9); +} + +#[test] +fn colour_maps_alias_the_background_and_text_slots() { + let mut m = Master::default(); + let base = m.theme.colors.clone(); + assert_eq!(m.map_slot(SchemeSlot::Bg1), SchemeSlot::Lt1, "the default mapping when empty"); + assert_eq!(m.map_slot(SchemeSlot::Accent2), SchemeSlot::Accent2); + let dark: Vec<(String, String)> = [("bg1", "dk1"), ("tx1", "lt1")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect(); + let s = m.scheme_with(&dark); + assert_eq!(s.get(SchemeSlot::Lt1), base.get(SchemeSlot::Dk1)); + assert_eq!(s.get(SchemeSlot::Dk1), base.get(SchemeSlot::Lt1)); + assert_eq!(s.get(SchemeSlot::Lt2), base.get(SchemeSlot::Lt2), "bg2 keeps its default mapping"); + m.color_map = dark; + assert_eq!(m.scheme(), s); +} + +#[test] +fn enum_names_round_trip_through_their_xml() { + for k in [PhType::Title, PhType::CtrTitle, PhType::Table, PhType::Diagram, PhType::SlideImage, PhType::SlideNum] { + assert_eq!(PhType::from_xml(k.xml()), k); + } + assert_eq!(PhType::from_xml("zzz"), PhType::Body); + for k in [LayoutType::Title, LayoutType::Comparison, LayoutType::VerticalTitleAndText, LayoutType::Custom] { + assert_eq!(LayoutType::from_xml(k.xml()), k); + } + assert_eq!(LayoutType::from_xml("twoColTx"), LayoutType::TwoContent); +}