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 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    /// An edit changed the background from what the source part declares, so
213    /// `background` stands for the slide whatever `p:bg` that part holds.
214    #[serde(default, skip_serializing_if = "is_false")]
215    pub background_edited: bool,
216    pub shapes: Vec<ShapeSnapshot>,
217}
218
219/// One slide's snapshot plus deck geometry — the slide-scoped render input.
220#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
221#[serde(rename_all = "camelCase")]
222pub struct SlideScope {
223    /// The slide's position in deck order.
224    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
451/// Rejects characters XML 1.0 cannot carry, so a bad edit fails loudly
452/// instead of producing an unopenable file at save time.
453pub(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}