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;
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}
50
51#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
52#[serde(rename_all = "camelCase")]
53pub struct TextStylePatch {
54    pub bold: Option<bool>,
55    pub italic: Option<bool>,
56    pub font_size_pt: Option<f64>,
57    pub color: Option<String>,
58    pub font_family: Option<String>,
59    pub underline: Option<String>,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub spacing_pt: Option<f64>,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub baseline_pct: Option<f64>,
64}
65
66#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase")]
68pub struct TextRunSnapshot {
69    pub text: String,
70    pub style: TextStyle,
71}
72
73#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
74#[serde(rename_all = "camelCase")]
75pub struct ParagraphSnapshot {
76    pub id: String,
77    pub alignment: Option<String>,
78    pub level: u32,
79    pub bullet_json: Option<String>,
80    pub runs: Vec<TextRunSnapshot>,
81}
82
83#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct StorySnapshot {
86    pub id: String,
87    pub length: u32,
88    pub paragraphs: Vec<ParagraphSnapshot>,
89}
90
91impl StorySnapshot {
92    pub fn plain_text(&self) -> String {
93        self.paragraphs
94            .iter()
95            .map(|paragraph| {
96                paragraph
97                    .runs
98                    .iter()
99                    .map(|run| run.text.as_str())
100                    .collect::<String>()
101            })
102            .collect::<Vec<_>>()
103            .join("\n")
104    }
105}
106
107#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
108#[serde(rename_all = "camelCase")]
109pub struct ShapeSnapshot {
110    pub id: String,
111    pub source_id: u32,
112    pub kind: ShapeKind,
113    pub name: String,
114    pub x: i64,
115    pub y: i64,
116    pub width: i64,
117    pub height: i64,
118    pub rotation_deg: f64,
119    pub flip_h: bool,
120    pub flip_v: bool,
121    /// Hides this shape and its descendants.
122    #[serde(default, skip_serializing_if = "is_false")]
123    pub hidden: bool,
124    pub geometry: String,
125    pub adjust_values: BTreeMap<String, f64>,
126    pub placeholder: Option<Placeholder>,
127    pub fill: Option<ShapeFill>,
128    pub resolved_fill_color: Option<String>,
129    pub outline: Option<ShapeOutline>,
130    pub resolved_outline_color: Option<String>,
131    pub media_part_path: Option<String>,
132    #[serde(default, skip_serializing_if = "Vec::is_empty")]
133    pub blip_effects: Vec<BlipEffect>,
134    pub graphic: Option<GraphicFrameData>,
135    pub text_stories: Vec<StorySnapshot>,
136    pub children: Vec<ShapeSnapshot>,
137}
138
139fn is_false(value: &bool) -> bool {
140    !value
141}
142
143#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
144#[serde(rename_all = "camelCase")]
145pub enum ShapeKind {
146    Shape,
147    Picture,
148    GraphicFrame,
149    Group,
150}
151
152#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
153#[serde(rename_all = "camelCase")]
154pub struct SlideSnapshot {
155    pub id: String,
156    pub source_part_path: Option<String>,
157    pub layout_part_path: Option<String>,
158    pub name: Option<String>,
159    #[serde(default, skip_serializing_if = "String::is_empty")]
160    pub notes: String,
161    pub shapes: Vec<ShapeSnapshot>,
162}
163
164#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
165#[serde(rename_all = "camelCase")]
166pub struct DeckSnapshot {
167    pub width_emu: i64,
168    pub height_emu: i64,
169    pub slides: Vec<SlideSnapshot>,
170    #[serde(default, skip_serializing_if = "legacy_comment_flavor")]
171    pub comment_flavor: CommentFlavor,
172    #[serde(default, skip_serializing_if = "Vec::is_empty")]
173    pub comments: Vec<CommentSnapshot>,
174}
175
176fn legacy_comment_flavor(flavor: &CommentFlavor) -> bool {
177    *flavor == CommentFlavor::Legacy
178}
179
180#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct CommentSnapshot {
183    pub id: String,
184    pub slide_id: String,
185    pub author: String,
186    pub initials: String,
187    pub text: String,
188    pub created: Option<String>,
189    pub x_emu: i64,
190    pub y_emu: i64,
191    pub parent_id: Option<String>,
192    pub resolved: bool,
193}
194
195#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197pub struct CommentReceipt {
198    pub comment_id: String,
199    pub slide_id: String,
200    pub parent_id: Option<String>,
201    pub resolved: bool,
202}
203
204#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
205#[serde(rename_all = "camelCase")]
206pub struct SlideReceipt {
207    pub slide_id: String,
208    pub from_index: Option<u32>,
209    pub to_index: Option<u32>,
210}
211
212#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
213#[serde(rename_all = "camelCase")]
214pub struct ShapeReceipt {
215    pub slide_id: String,
216    pub shape_id: String,
217    pub index: u32,
218}
219
220#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
221#[serde(rename_all = "camelCase")]
222pub struct TransformReceipt {
223    pub slide_id: String,
224    pub shape_id: String,
225    pub before: ShapeRect,
226    pub after: ShapeRect,
227}
228
229#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
230pub struct ShapeRect {
231    pub x: i64,
232    pub y: i64,
233    pub width: i64,
234    pub height: i64,
235}
236
237#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
238#[serde(rename_all = "camelCase")]
239pub struct TextReceipt {
240    pub story_id: String,
241    pub start: u32,
242    pub end: u32,
243    pub text: String,
244}
245
246#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
247#[serde(rename_all = "camelCase")]
248pub struct ShapeDraft {
249    pub name: String,
250    pub rect: ShapeRect,
251    pub text: String,
252    pub style: TextStyle,
253}
254
255#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
256#[serde(rename_all = "camelCase")]
257pub struct PresetShapeDraft {
258    pub name: String,
259    pub geometry: String,
260    pub rect: ShapeRect,
261    pub fill: Option<String>,
262}
263
264#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
265#[serde(rename_all = "camelCase")]
266pub struct ShapeStroke {
267    pub color: Option<String>,
268    pub width_pt: Option<f64>,
269}
270
271#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
272#[serde(rename_all = "camelCase")]
273pub struct ShapeFillReceipt {
274    pub slide_id: String,
275    pub shape_id: String,
276    pub before: Option<String>,
277    pub after: Option<String>,
278}
279
280#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
281#[serde(rename_all = "camelCase")]
282pub struct ShapeStrokeReceipt {
283    pub slide_id: String,
284    pub shape_id: String,
285    pub before: Option<ShapeStroke>,
286    pub after: Option<ShapeStroke>,
287}
288
289#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
290#[serde(rename_all = "camelCase")]
291pub struct ShapeAdjustReceipt {
292    pub slide_id: String,
293    pub shape_id: String,
294    pub before: BTreeMap<String, f64>,
295    pub after: BTreeMap<String, f64>,
296}
297
298#[derive(Clone, Copy, Debug, Eq, PartialEq)]
299pub enum UpdateOrigin {
300    Local,
301    Remote,
302}
303
304#[derive(Clone, Debug, Eq, PartialEq)]
305pub struct UpdateEvent {
306    pub update: Vec<u8>,
307    pub origin: UpdateOrigin,
308}
309
310#[derive(Debug, Error)]
311pub enum EditError {
312    #[error("invalid client ID {0}")]
313    InvalidClientId(u64),
314    #[error("could not parse PPTX: {0}")]
315    Parse(String),
316    #[error("invalid deck state: {0}")]
317    InvalidState(String),
318    #[error("invalid yrs update: {0}")]
319    InvalidUpdate(String),
320    #[error("invalid yrs state vector: {0}")]
321    InvalidStateVector(String),
322    #[error("slide {0:?} was not found")]
323    SlideNotFound(String),
324    #[error("shape {0:?} was not found")]
325    ShapeNotFound(String),
326    #[error("story {0:?} was not found")]
327    StoryNotFound(String),
328    #[error("comment {0:?} was not found")]
329    CommentNotFound(String),
330    #[error("invalid comment: {0}")]
331    InvalidComment(String),
332    #[error("index {index} is outside length {length}")]
333    OutOfBounds { index: u32, length: u32 },
334    #[error("text range {start}..{end} crosses a paragraph boundary")]
335    ParagraphBoundary { start: u32, end: u32 },
336    #[error("invalid shape geometry: {0}")]
337    InvalidGeometry(String),
338    #[error("invalid shape adjustment: {0}")]
339    InvalidAdjustment(String),
340    #[error("invalid text: {0}")]
341    InvalidText(String),
342    #[error("update observer failed: {0}")]
343    Observer(String),
344    #[error("JSON boundary error: {0}")]
345    Json(String),
346    #[error("could not write PPTX: {0}")]
347    Write(String),
348}
349
350pub type EditResult<T> = Result<T, EditError>;
351
352/// Rejects characters XML 1.0 cannot carry, so a bad edit fails loudly
353/// instead of producing an unopenable file at save time.
354pub(crate) fn validate_xml_text(value: &str) -> EditResult<()> {
355    match value
356        .chars()
357        .find(|character| !legal_xml_character(*character))
358    {
359        Some(character) => Err(EditError::InvalidText(format!(
360            "character U+{:04X} cannot be stored in a PPTX file",
361            character as u32
362        ))),
363        None => Ok(()),
364    }
365}
366
367fn legal_xml_character(character: char) -> bool {
368    matches!(character, '\u{9}' | '\u{a}' | '\u{d}')
369        || ('\u{20}'..='\u{d7ff}').contains(&character)
370        || ('\u{e000}'..='\u{fffd}').contains(&character)
371        || ('\u{10000}'..='\u{10ffff}').contains(&character)
372}