Skip to main content

pptx_edit/
lib.rs

1//! Collaborative yrs-backed PPTX deck model.
2
3use std::cell::RefCell;
4use std::panic::{AssertUnwindSafe, catch_unwind};
5use std::sync::Arc;
6use std::sync::atomic::{AtomicU64, Ordering};
7
8use pptx_parse::PptxPackage;
9use sha2::{Digest, Sha256};
10use yrs::updates::decoder::{Decode, Decoder, DecoderV1};
11use yrs::updates::encoder::Encode;
12use yrs::{
13    ClientID, Doc, OffsetKind, Options, ReadTxn, StateVector, StickyIndex, Subscription, Transact,
14    Update, WriteTxn,
15};
16
17mod comments;
18mod deck;
19mod effects;
20mod model;
21mod outline_gradients;
22mod proposal_diff;
23mod proposals;
24mod save;
25mod source_run_properties;
26mod story;
27mod undo;
28
29pub use model::*;
30pub use proposal_diff::*;
31pub use proposals::*;
32pub use undo::DeckUndoManager;
33
34#[cfg(feature = "wasm")]
35pub mod wasm;
36
37pub(crate) const META: &str = "pptx:meta";
38pub(crate) const SLIDE_ORDER: &str = "pptx:slide-order";
39pub(crate) const SLIDES: &str = "pptx:slides";
40pub(crate) const SHAPES: &str = "pptx:shapes";
41pub(crate) const STORIES: &str = "pptx:stories";
42pub(crate) const COMMENTS: &str = "pptx:comments";
43pub(crate) const REMOTE_ORIGIN: &str = "pptx:remote";
44pub(crate) const HYDRATE_ORIGIN: &str = "pptx:hydrate";
45pub(crate) const MIGRATE_ORIGIN: &str = "pptx:migrate";
46pub(crate) const PILCROW_KIND: &str = "pilcrow";
47pub(crate) const KIND: &str = "_kind";
48pub(crate) const PARA_ID: &str = "paraId";
49pub(crate) const BOOTSTRAP_CLIENT_ID: u64 = (1_u64 << 53) - 1;
50pub const MAX_SAFE_CLIENT_ID: u64 = BOOTSTRAP_CLIENT_ID - 1;
51pub const MAX_UPDATE_BYTES: usize = 64 * 1024 * 1024;
52const MAX_STATE_VECTOR_ENTRIES: u32 = 65_536;
53
54pub type UpdateSubscription = Subscription;
55
56#[derive(Clone, Debug, Eq, PartialEq)]
57pub struct CaretAnchor {
58    story_id: String,
59    position: StickyIndex,
60}
61
62pub struct DeckSession {
63    pub(crate) doc: Doc,
64    client_id: u64,
65    id_counter: AtomicU64,
66    package: Arc<PptxPackage>,
67    undo: RefCell<DeckUndoManager>,
68    proposals: RefCell<proposals::ProposalStore>,
69}
70
71impl DeckSession {
72    pub fn open(bytes: &[u8], client_id: u64) -> EditResult<Self> {
73        let package =
74            pptx_parse::parse_pptx(bytes).map_err(|error| EditError::Parse(error.to_string()))?;
75        Self::from_package_with_source(package, bytes, client_id)
76    }
77
78    /// Opens an edit session from an already parsed package. The fingerprint
79    /// is taken from a re-zip of the package; prefer
80    /// [`Self::from_package_with_source`] when the file bytes are at hand, so
81    /// peers can match them against the update.
82    pub fn from_package(package: PptxPackage, client_id: u64) -> EditResult<Self> {
83        let bytes = pptx_parse::write_pptx(&package)
84            .map_err(|error| EditError::Parse(error.to_string()))?;
85        let fingerprint = format!("{:x}", Sha256::digest(bytes));
86        Self::from_package_with_fingerprint(package, fingerprint, client_id)
87    }
88
89    /// Opens an edit session from a parsed package, fingerprinting the file
90    /// bytes it was parsed from.
91    pub fn from_package_with_source(
92        package: PptxPackage,
93        source: &[u8],
94        client_id: u64,
95    ) -> EditResult<Self> {
96        let fingerprint = format!("{:x}", Sha256::digest(source));
97        Self::from_package_with_fingerprint(package, fingerprint, client_id)
98    }
99
100    fn from_package_with_fingerprint(
101        package: PptxPackage,
102        fingerprint: String,
103        client_id: u64,
104    ) -> EditResult<Self> {
105        validate_client_id(client_id)?;
106        let bootstrap = doc_with_client_id(BOOTSTRAP_CLIENT_ID);
107        deck::seed_doc(&bootstrap, &package, &fingerprint)?;
108        let baseline = bootstrap
109            .transact()
110            .encode_state_as_update_v1(&StateVector::default());
111        let doc = doc_with_client_id(client_id);
112        hydrate_doc(&doc, &baseline)?;
113        deck::validate_doc(&doc)?;
114        let undo = DeckUndoManager::new(&doc, client_id)?;
115        Ok(Self {
116            doc,
117            client_id,
118            id_counter: AtomicU64::new(0),
119            package: Arc::new(package),
120            undo: RefCell::new(undo),
121            proposals: Default::default(),
122        })
123    }
124
125    pub fn open_from_update(update: &[u8], client_id: u64) -> EditResult<Self> {
126        validate_client_id(client_id)?;
127        if update.len() > MAX_UPDATE_BYTES {
128            return Err(EditError::InvalidUpdate(format!(
129                "update exceeds {MAX_UPDATE_BYTES} bytes"
130            )));
131        }
132        let doc = doc_with_client_id(client_id);
133        hydrate_doc(&doc, update)?;
134        deck::migrate_doc(&doc)?;
135        deck::validate_doc(&doc)?;
136        let package = deck::package_from_doc(&doc)?;
137        let undo = DeckUndoManager::new(&doc, client_id)?;
138        Ok(Self {
139            doc,
140            client_id,
141            id_counter: AtomicU64::new(0),
142            package: Arc::new(package),
143            undo: RefCell::new(undo),
144            proposals: Default::default(),
145        })
146    }
147
148    /// Like [`Self::open_from_update`], but re-attaches the source file the
149    /// update was seeded from, so the session can save. The bytes must hash to
150    /// the fingerprint recorded in the update.
151    pub fn open_from_update_with_source(
152        update: &[u8],
153        source: &[u8],
154        client_id: u64,
155    ) -> EditResult<Self> {
156        let session = Self::open_from_update(update, client_id)?;
157        let recorded = deck::fingerprint_from_doc(&session.doc)?;
158        let actual = format!("{:x}", Sha256::digest(source));
159        if recorded != actual {
160            return Err(EditError::Parse(
161                "source bytes do not match the fingerprint recorded in the update".to_owned(),
162            ));
163        }
164        let package = if session.package.models_connectors() {
165            pptx_parse::parse_pptx(source)
166        } else {
167            pptx_parse::parse_pptx_without_connectors(source)
168        }
169        .map_err(|error| EditError::Parse(error.to_string()))?;
170        comments::import_source_comments(&session, &package)?;
171        deck::import_source_render_data(&session.doc, &package)?;
172        source_run_properties::import_source(
173            &session,
174            &package,
175            source_run_properties::SourceProperty::Baseline,
176        )?;
177        deck::import_source_ole_pictures(&session.doc, &package)?;
178        effects::import_source(&session.doc, &package)?;
179        source_run_properties::import_source(
180            &session,
181            &package,
182            source_run_properties::SourceProperty::Spacing,
183        )?;
184        story::import_source_numbering_restarts(&session.doc, &package)?;
185        outline_gradients::import_source(&session, &package)?;
186        Ok(Self {
187            package: Arc::new(package),
188            ..session
189        })
190    }
191
192    pub fn client_id(&self) -> u64 {
193        self.client_id
194    }
195
196    pub fn package(&self) -> &PptxPackage {
197        &self.package
198    }
199
200    pub fn yrs_doc(&self) -> &Doc {
201        &self.doc
202    }
203
204    pub fn encode_state_vector_v1(&self) -> Vec<u8> {
205        self.doc.transact().state_vector().encode_v1()
206    }
207
208    pub fn encode_state_as_update_v1(&self) -> Vec<u8> {
209        self.doc
210            .transact()
211            .encode_state_as_update_v1(&StateVector::default())
212    }
213
214    pub fn encode_diff_v1(&self, remote_state_vector: &[u8]) -> EditResult<Vec<u8>> {
215        let state_vector =
216            decode_state_vector_v1(remote_state_vector).map_err(EditError::InvalidStateVector)?;
217        Ok(self.doc.transact().encode_diff_v1(&state_vector))
218    }
219
220    pub fn apply_update_v1(&self, bytes: &[u8]) -> EditResult<DeckSnapshot> {
221        if bytes.len() > MAX_UPDATE_BYTES {
222            return Err(EditError::InvalidUpdate(format!(
223                "update exceeds {MAX_UPDATE_BYTES} bytes"
224            )));
225        }
226        let incoming = decode_update_v1(bytes).map_err(EditError::InvalidUpdate)?;
227        let staged = doc_with_client_id(self.client_id);
228        hydrate_doc(&staged, &self.encode_state_as_update_v1())?;
229        staged
230            .transact_mut_with(REMOTE_ORIGIN)
231            .apply_update(incoming)
232            .map_err(|error| EditError::InvalidUpdate(error.to_string()))?;
233        deck::validate_doc(&staged)?;
234
235        let incoming = decode_update_v1(bytes).map_err(EditError::InvalidUpdate)?;
236        self.doc
237            .transact_mut_with(REMOTE_ORIGIN)
238            .apply_update(incoming)
239            .map_err(|error| EditError::InvalidUpdate(error.to_string()))?;
240        self.snapshot()
241    }
242
243    pub fn observe_update_v1<F>(&self, callback: F) -> EditResult<Subscription>
244    where
245        F: Fn(UpdateEvent) + 'static,
246    {
247        self.doc
248            .observe_update_v1(move |txn, event| {
249                let origin = if txn
250                    .origin()
251                    .is_some_and(|origin| origin.as_ref() == REMOTE_ORIGIN.as_bytes())
252                {
253                    UpdateOrigin::Remote
254                } else {
255                    UpdateOrigin::Local
256                };
257                let update = UpdateEvent {
258                    update: event.update.clone(),
259                    origin,
260                };
261                let _ = catch_unwind(AssertUnwindSafe(|| callback(update)));
262            })
263            .map_err(|error| EditError::Observer(error.to_string()))
264    }
265
266    pub fn undo(&self) -> bool {
267        self.undo.borrow_mut().undo()
268    }
269
270    pub fn redo(&self) -> bool {
271        self.undo.borrow_mut().redo()
272    }
273
274    pub fn can_undo(&self) -> bool {
275        self.undo.borrow().can_undo()
276    }
277
278    pub fn can_redo(&self) -> bool {
279        self.undo.borrow().can_redo()
280    }
281
282    pub fn add_undo_barrier(&self) {
283        self.undo.borrow_mut().add_undo_barrier();
284    }
285
286    pub(crate) fn transact_for(&self, context: &EditCtx) -> yrs::TransactionMut<'_> {
287        match context.origin {
288            EditOrigin::Local => self.doc.transact_mut_with(self.client_id),
289            EditOrigin::Agent => self.doc.transact_mut_with("pptx:agent"),
290            EditOrigin::Remote => self.doc.transact_mut_with(REMOTE_ORIGIN),
291            EditOrigin::System => self.doc.transact_mut_with("pptx:system"),
292        }
293    }
294
295    pub(crate) fn next_id(&self, prefix: &str) -> String {
296        let counter = self.id_counter.fetch_add(1, Ordering::Relaxed);
297        format!("{prefix}:{}:{counter}", self.client_id)
298    }
299}
300
301pub(crate) fn doc_with_client_id(client_id: u64) -> Doc {
302    let mut options = Options::with_client_id(ClientID::new(client_id));
303    options.offset_kind = OffsetKind::Utf16;
304    Doc::with_options(options)
305}
306
307fn validate_client_id(client_id: u64) -> EditResult<()> {
308    if client_id == 0 || client_id > MAX_SAFE_CLIENT_ID {
309        return Err(EditError::InvalidClientId(client_id));
310    }
311    Ok(())
312}
313
314fn hydrate_doc(doc: &Doc, bytes: &[u8]) -> EditResult<()> {
315    let update = decode_update_v1(bytes).map_err(EditError::InvalidUpdate)?;
316    let mut txn = doc.transact_mut_with(HYDRATE_ORIGIN);
317    txn.apply_update(update)
318        .map_err(|error| EditError::InvalidUpdate(error.to_string()))?;
319    // An empty container carries no items, so an update cannot carry it either.
320    // Every peer registers the roots the same way, which stays convergent.
321    txn.get_or_insert_array(SLIDE_ORDER);
322    for root in [META, SLIDES, SHAPES, STORIES, COMMENTS] {
323        txn.get_or_insert_map(root);
324    }
325    Ok(())
326}
327
328fn decode_update_v1(bytes: &[u8]) -> Result<Update, String> {
329    let mut decoder = DecoderV1::from(bytes);
330    let update = Update::decode(&mut decoder).map_err(|error| error.to_string())?;
331    if !decoder
332        .read_to_end()
333        .map_err(|error| error.to_string())?
334        .is_empty()
335    {
336        return Err("update contains trailing bytes".to_owned());
337    }
338    Ok(update)
339}
340
341fn decode_state_vector_v1(bytes: &[u8]) -> Result<StateVector, String> {
342    validate_state_vector_entry_count(bytes)?;
343    let mut decoder = DecoderV1::from(bytes);
344    let state_vector = StateVector::decode(&mut decoder).map_err(|error| error.to_string())?;
345    if !decoder
346        .read_to_end()
347        .map_err(|error| error.to_string())?
348        .is_empty()
349    {
350        return Err("state vector contains trailing bytes".to_owned());
351    }
352    Ok(state_vector)
353}
354
355fn validate_state_vector_entry_count(bytes: &[u8]) -> Result<(), String> {
356    let Some((&first, _)) = bytes.split_first() else {
357        return Err("state vector is empty".to_owned());
358    };
359    let mut value = u32::from(first & 0x7f);
360    let mut shift = 7;
361    let mut used = 1;
362    let mut byte = first;
363    while byte & 0x80 != 0 {
364        if used == 5 || used >= bytes.len() {
365            return Err("invalid state vector entry count".to_owned());
366        }
367        byte = bytes[used];
368        if used == 4 && byte > 0x0f {
369            return Err("invalid state vector entry count".to_owned());
370        }
371        value |= u32::from(byte & 0x7f) << shift;
372        shift += 7;
373        used += 1;
374    }
375    if value > MAX_STATE_VECTOR_ENTRIES {
376        return Err(format!(
377            "state vector contains {value} entries, exceeds the {MAX_STATE_VECTOR_ENTRIES}-entry limit"
378        ));
379    }
380    if value as usize > bytes.len().saturating_sub(used) / 2 {
381        return Err("state vector entry count exceeds its payload".to_owned());
382    }
383    Ok(())
384}