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 SlideLayoutSnapshot {
204 pub name: Option<String>,
205 pub r#type: Option<String>,
206}
207
208#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
209#[serde(rename_all = "camelCase")]
210pub struct SlideSnapshot {
211 #[serde(default)]
212 pub title: Option<String>,
213 #[serde(default)]
214 pub hidden: Option<bool>,
215 #[serde(default)]
216 pub layout: Option<SlideLayoutSnapshot>,
217 pub id: String,
218 pub source_part_path: Option<String>,
219 pub layout_part_path: Option<String>,
220 pub name: Option<String>,
221 #[serde(default, skip_serializing_if = "String::is_empty")]
222 pub notes: String,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub background: Option<ShapeFill>,
225 #[serde(default, skip_serializing_if = "is_false")]
228 pub background_edited: bool,
229 pub shapes: Vec<ShapeSnapshot>,
230}
231
232#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
234#[serde(rename_all = "camelCase")]
235pub struct SlideScope {
236 pub index: usize,
238 pub slide: SlideSnapshot,
239 pub width_emu: i64,
240 pub height_emu: i64,
241}
242
243#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
244#[serde(rename_all = "camelCase")]
245pub struct DeckSnapshot {
246 pub width_emu: i64,
247 pub height_emu: i64,
248 pub slides: Vec<SlideSnapshot>,
249 #[serde(default, skip_serializing_if = "legacy_comment_flavor")]
250 pub comment_flavor: CommentFlavor,
251 #[serde(default, skip_serializing_if = "Vec::is_empty")]
252 pub comments: Vec<CommentSnapshot>,
253}
254
255fn legacy_comment_flavor(flavor: &CommentFlavor) -> bool {
256 *flavor == CommentFlavor::Legacy
257}
258
259#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
260#[serde(rename_all = "camelCase")]
261pub struct CommentSnapshot {
262 pub id: String,
263 pub slide_id: String,
264 pub author: String,
265 pub initials: String,
266 pub text: String,
267 pub created: Option<String>,
268 pub x_emu: i64,
269 pub y_emu: i64,
270 pub parent_id: Option<String>,
271 pub resolved: bool,
272}
273
274#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
275#[serde(rename_all = "camelCase")]
276pub struct CommentReceipt {
277 pub comment_id: String,
278 pub slide_id: String,
279 pub parent_id: Option<String>,
280 pub resolved: bool,
281}
282
283#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
284#[serde(rename_all = "camelCase")]
285pub struct SlideReceipt {
286 pub slide_id: String,
287 pub from_index: Option<u32>,
288 pub to_index: Option<u32>,
289}
290
291#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
292#[serde(rename_all = "camelCase")]
293pub struct ShapeReceipt {
294 pub slide_id: String,
295 pub shape_id: String,
296 pub index: u32,
297}
298
299#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
300#[serde(rename_all = "camelCase")]
301pub struct ShapeZOrderReceipt {
302 pub slide_id: String,
303 pub shape_id: String,
304 pub from_index: u32,
305 pub to_index: u32,
306}
307
308#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
309#[serde(rename_all = "camelCase")]
310pub struct TransformReceipt {
311 pub slide_id: String,
312 pub shape_id: String,
313 pub before: ShapeRect,
314 pub after: ShapeRect,
315}
316
317#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
318pub struct ShapeRect {
319 pub x: i64,
320 pub y: i64,
321 pub width: i64,
322 pub height: i64,
323}
324
325#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
326#[serde(rename_all = "camelCase")]
327pub struct TextReceipt {
328 pub story_id: String,
329 pub start: u32,
330 pub end: u32,
331 pub text: String,
332}
333
334#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
335#[serde(rename_all = "camelCase")]
336pub struct ShapeDraft {
337 pub name: String,
338 pub rect: ShapeRect,
339 pub text: String,
340 pub style: TextStyle,
341}
342
343#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
344#[serde(rename_all = "camelCase")]
345pub struct PresetShapeDraft {
346 pub name: String,
347 pub geometry: String,
348 pub rect: ShapeRect,
349 pub fill: Option<String>,
350}
351
352#[derive(Clone, Debug, PartialEq)]
353pub struct PictureDraft {
354 pub name: String,
355 pub rect: ShapeRect,
356 pub content_type: String,
357 pub media_bytes: Vec<u8>,
358}
359
360#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
361#[serde(rename_all = "camelCase")]
362pub struct ShapeStroke {
363 pub color: Option<String>,
364 pub width_pt: Option<f64>,
365}
366
367#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
368#[serde(rename_all = "camelCase")]
369pub struct ShapeFillReceipt {
370 pub slide_id: String,
371 pub shape_id: String,
372 pub before: Option<String>,
373 pub after: Option<String>,
374}
375
376#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
377#[serde(rename_all = "camelCase")]
378pub struct SlideBackgroundReceipt {
379 pub slide_id: String,
380 pub before: Option<ShapeFill>,
381 pub after: Option<ShapeFill>,
382}
383
384#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
385#[serde(rename_all = "camelCase")]
386pub struct ShapeStrokeReceipt {
387 pub slide_id: String,
388 pub shape_id: String,
389 pub before: Option<ShapeStroke>,
390 pub after: Option<ShapeStroke>,
391}
392
393#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
394#[serde(rename_all = "camelCase")]
395pub struct ShapeAdjustReceipt {
396 pub slide_id: String,
397 pub shape_id: String,
398 pub before: BTreeMap<String, f64>,
399 pub after: BTreeMap<String, f64>,
400}
401
402#[derive(Clone, Copy, Debug, Eq, PartialEq)]
403pub enum UpdateOrigin {
404 Local,
405 Remote,
406}
407
408#[derive(Clone, Debug, Eq, PartialEq)]
409pub struct UpdateEvent {
410 pub update: Vec<u8>,
411 pub origin: UpdateOrigin,
412}
413
414#[derive(Debug, Error)]
415pub enum EditError {
416 #[error("invalid client ID {0}")]
417 InvalidClientId(u64),
418 #[error("could not parse PPTX: {0}")]
419 Parse(String),
420 #[error("invalid deck state: {0}")]
421 InvalidState(String),
422 #[error("invalid yrs update: {0}")]
423 InvalidUpdate(String),
424 #[error("invalid yrs state vector: {0}")]
425 InvalidStateVector(String),
426 #[error("slide {0:?} was not found")]
427 SlideNotFound(String),
428 #[error("shape {0:?} was not found")]
429 ShapeNotFound(String),
430 #[error("story {0:?} was not found")]
431 StoryNotFound(String),
432 #[error("comment {0:?} was not found")]
433 CommentNotFound(String),
434 #[error("invalid comment: {0}")]
435 InvalidComment(String),
436 #[error("index {index} is outside length {length}")]
437 OutOfBounds { index: u32, length: u32 },
438 #[error("text range {start}..{end} crosses a paragraph boundary")]
439 ParagraphBoundary { start: u32, end: u32 },
440 #[error("invalid shape geometry: {0}")]
441 InvalidGeometry(String),
442 #[error("invalid fill: {0}")]
443 InvalidFill(String),
444 #[error("invalid shape adjustment: {0}")]
445 InvalidAdjustment(String),
446 #[error("invalid table: {0}")]
447 InvalidTable(String),
448 #[error(
449 "structural table operations are unavailable in collaborative mode; edit cell text instead, or change the table structure outside the shared session"
450 )]
451 CollaborativeStructureOperation,
452 #[error("invalid text: {0}")]
453 InvalidText(String),
454 #[error("update observer failed: {0}")]
455 Observer(String),
456 #[error("JSON boundary error: {0}")]
457 Json(String),
458 #[error("could not write PPTX: {0}")]
459 Write(String),
460}
461
462pub type EditResult<T> = Result<T, EditError>;
463
464pub(crate) fn validate_xml_text(value: &str) -> EditResult<()> {
467 match value
468 .chars()
469 .find(|character| !legal_xml_character(*character))
470 {
471 Some(character) => Err(EditError::InvalidText(format!(
472 "character U+{:04X} cannot be stored in a PPTX file",
473 character as u32
474 ))),
475 None => Ok(()),
476 }
477}
478
479fn legal_xml_character(character: char) -> bool {
480 matches!(character, '\u{9}' | '\u{a}' | '\u{d}')
481 || ('\u{20}'..='\u{d7ff}').contains(&character)
482 || ('\u{e000}'..='\u{fffd}').contains(&character)
483 || ('\u{10000}'..='\u{10ffff}').contains(&character)
484}
485
486#[derive(Clone, Debug, Serialize, Deserialize)]
487#[serde(rename_all = "camelCase", deny_unknown_fields)]
488pub struct TextParagraphDraft {
489 pub alignment: Option<String>,
490 #[serde(default, skip_serializing_if = "Option::is_none")]
491 pub bullet: Option<bool>,
492 pub runs: Vec<TextRunDraft>,
493}
494
495#[derive(Clone, Debug, Serialize, Deserialize)]
496#[serde(rename_all = "camelCase", deny_unknown_fields)]
497pub struct TextRunDraft {
498 pub text: String,
499 #[serde(default)]
500 pub style: TextStyle,
501}