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