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, 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    /// `a:rPr@kern`: the smallest size, in points, the run is kerned at.
50    #[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    /// The geometry the shape draws at, present only while it has none of
137    /// its own.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub inherited: Option<InheritedGeometry>,
140    /// Hides this shape and its descendants.
141    #[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    /// Image data added to this session, retained across saves.
156    #[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/// The transform a placeholder takes from its layout or master.
168#[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/// Image data shared by editing peers.
181#[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    /// An edit changed the background from what the source part declares, so
226    /// `background` stands for the slide whatever `p:bg` that part holds.
227    #[serde(default, skip_serializing_if = "is_false")]
228    pub background_edited: bool,
229    pub shapes: Vec<ShapeSnapshot>,
230}
231
232/// One slide's snapshot plus deck geometry — the slide-scoped render input.
233#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
234#[serde(rename_all = "camelCase")]
235pub struct SlideScope {
236    /// The slide's position in deck order.
237    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
464/// Rejects characters XML 1.0 cannot carry, so a bad edit fails loudly
465/// instead of producing an unopenable file at save time.
466pub(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}