Skip to main content

pptx_edit/
model.rs

1use std::collections::BTreeMap;
2
3use ooxml_drawingml::{ShapeFill, ShapeOutline};
4use pptx_parse::{BlipEffect, GraphicFrameData, Placeholder};
5
6pub use pptx_parse::{CommentFlavor, TextCaps};
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub enum EditOrigin {
13    #[default]
14    Local,
15    Agent,
16    Remote,
17    System,
18}
19
20#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct EditCtx {
23    pub origin: EditOrigin,
24    pub author: String,
25}
26
27impl EditCtx {
28    pub fn local(author: impl Into<String>) -> Self {
29        Self {
30            origin: EditOrigin::Local,
31            author: author.into(),
32        }
33    }
34}
35
36#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct TextStyle {
39    pub bold: Option<bool>,
40    pub italic: Option<bool>,
41    pub font_size_pt: Option<f64>,
42    pub color: Option<String>,
43    pub font_family: Option<String>,
44    pub underline: Option<String>,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub spacing_pt: Option<f64>,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub baseline_pct: Option<f64>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub caps: Option<TextCaps>,
51}
52
53#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct TextStylePatch {
56    pub bold: Option<bool>,
57    pub italic: Option<bool>,
58    pub font_size_pt: Option<f64>,
59    pub color: Option<String>,
60    pub font_family: Option<String>,
61    pub underline: Option<String>,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub spacing_pt: Option<f64>,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub baseline_pct: Option<f64>,
66}
67
68#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct TextRunSnapshot {
71    pub text: String,
72    pub style: TextStyle,
73}
74
75#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct ParagraphSnapshot {
78    pub id: String,
79    pub alignment: Option<String>,
80    pub level: u32,
81    pub bullet_json: Option<String>,
82    pub runs: Vec<TextRunSnapshot>,
83}
84
85#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
86#[serde(rename_all = "camelCase")]
87pub struct StorySnapshot {
88    pub id: String,
89    pub length: u32,
90    pub paragraphs: Vec<ParagraphSnapshot>,
91}
92
93impl StorySnapshot {
94    pub fn plain_text(&self) -> String {
95        self.paragraphs
96            .iter()
97            .map(|paragraph| {
98                paragraph
99                    .runs
100                    .iter()
101                    .map(|run| run.text.as_str())
102                    .collect::<String>()
103            })
104            .collect::<Vec<_>>()
105            .join("\n")
106    }
107}
108
109#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct ShapeSnapshot {
112    pub id: String,
113    pub source_id: u32,
114    pub kind: ShapeKind,
115    pub name: String,
116    pub x: i64,
117    pub y: i64,
118    pub width: i64,
119    pub height: i64,
120    pub rotation_deg: f64,
121    pub flip_h: bool,
122    pub flip_v: bool,
123    /// The geometry the shape draws at, present only while it has none of
124    /// its own.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub inherited: Option<InheritedGeometry>,
127    /// Hides this shape and its descendants.
128    #[serde(default, skip_serializing_if = "is_false")]
129    pub hidden: bool,
130    pub geometry: String,
131    pub adjust_values: BTreeMap<String, f64>,
132    pub placeholder: Option<Placeholder>,
133    pub fill: Option<ShapeFill>,
134    pub resolved_fill_color: Option<String>,
135    pub outline: Option<ShapeOutline>,
136    pub resolved_outline_color: Option<String>,
137    pub media_part_path: Option<String>,
138    /// Image data added to this session, retained across saves.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub pending_media: Option<PendingMedia>,
141    #[serde(default, skip_serializing_if = "Vec::is_empty")]
142    pub blip_effects: Vec<BlipEffect>,
143    pub graphic: Option<GraphicFrameData>,
144    pub text_stories: Vec<StorySnapshot>,
145    pub children: Vec<ShapeSnapshot>,
146}
147
148/// The transform a placeholder takes from its layout or master.
149#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct InheritedGeometry {
152    pub x: i64,
153    pub y: i64,
154    pub width: i64,
155    pub height: i64,
156    pub rotation_deg: f64,
157    pub flip_h: bool,
158    pub flip_v: bool,
159}
160
161/// Image data shared by editing peers.
162#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
163#[serde(rename_all = "camelCase")]
164pub struct PendingMedia {
165    pub content_type: String,
166    pub base64: String,
167}
168
169fn is_false(value: &bool) -> bool {
170    !value
171}
172
173#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
174#[serde(rename_all = "camelCase")]
175pub enum ShapeKind {
176    Shape,
177    Picture,
178    GraphicFrame,
179    Group,
180}
181
182#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
183#[serde(rename_all = "camelCase")]
184pub struct SlideSnapshot {
185    pub id: String,
186    pub source_part_path: Option<String>,
187    pub layout_part_path: Option<String>,
188    pub name: Option<String>,
189    #[serde(default, skip_serializing_if = "String::is_empty")]
190    pub notes: String,
191    pub shapes: Vec<ShapeSnapshot>,
192}
193
194/// One slide's snapshot plus deck geometry — the slide-scoped render input.
195#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197pub struct SlideScope {
198    /// The slide's position in deck order.
199    pub index: usize,
200    pub slide: SlideSnapshot,
201    pub width_emu: i64,
202    pub height_emu: i64,
203}
204
205#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
206#[serde(rename_all = "camelCase")]
207pub struct DeckSnapshot {
208    pub width_emu: i64,
209    pub height_emu: i64,
210    pub slides: Vec<SlideSnapshot>,
211    #[serde(default, skip_serializing_if = "legacy_comment_flavor")]
212    pub comment_flavor: CommentFlavor,
213    #[serde(default, skip_serializing_if = "Vec::is_empty")]
214    pub comments: Vec<CommentSnapshot>,
215}
216
217fn legacy_comment_flavor(flavor: &CommentFlavor) -> bool {
218    *flavor == CommentFlavor::Legacy
219}
220
221#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
222#[serde(rename_all = "camelCase")]
223pub struct CommentSnapshot {
224    pub id: String,
225    pub slide_id: String,
226    pub author: String,
227    pub initials: String,
228    pub text: String,
229    pub created: Option<String>,
230    pub x_emu: i64,
231    pub y_emu: i64,
232    pub parent_id: Option<String>,
233    pub resolved: bool,
234}
235
236#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
237#[serde(rename_all = "camelCase")]
238pub struct CommentReceipt {
239    pub comment_id: String,
240    pub slide_id: String,
241    pub parent_id: Option<String>,
242    pub resolved: bool,
243}
244
245#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
246#[serde(rename_all = "camelCase")]
247pub struct SlideReceipt {
248    pub slide_id: String,
249    pub from_index: Option<u32>,
250    pub to_index: Option<u32>,
251}
252
253#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
254#[serde(rename_all = "camelCase")]
255pub struct ShapeReceipt {
256    pub slide_id: String,
257    pub shape_id: String,
258    pub index: u32,
259}
260
261#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
262#[serde(rename_all = "camelCase")]
263pub struct ShapeZOrderReceipt {
264    pub slide_id: String,
265    pub shape_id: String,
266    pub from_index: u32,
267    pub to_index: u32,
268}
269
270#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
271#[serde(rename_all = "camelCase")]
272pub struct TransformReceipt {
273    pub slide_id: String,
274    pub shape_id: String,
275    pub before: ShapeRect,
276    pub after: ShapeRect,
277}
278
279#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
280pub struct ShapeRect {
281    pub x: i64,
282    pub y: i64,
283    pub width: i64,
284    pub height: i64,
285}
286
287#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
288#[serde(rename_all = "camelCase")]
289pub struct TextReceipt {
290    pub story_id: String,
291    pub start: u32,
292    pub end: u32,
293    pub text: String,
294}
295
296#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
297#[serde(rename_all = "camelCase")]
298pub struct ShapeDraft {
299    pub name: String,
300    pub rect: ShapeRect,
301    pub text: String,
302    pub style: TextStyle,
303}
304
305#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
306#[serde(rename_all = "camelCase")]
307pub struct PresetShapeDraft {
308    pub name: String,
309    pub geometry: String,
310    pub rect: ShapeRect,
311    pub fill: Option<String>,
312}
313
314#[derive(Clone, Debug, PartialEq)]
315pub struct PictureDraft {
316    pub name: String,
317    pub rect: ShapeRect,
318    pub content_type: String,
319    pub media_bytes: Vec<u8>,
320}
321
322#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
323#[serde(rename_all = "camelCase")]
324pub struct ShapeStroke {
325    pub color: Option<String>,
326    pub width_pt: Option<f64>,
327}
328
329#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
330#[serde(rename_all = "camelCase")]
331pub struct ShapeFillReceipt {
332    pub slide_id: String,
333    pub shape_id: String,
334    pub before: Option<String>,
335    pub after: Option<String>,
336}
337
338#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
339#[serde(rename_all = "camelCase")]
340pub struct ShapeStrokeReceipt {
341    pub slide_id: String,
342    pub shape_id: String,
343    pub before: Option<ShapeStroke>,
344    pub after: Option<ShapeStroke>,
345}
346
347#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
348#[serde(rename_all = "camelCase")]
349pub struct ShapeAdjustReceipt {
350    pub slide_id: String,
351    pub shape_id: String,
352    pub before: BTreeMap<String, f64>,
353    pub after: BTreeMap<String, f64>,
354}
355
356#[derive(Clone, Copy, Debug, Eq, PartialEq)]
357pub enum UpdateOrigin {
358    Local,
359    Remote,
360}
361
362#[derive(Clone, Debug, Eq, PartialEq)]
363pub struct UpdateEvent {
364    pub update: Vec<u8>,
365    pub origin: UpdateOrigin,
366}
367
368#[derive(Debug, Error)]
369pub enum EditError {
370    #[error("invalid client ID {0}")]
371    InvalidClientId(u64),
372    #[error("could not parse PPTX: {0}")]
373    Parse(String),
374    #[error("invalid deck state: {0}")]
375    InvalidState(String),
376    #[error("invalid yrs update: {0}")]
377    InvalidUpdate(String),
378    #[error("invalid yrs state vector: {0}")]
379    InvalidStateVector(String),
380    #[error("slide {0:?} was not found")]
381    SlideNotFound(String),
382    #[error("shape {0:?} was not found")]
383    ShapeNotFound(String),
384    #[error("story {0:?} was not found")]
385    StoryNotFound(String),
386    #[error("comment {0:?} was not found")]
387    CommentNotFound(String),
388    #[error("invalid comment: {0}")]
389    InvalidComment(String),
390    #[error("index {index} is outside length {length}")]
391    OutOfBounds { index: u32, length: u32 },
392    #[error("text range {start}..{end} crosses a paragraph boundary")]
393    ParagraphBoundary { start: u32, end: u32 },
394    #[error("invalid shape geometry: {0}")]
395    InvalidGeometry(String),
396    #[error("invalid shape adjustment: {0}")]
397    InvalidAdjustment(String),
398    #[error("invalid text: {0}")]
399    InvalidText(String),
400    #[error("update observer failed: {0}")]
401    Observer(String),
402    #[error("JSON boundary error: {0}")]
403    Json(String),
404    #[error("could not write PPTX: {0}")]
405    Write(String),
406}
407
408pub type EditResult<T> = Result<T, EditError>;
409
410/// Rejects characters XML 1.0 cannot carry, so a bad edit fails loudly
411/// instead of producing an unopenable file at save time.
412pub(crate) fn validate_xml_text(value: &str) -> EditResult<()> {
413    match value
414        .chars()
415        .find(|character| !legal_xml_character(*character))
416    {
417        Some(character) => Err(EditError::InvalidText(format!(
418            "character U+{:04X} cannot be stored in a PPTX file",
419            character as u32
420        ))),
421        None => Ok(()),
422    }
423}
424
425fn legal_xml_character(character: char) -> bool {
426    matches!(character, '\u{9}' | '\u{a}' | '\u{d}')
427        || ('\u{20}'..='\u{d7ff}').contains(&character)
428        || ('\u{e000}'..='\u{fffd}').contains(&character)
429        || ('\u{10000}'..='\u{10ffff}').contains(&character)
430}