mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 07:46:08 +00:00
Add craft-pptx: the PresentationML model (from DeckCraft) (#1044)
* Add craft-drawingml: units, colours, preset shapes, custom geometry and text warps The parts of DrawingML that need no document model, moved from DeckCraft (60f91f78): crates/color, crates/geom (preset, warp, Xfrm and the EMU conversions) and crates/pptx/src/custgeom.rs with CustomPath from crates/model. The code is unchanged except that custgeom reads craft-xml trees and writes to a craft-xml Writer instead of DeckCraft's own XML helper, and matches elements by local name. The 59 tests of the moved code come with it; six more cover the custom geometry limits. * drawingml: evaluate a custom geometry's guide lists once per coordinate space read_cust_geom rebuilt Guides and evaluated avLst and gdLst again for every path: up to 1,000 paths times up to 10,000 guides, each name copied and hashed. Guides are now cached per path coordinate space (w, h). At most 16 spaces and 4 MiB of guide names are kept per geometry; paths in further spaces are left out. The first space is always kept, and a geometry without guides is not capped. Ported from storytold/deckcraft#1789. A list counts as having guides only when it has gd children, as that PR's review asked. * drawingml: the model types for fills, lines, text, tables, charts and diagrams Moved from DeckCraft (60f91f78) crates/model/src: style.rs, text.rs, table.rs, chart.rs, numfmt.rs and trendline.rs, plus Diagram, DiagramNode and the MediaId and SlideId newtypes from lib.rs. The files are unchanged except that deckcraft_color:: is crate::color:: and Fill and TableBorder allow clippy::large_enum_variant. Their 60 tests come with them. Readers and writers follow. * drawingml: the theme model (colour, font and format schemes) Moved from DeckCraft crates/model/src/theme.rs at 60f91f78: FontSet, ScriptFont, FontScheme, FormatScheme, Theme and default_format. The app's theme gallery, its variants and its own default theme stay in the app. Theme::default() is a plain theme built in place, so nothing here depends on a gallery. * drawingml: readers and writers for fills, lines, text, charts, themes, table styles and diagrams Moved from DeckCraft crates/pptx/src at 60f91f78: read/dml.rs, write/dml.rs, read/chart.rs, read/chartex.rs, write/chart.rs, read/theme.rs, write/theme.rs, tables.rs and dgm.rs, onto craft-xml. Package lookups (Imp, Part, Exp, Out) are the caller's part::Source and part::Sink. The chart frame budget and kept-part capture stay with the presentation reader. * drawingml: what WordCraft and GridCraft read that DeckCraft's copy did not Every ST_PresetColorVal name has its colour, in any case and with the dk/lt/med abbreviations; a sysClr without lastClr takes the usual value of its name; a Strict percentage (50%) reads anywhere a percentage does; a chart series keeps its c:f formulas and the writer uses them. * craft-drawingml: extra named theme colours and explicit tint rows Ported from the format part of storytold/deckcraft#1746 by @jagjordi: ColorScheme::{extra, tints, computed_tints}, ExtraColor, GalleryVariant, variants_of, and the a:clrScheme extension that keeps them in a file. * drawingml xmlx: copy only used, bounded namespace declarations onto kept fragments and mc branches From storytold/deckcraft#1740 (988a4920), which landed after the extraction commit 60f91f78. * Add craft-pptx: the PresentationML model (from DeckCraft) Presentation, masters, layouts, slides, shapes, placeholders, media, comments, sections, show settings, properties, embedded fonts, kept parts and drawing guides from DeckCraft's crates/model/src/lib.rs, and transitions and animations from anim.rs. The DrawingML types come from craft-drawingml. The placeholder matching from DeckCraft's resolve.rs moves as placeholder::parents, without the rest of resolve. Presentation::default() is now an empty 16:9 deck with no masters; the starting deck stays with the application. --------- Co-authored-by: Jordi Altayó <jordiag@kth.se>
This commit is contained in:
co-authored by
Jordi Altayó
parent
392b8720cd
commit
5538d8a0d7
Generated
+10
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -262,6 +262,9 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
- [`craft-plotter`](crates/plotter/README.md): cutter and pen-plotter command languages: HP-GL,
|
||||
DMPL and GP-GL (plain and the Silhouette dialect, three generations, registration marks) from a
|
||||
job in millimetres, plus the Silhouette device queries and reply parsers. No I/O.
|
||||
- [`craft-pptx`](crates/pptx/README.md): PresentationML, the model of a `.pptx` presentation:
|
||||
masters, layouts, slides, shapes, placeholders and their inheritance, transitions and animations,
|
||||
comments, sections, media and kept parts. From DeckCraft. DrawingML comes from `craft-drawingml`.
|
||||
- [`craft-print`](crates/print/README.md): PDF submission to CUPS and Windows, printer discovery,
|
||||
duplex/copies/colour and driver preferences. PDF generation stays in each app.
|
||||
- [`craft-proof`](crates/proof/README.md): proofing per language (spelling, suggestions, grammar,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "craft-pptx"
|
||||
description = "PresentationML (ECMA-376 part 1, chapter 19): the model of a .pptx presentation (masters, layouts, slides, shapes, placeholders, transitions and animations)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-drawingml = { workspace = true }
|
||||
kurbo = { workspace = true, features = ["serde"] }
|
||||
serde = { workspace = true, features = ["rc"] }
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,21 @@
|
||||
# craft-pptx
|
||||
|
||||
PresentationML (ECMA-376 part 1, chapter 19): what a `.pptx` presentation holds, as plain Rust
|
||||
types with serde. The DrawingML inside it (fills, lines, text bodies, tables, charts, themes)
|
||||
comes from `craft-drawingml`.
|
||||
|
||||
| Item | What | Markup |
|
||||
|---|---|---|
|
||||
| `Presentation` | slide size, masters, slides, sections, custom shows, show settings, header and footer, properties, media, embedded fonts, table styles, tags, kept parts, drawing guides | `p:presentation`, `docProps/*`, `p:viewPr` |
|
||||
| `Master`, `Layout` | theme, shapes, background, title/body/other text styles, colour map | `p:sldMaster`, `p:sldLayout` |
|
||||
| `Slide` | shapes, background, transition, animations, notes, comments, colour map override | `p:sld`, `p:notes`, comments parts |
|
||||
| `Shape`, `ShapeKind` | shapes, pictures, groups, connectors, tables, charts, media, ink, 3-D models, and objects kept as XML | `p:sp`, `p:pic`, `p:grpSp`, `p:cxnSp`, `p:graphicFrame`, `p:contentPart` |
|
||||
| `Placeholder`, `PhType`, `placeholder` | placeholders and the rule that matches a slide placeholder to its layout and master parents | `p:ph` |
|
||||
| `anim` | transitions and the flat list of animations the timing tree stands for, with the effect catalogues | `p:transition`, `p:timing` |
|
||||
|
||||
Ids (`ShapeId`, `LayoutId`, `MasterId`, and `MediaId`, `SlideId` from `craft-drawingml`) are
|
||||
unique across a presentation; `Presentation::alloc_id` hands out fresh ones and refuses once they
|
||||
run out. `Presentation::default()` is an empty 16:9 deck with no masters: an application adds its
|
||||
own starting masters and layouts.
|
||||
|
||||
The reader and writer come in later changes.
|
||||
@@ -0,0 +1,668 @@
|
||||
//! Slide transitions and object animations.
|
||||
//!
|
||||
//! Animations are kept as the flat, ordered list the Animation Pane shows; the PPTX writer builds
|
||||
//! the nested timing tree from it and the reader flattens files back into it.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ShapeId;
|
||||
use craft_drawingml::{style, text};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Transition {
|
||||
/// Effect id: `none`, `fade`, `push`, `wipe`, `split`, `reveal`, `cut`, `randomBar`, `shape`,
|
||||
/// `uncover`, `cover`, `flash`, `morph`, `dissolve`, `checker`, `blinds`, `clock`, `zoom`… (see
|
||||
/// [`TRANSITIONS`]).
|
||||
pub kind: String,
|
||||
/// Effect option, e.g. direction `l`, `r`, `u`, `d`, `horz`, `vert`, `in`, `out`.
|
||||
pub option: String,
|
||||
pub duration_ms: u32,
|
||||
pub advance_on_click: bool,
|
||||
pub advance_after_ms: Option<u32>,
|
||||
/// Embedded sound media.
|
||||
pub sound: Option<crate::MediaId>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<BuildRole> {
|
||||
let top = text.paragraphs.iter().filter(|p| build_filled(p)).map(|p| p.level).min().unwrap_or(0);
|
||||
let mut tops = 0;
|
||||
let mut owner: Option<usize> = None;
|
||||
text.paragraphs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
if p.level <= top && build_filled(p) && tops < MAX_BUILD_PARAGRAPHS {
|
||||
tops += 1;
|
||||
owner = Some(i);
|
||||
BuildRole::Top
|
||||
} else {
|
||||
owner.map_or(BuildRole::Unowned, BuildRole::Member)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Animation {
|
||||
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<u32>,
|
||||
/// Motion path in slide-relative units (SVG-like `M 0 0 L 0.25 0 E`).
|
||||
pub path: Option<String>,
|
||||
/// Triggered by a click on another shape.
|
||||
pub trigger: Option<ShapeId>,
|
||||
/// Emphasis amount: spin degrees, grow/shrink scale (1.5 = 150%), transparency…
|
||||
pub amount: Option<f64>,
|
||||
/// Emphasis colour (fill/line/font colour change).
|
||||
pub color: Option<style::ColorRef>,
|
||||
/// PowerPoint preset id/subtype read from or written to files.
|
||||
pub preset_id: Option<u32>,
|
||||
pub preset_subtype: Option<u32>,
|
||||
/// After animation: dim to colour, hide, hide on next click.
|
||||
pub after: Option<String>,
|
||||
pub sound: Option<crate::MediaId>,
|
||||
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<String> {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>| 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<Shape>| v.iter().map(|s| s.id.0).collect::<Vec<_>>();
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user