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 #[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
352pub(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}