Skip to main content

deckcraft_model/
lib.rs

1//! The DeckCraft document model.
2//!
3//! A [`Presentation`] has slide masters (each with a theme and layouts) and slides. Each slide
4//! uses a layout; placeholders on a slide inherit position, formatting and text styles from the
5//! matching placeholder on its layout and master ([`resolve`]). Slides and masters sit behind
6//! `Arc`s so an undo snapshot of the whole deck costs one clone of the slide list.
7#![deny(
8    clippy::unwrap_used,
9    clippy::expect_used,
10    clippy::panic,
11    clippy::unimplemented,
12    clippy::todo,
13    clippy::unreachable
14)]
15#![forbid(unsafe_code)]
16
17pub mod anim;
18pub mod chart;
19pub mod defaults;
20pub mod edit;
21pub mod resolve;
22pub mod style;
23pub mod table;
24pub mod text;
25pub mod theme;
26pub mod words;
27
28use std::sync::Arc;
29
30pub use deckcraft_color::{ColorScheme, ColorTransform, Rgba, SchemeSlot};
31pub use deckcraft_geom::{Rect, Size, Xfrm};
32use serde::{Deserialize, Serialize};
33
34pub use anim::{AnimClass, AnimStart, Animation, Transition};
35pub use chart::Chart;
36pub use style::{ColorBase, ColorRef, Effects, Fill, Line, ShapeStyle};
37pub use table::{Cell, Table};
38pub use text::{Paragraph, Run, RunProps, TextBody};
39pub use theme::Theme;
40
41pub(crate) fn yes() -> bool {
42    true
43}
44
45macro_rules! id_type {
46    ($name:ident) => {
47        #[derive(
48            Clone,
49            Copy,
50            Debug,
51            Default,
52            PartialEq,
53            Eq,
54            PartialOrd,
55            Ord,
56            Hash,
57            Serialize,
58            Deserialize,
59        )]
60        #[serde(transparent)]
61        pub struct $name(pub u32);
62        impl std::fmt::Display for $name {
63            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64                write!(f, "{}", self.0)
65            }
66        }
67    };
68}
69id_type!(SlideId);
70id_type!(ShapeId);
71id_type!(LayoutId);
72id_type!(MasterId);
73id_type!(MediaId);
74
75#[derive(Debug, thiserror::Error)]
76pub enum ModelError {
77    #[error("no slide {0}")]
78    NoSlide(String),
79    #[error("no shape {0}")]
80    NoShape(ShapeId),
81    #[error("no layout {0}")]
82    NoLayout(LayoutId),
83    #[error("{0}")]
84    Invalid(String),
85}
86
87pub type Result<T> = std::result::Result<T, ModelError>;
88
89/// Placeholder types.
90#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
91#[serde(rename_all = "camelCase")]
92pub enum PhType {
93    Title,
94    CtrTitle,
95    SubTitle,
96    #[default]
97    Body,
98    Obj,
99    Chart,
100    Table,
101    ClipArt,
102    Diagram,
103    Media,
104    Picture,
105    Date,
106    Footer,
107    SlideNum,
108    Header,
109    SlideImage,
110}
111
112impl PhType {
113    pub fn xml(self) -> &'static str {
114        match self {
115            PhType::Title => "title",
116            PhType::CtrTitle => "ctrTitle",
117            PhType::SubTitle => "subTitle",
118            PhType::Body => "body",
119            PhType::Obj => "obj",
120            PhType::Chart => "chart",
121            PhType::Table => "tbl",
122            PhType::ClipArt => "clipArt",
123            PhType::Diagram => "dgm",
124            PhType::Media => "media",
125            PhType::Picture => "pic",
126            PhType::Date => "dt",
127            PhType::Footer => "ftr",
128            PhType::SlideNum => "sldNum",
129            PhType::Header => "hdr",
130            PhType::SlideImage => "sldImg",
131        }
132    }
133    pub fn from_xml(s: &str) -> Self {
134        match s {
135            "title" => PhType::Title,
136            "ctrTitle" => PhType::CtrTitle,
137            "subTitle" => PhType::SubTitle,
138            "obj" => PhType::Obj,
139            "chart" => PhType::Chart,
140            "tbl" => PhType::Table,
141            "clipArt" => PhType::ClipArt,
142            "dgm" => PhType::Diagram,
143            "media" => PhType::Media,
144            "pic" => PhType::Picture,
145            "dt" => PhType::Date,
146            "ftr" => PhType::Footer,
147            "sldNum" => PhType::SlideNum,
148            "hdr" => PhType::Header,
149            "sldImg" => PhType::SlideImage,
150            _ => PhType::Body,
151        }
152    }
153    pub fn is_title(self) -> bool {
154        matches!(self, PhType::Title | PhType::CtrTitle)
155    }
156    /// The prompt shown in an empty placeholder in Normal view.
157    pub fn prompt(self) -> &'static str {
158        match self {
159            PhType::Title | PhType::CtrTitle => "Click to add title",
160            PhType::SubTitle => "Click to add subtitle",
161            PhType::Body | PhType::Obj => "Click to add text",
162            PhType::Chart => "Click icon to add chart",
163            PhType::Table => "Click icon to add table",
164            PhType::Diagram => "Click icon to add SmartArt graphic",
165            PhType::Media => "Click icon to add media",
166            PhType::Picture | PhType::ClipArt => "Click icon to add picture",
167            PhType::Date => "Date",
168            PhType::Footer => "Footer",
169            PhType::SlideNum => "‹#›",
170            PhType::Header => "Header",
171            PhType::SlideImage => "",
172        }
173    }
174    /// Footer-row placeholders are not shown on slides unless the deck turns them on.
175    pub fn is_footer_kind(self) -> bool {
176        matches!(
177            self,
178            PhType::Date | PhType::Footer | PhType::SlideNum | PhType::Header
179        )
180    }
181}
182
183#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
184#[serde(default, rename_all = "camelCase")]
185pub struct Placeholder {
186    pub kind: PhType,
187    pub idx: u32,
188    /// `vert` orientation flag.
189    pub vertical: bool,
190    /// `quarter`, `half`, `full` size hint.
191    pub size: Option<String>,
192    pub has_custom_prompt: bool,
193}
194
195/// Shape geometry: a preset name with adjust values, or a custom path.
196#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
197#[serde(rename_all = "camelCase", tag = "kind")]
198pub enum Geom {
199    Preset {
200        name: String,
201        #[serde(default, skip_serializing_if = "Vec::is_empty")]
202        adj: Vec<f64>,
203    },
204    /// Custom geometry: sub-paths in their own coordinate space (w, h) scaled to the shape.
205    Custom { paths: Vec<CustomPath> },
206}
207
208impl Default for Geom {
209    fn default() -> Self {
210        Geom::Preset {
211            name: "rect".into(),
212            adj: vec![],
213        }
214    }
215}
216
217impl Geom {
218    pub fn preset(name: &str) -> Self {
219        Geom::Preset {
220            name: name.into(),
221            adj: vec![],
222        }
223    }
224    pub fn preset_name(&self) -> Option<&str> {
225        match self {
226            Geom::Preset { name, .. } => Some(name),
227            Geom::Custom { .. } => None,
228        }
229    }
230}
231
232/// One sub-path of a custom geometry: SVG-like commands in path units.
233#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
234#[serde(default, rename_all = "camelCase")]
235pub struct CustomPath {
236    pub w: f64,
237    pub h: f64,
238    /// `M x y`, `L x y`, `C x1 y1 x2 y2 x y`, `Q x1 y1 x y`, `Z`.
239    pub d: String,
240    pub fill: deckcraft_geom::preset::FillMode,
241    pub stroke: bool,
242}
243
244impl Default for CustomPath {
245    fn default() -> Self {
246        CustomPath {
247            w: 0.0,
248            h: 0.0,
249            d: String::new(),
250            fill: deckcraft_geom::preset::FillMode::Norm,
251            stroke: true,
252        }
253    }
254}
255
256/// Embedded audio or video on a slide.
257#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
258#[serde(default, rename_all = "camelCase")]
259pub struct MediaClip {
260    pub media: MediaId,
261    pub video: bool,
262    /// Poster frame image.
263    pub poster: Option<MediaId>,
264    pub trim_start_ms: u32,
265    pub trim_end_ms: u32,
266    pub fade_in_ms: u32,
267    pub fade_out_ms: u32,
268    /// 0–1.
269    pub volume: f64,
270    pub autoplay: bool,
271    pub loop_play: bool,
272    pub rewind: bool,
273    pub play_across_slides: bool,
274    pub hide_while_not_playing: bool,
275    pub full_screen: bool,
276    /// Bookmarks (name, ms).
277    pub bookmarks: Vec<(String, u32)>,
278    /// Probed length in ms (0 = unknown).
279    pub duration_ms: u32,
280    /// Probed picture size of a video in pixels (0 = unknown).
281    pub width: u32,
282    pub height: u32,
283}
284
285/// One ink stroke (Draw tab): points in slide coordinates with pressure.
286#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
287#[serde(default, rename_all = "camelCase")]
288pub struct InkStroke {
289    pub points: Vec<(f64, f64, f32)>,
290    pub color: Rgba,
291    pub width: f64,
292    pub highlighter: bool,
293}
294
295#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
296#[serde(rename_all = "camelCase", tag = "type")]
297pub enum ShapeKind {
298    /// An autoshape or text box.
299    Shape,
300    Picture {
301        fill: style::PictureFill,
302    },
303    Group {
304        children: Vec<Shape>,
305        /// Child coordinate space.
306        child: Xfrm,
307    },
308    Connector {
309        start: Option<(ShapeId, u32)>,
310        end: Option<(ShapeId, u32)>,
311    },
312    Table(Table),
313    Chart(Box<Chart>),
314    Media(MediaClip),
315    Ink {
316        strokes: Vec<InkStroke>,
317    },
318    /// Something we read but don't model (SmartArt without fallback, OLE…): its XML is kept and its
319    /// fallback picture, if any, is shown.
320    Opaque {
321        xml: String,
322        preview: Option<MediaId>,
323        label: String,
324    },
325}
326
327#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
328#[serde(default, rename_all = "camelCase")]
329pub struct Shape {
330    pub id: ShapeId,
331    pub name: String,
332    /// `None`: placeholder inheriting its box from the layout/master.
333    pub xfrm: Option<Xfrm>,
334    pub kind: ShapeKind,
335    pub geom: Geom,
336    pub ph: Option<Placeholder>,
337    /// `None`: inherited (placeholder chain or style); `Some(Fill::None)`: no fill.
338    pub fill: Option<Fill>,
339    pub line: Option<Line>,
340    pub effects: Option<Effects>,
341    pub style: Option<ShapeStyle>,
342    pub text: Option<TextBody>,
343    pub hidden: bool,
344    pub locked: bool,
345    /// Text box (`txBox="1"`): no fill, autosize, wrap off by default.
346    pub text_box: bool,
347    /// Alt text.
348    pub descr: String,
349    pub title: String,
350    pub decorative: bool,
351    pub click: Option<text::Hyperlink>,
352    pub hover: Option<text::Hyperlink>,
353    /// Unmodelled XML kept for round-trip (extension lists etc.).
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub raw_ext: Option<String>,
356}
357
358impl Default for Shape {
359    fn default() -> Self {
360        Shape {
361            id: ShapeId(0),
362            name: String::new(),
363            xfrm: None,
364            kind: ShapeKind::Shape,
365            geom: Geom::default(),
366            ph: None,
367            fill: None,
368            line: None,
369            effects: None,
370            style: None,
371            text: None,
372            hidden: false,
373            locked: false,
374            text_box: false,
375            descr: String::new(),
376            title: String::new(),
377            decorative: false,
378            click: None,
379            hover: None,
380            raw_ext: None,
381        }
382    }
383}
384
385impl Shape {
386    pub fn is_placeholder(&self) -> bool {
387        self.ph.is_some()
388    }
389    pub fn ph_type(&self) -> Option<PhType> {
390        self.ph.as_ref().map(|p| p.kind)
391    }
392    pub fn is_group(&self) -> bool {
393        matches!(self.kind, ShapeKind::Group { .. })
394    }
395    pub fn children(&self) -> &[Shape] {
396        match &self.kind {
397            ShapeKind::Group { children, .. } => children,
398            _ => &[],
399        }
400    }
401    pub fn children_mut(&mut self) -> Option<&mut Vec<Shape>> {
402        match &mut self.kind {
403            ShapeKind::Group { children, .. } => Some(children),
404            _ => None,
405        }
406    }
407    /// Is this a line-like shape (no area)?
408    pub fn is_line(&self) -> bool {
409        matches!(self.kind, ShapeKind::Connector { .. })
410            || self
411                .geom
412                .preset_name()
413                .is_some_and(deckcraft_geom::preset::is_line_like)
414    }
415    pub fn kind_name(&self) -> &'static str {
416        match &self.kind {
417            ShapeKind::Shape if self.ph.is_some() => "placeholder",
418            ShapeKind::Shape if self.text_box => "textBox",
419            ShapeKind::Shape => "shape",
420            ShapeKind::Picture { .. } => "picture",
421            ShapeKind::Group { .. } => "group",
422            ShapeKind::Connector { .. } => "connector",
423            ShapeKind::Table(_) => "table",
424            ShapeKind::Chart(_) => "chart",
425            ShapeKind::Media(m) if m.video => "video",
426            ShapeKind::Media(_) => "audio",
427            ShapeKind::Ink { .. } => "ink",
428            ShapeKind::Opaque { .. } => "object",
429        }
430    }
431}
432
433/// Walk shapes depth-first (groups included).
434pub fn walk<'a>(shapes: &'a [Shape], f: &mut dyn FnMut(&'a Shape, usize)) {
435    fn rec<'a>(shapes: &'a [Shape], depth: usize, f: &mut dyn FnMut(&'a Shape, usize)) {
436        if depth > 64 {
437            return;
438        }
439        for s in shapes {
440            f(s, depth);
441            rec(s.children(), depth + 1, f);
442        }
443    }
444    rec(shapes, 0, f);
445}
446
447pub fn find_shape(shapes: &[Shape], id: ShapeId) -> Option<&Shape> {
448    fn rec(shapes: &[Shape], id: ShapeId, depth: usize) -> Option<&Shape> {
449        if depth > 64 {
450            return None;
451        }
452        for s in shapes {
453            if s.id == id {
454                return Some(s);
455            }
456            if let Some(f) = rec(s.children(), id, depth + 1) {
457                return Some(f);
458            }
459        }
460        None
461    }
462    rec(shapes, id, 0)
463}
464
465pub fn find_shape_mut(shapes: &mut [Shape], id: ShapeId) -> Option<&mut Shape> {
466    fn rec(shapes: &mut [Shape], id: ShapeId, depth: usize) -> Option<&mut Shape> {
467        if depth > 64 {
468            return None;
469        }
470        for s in shapes.iter_mut() {
471            if s.id == id {
472                return Some(s);
473            }
474            if let ShapeKind::Group { children, .. } = &mut s.kind
475                && let Some(f) = rec(children, id, depth + 1)
476            {
477                return Some(f);
478            }
479        }
480        None
481    }
482    rec(shapes, id, 0)
483}
484
485/// The list holding `id` among its siblings: the tree's top level, or a group's children.
486pub fn sibling_list_mut(shapes: &mut Vec<Shape>, id: ShapeId) -> Option<&mut Vec<Shape>> {
487    fn rec(shapes: &mut Vec<Shape>, id: ShapeId, depth: usize) -> Option<&mut Vec<Shape>> {
488        if depth > 64 {
489            return None;
490        }
491        if shapes.iter().any(|s| s.id == id) {
492            return Some(shapes);
493        }
494        for s in shapes.iter_mut() {
495            if let ShapeKind::Group { children, .. } = &mut s.kind
496                && let Some(f) = rec(children, id, depth + 1)
497            {
498                return Some(f);
499            }
500        }
501        None
502    }
503    rec(shapes, id, 0)
504}
505
506/// The group directly holding `id`: `Some(None)` for a top-level shape, `None` when absent.
507pub fn parent_of(shapes: &[Shape], id: ShapeId) -> Option<Option<ShapeId>> {
508    fn rec(
509        shapes: &[Shape],
510        parent: Option<ShapeId>,
511        id: ShapeId,
512        depth: usize,
513    ) -> Option<Option<ShapeId>> {
514        if depth > 64 {
515            return None;
516        }
517        for s in shapes {
518            if s.id == id {
519                return Some(parent);
520            }
521            if let Some(f) = rec(s.children(), Some(s.id), id, depth + 1) {
522                return Some(f);
523            }
524        }
525        None
526    }
527    rec(shapes, None, id, 0)
528}
529
530/// Background of a slide, layout or master.
531#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
532#[serde(rename_all = "camelCase", tag = "kind")]
533pub enum Background {
534    Fill {
535        fill: Fill,
536    },
537    /// Theme background style reference (1001–1003) with a colour.
538    Ref {
539        idx: u32,
540        color: ColorRef,
541    },
542}
543
544#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
545#[serde(default, rename_all = "camelCase")]
546pub struct Comment {
547    pub author: String,
548    pub initials: String,
549    pub text: String,
550    /// ISO 8601.
551    pub date: String,
552    pub x: f64,
553    pub y: f64,
554    pub resolved: bool,
555    pub replies: Vec<Comment>,
556    /// The shape the comment is attached to.
557    pub shape: Option<ShapeId>,
558}
559
560#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
561#[serde(default, rename_all = "camelCase")]
562pub struct Slide {
563    pub id: SlideId,
564    pub layout: LayoutId,
565    pub name: String,
566    pub shapes: Vec<Shape>,
567    pub background: Option<Background>,
568    pub hidden: bool,
569    pub show_master_shapes: bool,
570    pub transition: Option<Transition>,
571    pub animations: Vec<Animation>,
572    /// Speaker notes.
573    pub notes: TextBody,
574    pub comments: Vec<Comment>,
575    /// `clrMapOvr`: a colour map replacing the layout's or master's (`bg1`→`dk1`…); None follows
576    /// the layout.
577    #[serde(skip_serializing_if = "Option::is_none")]
578    pub color_map: Option<Vec<(String, String)>>,
579    /// Unmodelled XML kept for round-trip (custom data, tags).
580    #[serde(skip_serializing_if = "Option::is_none")]
581    pub raw_ext: Option<String>,
582}
583
584impl Default for Slide {
585    fn default() -> Self {
586        Slide {
587            id: SlideId(0),
588            layout: LayoutId(0),
589            name: String::new(),
590            shapes: vec![],
591            background: None,
592            hidden: false,
593            show_master_shapes: true,
594            transition: None,
595            animations: vec![],
596            notes: TextBody::default(),
597            comments: vec![],
598            color_map: None,
599            raw_ext: None,
600        }
601    }
602}
603
604impl Slide {
605    pub fn shape(&self, id: ShapeId) -> Option<&Shape> {
606        find_shape(&self.shapes, id)
607    }
608    pub fn shape_mut(&mut self, id: ShapeId) -> Option<&mut Shape> {
609        find_shape_mut(&mut self.shapes, id)
610    }
611    /// The slide's title text (first title placeholder), for thumbnails, outline and navigation.
612    pub fn title(&self) -> String {
613        self.shapes
614            .iter()
615            .find(|s| s.ph_type().is_some_and(PhType::is_title))
616            .and_then(|s| s.text.as_ref())
617            .map(|t| t.text().replace('\u{b}', " "))
618            .unwrap_or_default()
619    }
620    pub fn notes_text(&self) -> String {
621        self.notes.text()
622    }
623}
624
625#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
626#[serde(rename_all = "camelCase")]
627pub enum LayoutType {
628    Title,
629    #[default]
630    TitleAndContent,
631    SectionHeader,
632    TwoContent,
633    Comparison,
634    TitleOnly,
635    Blank,
636    ContentWithCaption,
637    PictureWithCaption,
638    TitleAndVerticalText,
639    VerticalTitleAndText,
640    Custom,
641}
642
643impl LayoutType {
644    pub fn xml(self) -> &'static str {
645        match self {
646            LayoutType::Title => "title",
647            LayoutType::TitleAndContent => "obj",
648            LayoutType::SectionHeader => "secHead",
649            LayoutType::TwoContent => "twoObj",
650            LayoutType::Comparison => "twoTxTwoObj",
651            LayoutType::TitleOnly => "titleOnly",
652            LayoutType::Blank => "blank",
653            LayoutType::ContentWithCaption => "objTx",
654            LayoutType::PictureWithCaption => "picTx",
655            LayoutType::TitleAndVerticalText => "vertTx",
656            LayoutType::VerticalTitleAndText => "vertTitleAndTx",
657            LayoutType::Custom => "cust",
658        }
659    }
660    pub fn from_xml(s: &str) -> Self {
661        match s {
662            "title" => LayoutType::Title,
663            "obj" | "tx" => LayoutType::TitleAndContent,
664            "secHead" => LayoutType::SectionHeader,
665            "twoObj" | "twoColTx" => LayoutType::TwoContent,
666            "twoTxTwoObj" => LayoutType::Comparison,
667            "titleOnly" => LayoutType::TitleOnly,
668            "blank" => LayoutType::Blank,
669            "objTx" => LayoutType::ContentWithCaption,
670            "picTx" => LayoutType::PictureWithCaption,
671            "vertTx" => LayoutType::TitleAndVerticalText,
672            "vertTitleAndTx" => LayoutType::VerticalTitleAndText,
673            _ => LayoutType::Custom,
674        }
675    }
676}
677
678#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
679#[serde(default, rename_all = "camelCase")]
680pub struct Layout {
681    pub id: LayoutId,
682    pub name: String,
683    pub kind: LayoutType,
684    pub shapes: Vec<Shape>,
685    pub background: Option<Background>,
686    pub show_master_shapes: bool,
687    pub preserve: bool,
688    /// `clrMapOvr`: a colour map replacing the master's for this layout and the slides using it
689    /// (dark "section" layouts map `bg1` to `dk1`); None follows the master.
690    #[serde(skip_serializing_if = "Option::is_none")]
691    pub color_map: Option<Vec<(String, String)>>,
692    #[serde(skip_serializing_if = "Option::is_none")]
693    pub raw_ext: Option<String>,
694}
695
696/// Which placeholders slides show in the footer row (Header & Footer dialog).
697#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
698#[serde(default, rename_all = "camelCase")]
699pub struct HeaderFooter {
700    pub date: bool,
701    pub slide_number: bool,
702    pub footer: bool,
703    pub header: bool,
704    pub footer_text: String,
705    /// Fixed date text; empty = automatic date.
706    pub date_text: String,
707    /// `datetime1`…`datetime13` format for automatic dates.
708    pub date_format: String,
709    pub hide_on_title: bool,
710}
711
712#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
713#[serde(default, rename_all = "camelCase")]
714pub struct Master {
715    pub id: MasterId,
716    pub name: String,
717    pub theme: Theme,
718    pub shapes: Vec<Shape>,
719    pub background: Option<Background>,
720    pub title_style: text::ListStyle,
721    pub body_style: text::ListStyle,
722    pub other_style: text::ListStyle,
723    pub layouts: Vec<Layout>,
724    /// `bg1`→`lt1` etc. Default mapping when empty.
725    pub color_map: Vec<(String, String)>,
726    pub preserve: bool,
727    #[serde(skip_serializing_if = "Option::is_none")]
728    pub raw_ext: Option<String>,
729}
730
731impl Master {
732    pub fn layout(&self, id: LayoutId) -> Option<&Layout> {
733        self.layouts.iter().find(|l| l.id == id)
734    }
735    /// Resolve `bg1`/`tx1`/`bg2`/`tx2` through the colour map.
736    pub fn map_slot(&self, slot: SchemeSlot) -> SchemeSlot {
737        map_slot(&self.color_map, slot)
738    }
739    /// The theme's colour scheme with the colour map applied (`bg1`… resolve to mapped colours).
740    pub fn scheme(&self) -> ColorScheme {
741        self.scheme_with(&self.color_map)
742    }
743    /// The theme's colour scheme with `map` (a layout's or slide's override, or the master's own)
744    /// applied.
745    pub fn scheme_with(&self, map: &[(String, String)]) -> ColorScheme {
746        let mut s = self.theme.colors.clone();
747        if map.is_empty() {
748            return s;
749        }
750        let base = self.theme.colors.clone();
751        for (alias, slot) in [
752            (SchemeSlot::Bg1, SchemeSlot::Lt1),
753            (SchemeSlot::Tx1, SchemeSlot::Dk1),
754            (SchemeSlot::Bg2, SchemeSlot::Lt2),
755            (SchemeSlot::Tx2, SchemeSlot::Dk2),
756        ] {
757            s.set(slot, base.get(map_slot(map, alias)));
758        }
759        s
760    }
761}
762
763/// Resolve `bg1`/`tx1`/`bg2`/`tx2` through the colour map `map` (the default mapping when it has
764/// no entry).
765pub fn map_slot(map: &[(String, String)], slot: SchemeSlot) -> SchemeSlot {
766    let key = match slot {
767        SchemeSlot::Bg1 => "bg1",
768        SchemeSlot::Tx1 => "tx1",
769        SchemeSlot::Bg2 => "bg2",
770        SchemeSlot::Tx2 => "tx2",
771        other => return other,
772    };
773    map.iter()
774        .find(|(k, _)| k == key)
775        .and_then(|(_, v)| SchemeSlot::from_xml(v))
776        .unwrap_or(match slot {
777            SchemeSlot::Bg1 => SchemeSlot::Lt1,
778            SchemeSlot::Tx1 => SchemeSlot::Dk1,
779            SchemeSlot::Bg2 => SchemeSlot::Lt2,
780            _ => SchemeSlot::Dk2,
781        })
782}
783
784#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
785#[serde(default, rename_all = "camelCase")]
786pub struct Section {
787    pub name: String,
788    pub slides: Vec<SlideId>,
789}
790
791#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
792#[serde(default, rename_all = "camelCase")]
793pub struct CustomShow {
794    pub name: String,
795    pub slides: Vec<SlideId>,
796}
797
798/// Slide Show ▸ Set Up Slide Show.
799#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
800#[serde(default, rename_all = "camelCase")]
801pub struct ShowSettings {
802    /// `speaker`, `browsed`, `kiosk`.
803    pub show_type: String,
804    pub loop_until_esc: bool,
805    pub without_narration: bool,
806    pub without_animation: bool,
807    pub use_timings: bool,
808    /// 1-based inclusive range; `None` = all.
809    pub range: Option<(u32, u32)>,
810    pub custom_show: Option<String>,
811    pub pen_color: Rgba,
812    pub laser_color: Rgba,
813    pub show_media_controls: bool,
814}
815
816impl Default for ShowSettings {
817    fn default() -> Self {
818        ShowSettings {
819            show_type: "speaker".into(),
820            loop_until_esc: false,
821            without_narration: false,
822            without_animation: false,
823            use_timings: true,
824            range: None,
825            custom_show: None,
826            pen_color: Rgba::rgb(0xFF, 0x00, 0x00),
827            laser_color: Rgba::rgb(0xFF, 0x00, 0x00),
828            show_media_controls: true,
829        }
830    }
831}
832
833/// Document properties (File ▸ Properties).
834#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
835#[serde(default, rename_all = "camelCase")]
836pub struct Properties {
837    pub title: String,
838    pub subject: String,
839    pub author: String,
840    pub keywords: String,
841    pub comments: String,
842    pub category: String,
843    pub company: String,
844    pub created: String,
845    pub modified: String,
846    pub last_modified_by: String,
847    pub revision: u32,
848}
849
850/// An embedded file (image, audio, video, font, workbook).
851#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
852#[serde(default, rename_all = "camelCase")]
853pub struct MediaItem {
854    pub id: MediaId,
855    /// Original file name, e.g. `image1.png`.
856    pub name: String,
857    pub content_type: String,
858    /// Bytes; kept out of the JSON manifest and stored as a separate file in native files.
859    #[serde(skip)]
860    pub data: Arc<Vec<u8>>,
861    /// External link instead of embedded bytes.
862    pub link: Option<String>,
863}
864
865#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
866#[serde(default, rename_all = "camelCase")]
867pub struct Presentation {
868    /// Slide size in points (default 16:9, 960 × 540).
869    pub slide_size: Size,
870    pub notes_size: Size,
871    pub slides: Vec<Arc<Slide>>,
872    pub masters: Vec<Arc<Master>>,
873    pub notes_master: Option<Arc<Master>>,
874    pub handout_master: Option<Arc<Master>>,
875    pub sections: Vec<Section>,
876    pub custom_shows: Vec<CustomShow>,
877    pub show: ShowSettings,
878    pub header_footer: HeaderFooter,
879    pub props: Properties,
880    /// Presentation-wide default text style (shapes that aren't placeholders).
881    pub default_text_style: text::ListStyle,
882    pub media: Vec<MediaItem>,
883    pub first_slide_number: u32,
884    /// Next free shape/slide/layout/media id.
885    pub next_id: u32,
886    /// Embedded fonts (family names; bytes in `media`).
887    pub embedded_fonts: Vec<(String, MediaId)>,
888    /// Table styles defined in the file (`ppt/tableStyles.xml`).
889    #[serde(skip_serializing_if = "Vec::is_empty")]
890    pub table_styles: Vec<table::TableStyleDef>,
891    /// Unmodelled presentation-level XML kept for round-trip.
892    #[serde(skip_serializing_if = "Option::is_none")]
893    pub raw_ext: Option<String>,
894}
895
896impl Default for Presentation {
897    fn default() -> Self {
898        defaults::new_presentation(None)
899    }
900}
901
902impl Presentation {
903    /// A fresh id, unique across slides, shapes, layouts, masters and media in this deck.
904    pub fn alloc_id(&mut self) -> u32 {
905        self.next_id = self.next_id.max(1).saturating_add(1);
906        self.next_id
907    }
908    /// Make sure `next_id` is above every id in use (after loading a file).
909    pub fn fix_next_id(&mut self) {
910        let mut m = self.next_id;
911        for s in &self.slides {
912            m = m.max(s.id.0);
913            walk(&s.shapes, &mut |sh, _| m = m.max(sh.id.0));
914        }
915        for ma in &self.masters {
916            m = m.max(ma.id.0);
917            walk(&ma.shapes, &mut |sh, _| m = m.max(sh.id.0));
918            for l in &ma.layouts {
919                m = m.max(l.id.0);
920                walk(&l.shapes, &mut |sh, _| m = m.max(sh.id.0));
921            }
922        }
923        for md in &self.media {
924            m = m.max(md.id.0);
925        }
926        self.next_id = m;
927    }
928    pub fn slide_index(&self, id: SlideId) -> Option<usize> {
929        self.slides.iter().position(|s| s.id == id)
930    }
931    pub fn slide(&self, id: SlideId) -> Option<&Slide> {
932        self.slides.iter().find(|s| s.id == id).map(|s| s.as_ref())
933    }
934    pub fn slide_mut(&mut self, id: SlideId) -> Option<&mut Slide> {
935        self.slides
936            .iter_mut()
937            .find(|s| s.id == id)
938            .map(Arc::make_mut)
939    }
940    pub fn slide_at(&self, i: usize) -> Option<&Slide> {
941        self.slides.get(i).map(|s| s.as_ref())
942    }
943    /// Master and layout for a layout id.
944    pub fn layout(&self, id: LayoutId) -> Option<(&Master, &Layout)> {
945        self.masters
946            .iter()
947            .find_map(|m| m.layout(id).map(|l| (m.as_ref(), l)))
948    }
949    /// The master (and layout) a slide uses, falling back to the first.
950    pub fn master_for(&self, slide: &Slide) -> Option<(&Master, Option<&Layout>)> {
951        if let Some((m, l)) = self.layout(slide.layout) {
952            return Some((m, Some(l)));
953        }
954        self.masters
955            .first()
956            .map(|m| (m.as_ref(), m.layouts.first()))
957    }
958    pub fn first_master(&self) -> Option<&Master> {
959        self.masters.first().map(|m| m.as_ref())
960    }
961    pub fn media(&self, id: MediaId) -> Option<&MediaItem> {
962        self.media.iter().find(|m| m.id == id)
963    }
964    /// Add media bytes, reusing an identical existing item.
965    pub fn add_media(&mut self, name: &str, content_type: &str, data: Vec<u8>) -> MediaId {
966        if let Some(m) = self
967            .media
968            .iter()
969            .find(|m| m.data.len() == data.len() && *m.data == data)
970        {
971            return m.id;
972        }
973        let id = MediaId(self.alloc_id());
974        self.media.push(MediaItem {
975            id,
976            name: name.into(),
977            content_type: content_type.into(),
978            data: Arc::new(data),
979            link: None,
980        });
981        id
982    }
983    /// Section index containing a slide (by position), when sections exist.
984    pub fn section_of(&self, slide: SlideId) -> Option<usize> {
985        self.sections.iter().position(|s| s.slides.contains(&slide))
986    }
987    /// 1-based slide number as displayed (respects `first_slide_number`).
988    pub fn slide_number(&self, index: usize) -> u32 {
989        self.first_slide_number
990            .saturating_add(u32::try_from(index).unwrap_or(u32::MAX))
991    }
992    /// Basic consistency check: unique slide/shape ids, layouts exist. Returns problems found.
993    pub fn validate(&self) -> Vec<String> {
994        let mut out = vec![];
995        let mut seen = std::collections::HashSet::new();
996        for s in &self.slides {
997            if !seen.insert(s.id) {
998                out.push(format!("duplicate slide id {}", s.id));
999            }
1000            if self.layout(s.layout).is_none() {
1001                out.push(format!("slide {} uses missing layout {}", s.id, s.layout));
1002            }
1003            let mut ids = std::collections::HashSet::new();
1004            walk(&s.shapes, &mut |sh, _| {
1005                if !ids.insert(sh.id) {
1006                    out.push(format!("slide {}: duplicate shape id {}", s.id, sh.id));
1007                }
1008            });
1009        }
1010        out
1011    }
1012}
1013
1014#[cfg(test)]
1015mod tests;