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")]
51 pub kern_pt: Option<f64>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub caps: Option<TextCaps>,
54}
55
56#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
57#[serde(rename_all = "camelCase")]
58pub struct TextStylePatch {
59 pub bold: Option<bool>,
60 pub italic: Option<bool>,
61 pub font_size_pt: Option<f64>,
62 pub color: Option<String>,
63 pub font_family: Option<String>,
64 pub underline: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub spacing_pt: Option<f64>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub baseline_pct: Option<f64>,
69}
70
71#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct TextRunSnapshot {
74 pub text: String,
75 pub style: TextStyle,
76}
77
78#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
79#[serde(rename_all = "camelCase")]
80pub struct ParagraphSnapshot {
81 pub id: String,
82 #[serde(default, skip_serializing_if = "Vec::is_empty")]
83 pub source_paragraphs: Vec<ParagraphSource>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub preserved_xml: Option<String>,
86 pub alignment: Option<String>,
87 pub level: u32,
88 pub bullet_json: Option<String>,
89 pub runs: Vec<TextRunSnapshot>,
90}
91
92#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
93pub struct ParagraphSource {
94 pub id: String,
95 pub xml: Option<String>,
96}
97
98#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
99#[serde(rename_all = "camelCase")]
100pub struct StorySnapshot {
101 pub id: String,
102 pub length: u32,
103 pub paragraphs: Vec<ParagraphSnapshot>,
104}
105
106impl StorySnapshot {
107 pub fn plain_text(&self) -> String {
108 self.paragraphs
109 .iter()
110 .map(|paragraph| {
111 paragraph
112 .runs
113 .iter()
114 .map(|run| run.text.as_str())
115 .collect::<String>()
116 })
117 .collect::<Vec<_>>()
118 .join("\n")
119 }
120}
121
122#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
123#[serde(rename_all = "camelCase")]
124pub struct ShapeSnapshot {
125 pub id: String,
126 pub source_id: u32,
127 pub kind: ShapeKind,
128 pub name: String,
129 pub x: i64,
130 pub y: i64,
131 pub width: i64,
132 pub height: i64,
133 pub rotation_deg: f64,
134 pub flip_h: bool,
135 pub flip_v: bool,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub inherited: Option<InheritedGeometry>,
140 #[serde(default, skip_serializing_if = "is_false")]
142 pub hidden: bool,
143 #[serde(default, skip_serializing_if = "is_false")]
144 pub text_box: bool,
145 #[serde(default, skip_serializing_if = "is_false")]
146 pub aspect_ratio_locked: bool,
147 pub geometry: String,
148 pub adjust_values: BTreeMap<String, f64>,
149 pub placeholder: Option<Placeholder>,
150 pub fill: Option<ShapeFill>,
151 pub resolved_fill_color: Option<String>,
152 pub outline: Option<ShapeOutline>,
153 pub resolved_outline_color: Option<String>,
154 pub media_part_path: Option<String>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub pending_media: Option<PendingMedia>,
158 #[serde(default, skip_serializing_if = "Vec::is_empty")]
159 pub blip_effects: Vec<BlipEffect>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub table_structure: Option<pptx_parse::TableStructure>,
162 pub graphic: Option<GraphicFrameData>,
163 pub text_stories: Vec<StorySnapshot>,
164 pub children: Vec<ShapeSnapshot>,
165}
166
167#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
169#[serde(rename_all = "camelCase")]
170pub struct InheritedGeometry {
171 pub x: i64,
172 pub y: i64,
173 pub width: i64,
174 pub height: i64,
175 pub rotation_deg: f64,
176 pub flip_h: bool,
177 pub flip_v: bool,
178}
179
180#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
182#[serde(rename_all = "camelCase")]
183pub struct PendingMedia {
184 pub content_type: String,
185 pub base64: String,
186}
187
188fn is_false(value: &bool) -> bool {
189 !value
190}
191
192#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
193#[serde(rename_all = "camelCase")]
194pub enum ShapeKind {
195 Shape,
196 Picture,
197 GraphicFrame,
198 Group,
199}
200
201#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
202#[serde(rename_all = "camelCase")]
203pub struct SlideSnapshot {
204 pub id: String,
205 pub source_part_path: Option<String>,
206 pub layout_part_path: Option<String>,
207 pub name: Option<String>,
208 #[serde(default, skip_serializing_if = "String::is_empty")]
209 pub notes: String,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub background: Option<ShapeFill>,
212 #[serde(default, skip_serializing_if = "is_false")]
215 pub background_edited: bool,
216 pub shapes: Vec<ShapeSnapshot>,
217}
218
219#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
221#[serde(rename_all = "camelCase")]
222pub struct SlideScope {
223 pub index: usize,
225 pub slide: SlideSnapshot,
226 pub width_emu: i64,
227 pub height_emu: i64,
228}
229
230#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
231#[serde(rename_all = "camelCase")]
232pub struct DeckSnapshot {
233 pub width_emu: i64,
234 pub height_emu: i64,
235 pub slides: Vec<SlideSnapshot>,
236 #[serde(default, skip_serializing_if = "legacy_comment_flavor")]
237 pub comment_flavor: CommentFlavor,
238 #[serde(default, skip_serializing_if = "Vec::is_empty")]
239 pub comments: Vec<CommentSnapshot>,
240}
241
242fn legacy_comment_flavor(flavor: &CommentFlavor) -> bool {
243 *flavor == CommentFlavor::Legacy
244}
245
246#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
247#[serde(rename_all = "camelCase")]
248pub struct CommentSnapshot {
249 pub id: String,
250 pub slide_id: String,
251 pub author: String,
252 pub initials: String,
253 pub text: String,
254 pub created: Option<String>,
255 pub x_emu: i64,
256 pub y_emu: i64,
257 pub parent_id: Option<String>,
258 pub resolved: bool,
259}
260
261#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
262#[serde(rename_all = "camelCase")]
263pub struct CommentReceipt {
264 pub comment_id: String,
265 pub slide_id: String,
266 pub parent_id: Option<String>,
267 pub resolved: bool,
268}
269
270#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
271#[serde(rename_all = "camelCase")]
272pub struct SlideReceipt {
273 pub slide_id: String,
274 pub from_index: Option<u32>,
275 pub to_index: Option<u32>,
276}
277
278#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
279#[serde(rename_all = "camelCase")]
280pub struct ShapeReceipt {
281 pub slide_id: String,
282 pub shape_id: String,
283 pub index: u32,
284}
285
286#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
287#[serde(rename_all = "camelCase")]
288pub struct ShapeZOrderReceipt {
289 pub slide_id: String,
290 pub shape_id: String,
291 pub from_index: u32,
292 pub to_index: u32,
293}
294
295#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
296#[serde(rename_all = "camelCase")]
297pub struct TransformReceipt {
298 pub slide_id: String,
299 pub shape_id: String,
300 pub before: ShapeRect,
301 pub after: ShapeRect,
302}
303
304#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
305pub struct ShapeRect {
306 pub x: i64,
307 pub y: i64,
308 pub width: i64,
309 pub height: i64,
310}
311
312#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
313#[serde(rename_all = "camelCase")]
314pub struct TextReceipt {
315 pub story_id: String,
316 pub start: u32,
317 pub end: u32,
318 pub text: String,
319}
320
321#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
322#[serde(rename_all = "camelCase")]
323pub struct ShapeDraft {
324 pub name: String,
325 pub rect: ShapeRect,
326 pub text: String,
327 pub style: TextStyle,
328}
329
330#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
331#[serde(rename_all = "camelCase")]
332pub struct PresetShapeDraft {
333 pub name: String,
334 pub geometry: String,
335 pub rect: ShapeRect,
336 pub fill: Option<String>,
337}
338
339#[derive(Clone, Debug, PartialEq)]
340pub struct PictureDraft {
341 pub name: String,
342 pub rect: ShapeRect,
343 pub content_type: String,
344 pub media_bytes: Vec<u8>,
345}
346
347#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
348#[serde(rename_all = "camelCase")]
349pub struct ShapeStroke {
350 pub color: Option<String>,
351 pub width_pt: Option<f64>,
352}
353
354#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
355#[serde(rename_all = "camelCase")]
356pub struct ShapeFillReceipt {
357 pub slide_id: String,
358 pub shape_id: String,
359 pub before: Option<String>,
360 pub after: Option<String>,
361}
362
363#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
364#[serde(rename_all = "camelCase")]
365pub struct SlideBackgroundReceipt {
366 pub slide_id: String,
367 pub before: Option<ShapeFill>,
368 pub after: Option<ShapeFill>,
369}
370
371#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
372#[serde(rename_all = "camelCase")]
373pub struct ShapeStrokeReceipt {
374 pub slide_id: String,
375 pub shape_id: String,
376 pub before: Option<ShapeStroke>,
377 pub after: Option<ShapeStroke>,
378}
379
380#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
381#[serde(rename_all = "camelCase")]
382pub struct ShapeAdjustReceipt {
383 pub slide_id: String,
384 pub shape_id: String,
385 pub before: BTreeMap<String, f64>,
386 pub after: BTreeMap<String, f64>,
387}
388
389#[derive(Clone, Copy, Debug, Eq, PartialEq)]
390pub enum UpdateOrigin {
391 Local,
392 Remote,
393}
394
395#[derive(Clone, Debug, Eq, PartialEq)]
396pub struct UpdateEvent {
397 pub update: Vec<u8>,
398 pub origin: UpdateOrigin,
399}
400
401#[derive(Debug, Error)]
402pub enum EditError {
403 #[error("invalid client ID {0}")]
404 InvalidClientId(u64),
405 #[error("could not parse PPTX: {0}")]
406 Parse(String),
407 #[error("invalid deck state: {0}")]
408 InvalidState(String),
409 #[error("invalid yrs update: {0}")]
410 InvalidUpdate(String),
411 #[error("invalid yrs state vector: {0}")]
412 InvalidStateVector(String),
413 #[error("slide {0:?} was not found")]
414 SlideNotFound(String),
415 #[error("shape {0:?} was not found")]
416 ShapeNotFound(String),
417 #[error("story {0:?} was not found")]
418 StoryNotFound(String),
419 #[error("comment {0:?} was not found")]
420 CommentNotFound(String),
421 #[error("invalid comment: {0}")]
422 InvalidComment(String),
423 #[error("index {index} is outside length {length}")]
424 OutOfBounds { index: u32, length: u32 },
425 #[error("text range {start}..{end} crosses a paragraph boundary")]
426 ParagraphBoundary { start: u32, end: u32 },
427 #[error("invalid shape geometry: {0}")]
428 InvalidGeometry(String),
429 #[error("invalid fill: {0}")]
430 InvalidFill(String),
431 #[error("invalid shape adjustment: {0}")]
432 InvalidAdjustment(String),
433 #[error("invalid table: {0}")]
434 InvalidTable(String),
435 #[error(
436 "structural table operations are unavailable in collaborative mode; edit cell text instead, or change the table structure outside the shared session"
437 )]
438 CollaborativeStructureOperation,
439 #[error("invalid text: {0}")]
440 InvalidText(String),
441 #[error("update observer failed: {0}")]
442 Observer(String),
443 #[error("JSON boundary error: {0}")]
444 Json(String),
445 #[error("could not write PPTX: {0}")]
446 Write(String),
447}
448
449pub type EditResult<T> = Result<T, EditError>;
450
451pub(crate) fn validate_xml_text(value: &str) -> EditResult<()> {
454 match value
455 .chars()
456 .find(|character| !legal_xml_character(*character))
457 {
458 Some(character) => Err(EditError::InvalidText(format!(
459 "character U+{:04X} cannot be stored in a PPTX file",
460 character as u32
461 ))),
462 None => Ok(()),
463 }
464}
465
466fn legal_xml_character(character: char) -> bool {
467 matches!(character, '\u{9}' | '\u{a}' | '\u{d}')
468 || ('\u{20}'..='\u{d7ff}').contains(&character)
469 || ('\u{e000}'..='\u{fffd}').contains(&character)
470 || ('\u{10000}'..='\u{10ffff}').contains(&character)
471}
472
473#[derive(Clone, Debug, Serialize, Deserialize)]
474#[serde(rename_all = "camelCase", deny_unknown_fields)]
475pub struct TextParagraphDraft {
476 pub alignment: Option<String>,
477 #[serde(default, skip_serializing_if = "Option::is_none")]
478 pub bullet: Option<bool>,
479 pub runs: Vec<TextRunDraft>,
480}
481
482#[derive(Clone, Debug, Serialize, Deserialize)]
483#[serde(rename_all = "camelCase", deny_unknown_fields)]
484pub struct TextRunDraft {
485 pub text: String,
486 #[serde(default)]
487 pub style: TextStyle,
488}