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 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub inherited: Option<InheritedGeometry>,
127 #[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 #[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#[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#[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197pub struct SlideScope {
198 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
410pub(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}