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 batch;
18mod comments;
19mod deck;
20mod effects;
21mod inherit;
22mod model;
23mod outline_gradients;
24pub mod paragraph;
25mod proposal_diff;
26mod proposals;
27mod save;
28mod search;
29mod source_run_properties;
30mod staging;
31mod story;
32pub mod structured;
33mod target;
34mod undo;
35
36pub use batch::{
37    DocumentVersion, EditApplication, EditFailure, EditFailureCode, EditHistory, EditOutcome,
38    EditPreview, EditReceipt, EditRefusal, EditRequest, EditSource, EditStep, EditTarget,
39    EditValidation, FillGuard, MAX_REQUEST_BYTES, OutlineGuard, RectGuard, SlideTarget, TargetEdge,
40    TextGuard, ValidationOutcome, outcome_json, oversized_request,
41};
42pub use model::*;
43pub use proposal_diff::*;
44pub use proposals::*;
45pub use search::TextSearchMatch;
46pub use target::{
47    FindMatch, FindOutcome, FindRequest, FindResponse, FindScope, ParagraphText, ReadOutcome,
48    ReadRequest, ReadResponse, ShapeTarget, StoryTarget, StoryText, TextField, TextRange,
49    TextTarget,
50};
51pub use undo::{DeckUndoManager, UndoCaptureMode};
52
53#[cfg(feature = "wasm")]
54pub mod wasm;
55
56pub(crate) const META: &str = "pptx:meta";
57pub(crate) const SLIDE_ORDER: &str = "pptx:slide-order";
58pub(crate) const SLIDES: &str = "pptx:slides";
59pub(crate) const SHAPES: &str = "pptx:shapes";
60pub(crate) const STORIES: &str = "pptx:stories";
61pub(crate) const COMMENTS: &str = "pptx:comments";
62pub(crate) const REMOTE_ORIGIN: &str = "pptx:remote";
63pub(crate) const HYDRATE_ORIGIN: &str = "pptx:hydrate";
64pub(crate) const MIGRATE_ORIGIN: &str = "pptx:migrate";
65pub(crate) const PILCROW_KIND: &str = "pilcrow";
66pub(crate) const KIND: &str = "_kind";
67pub(crate) const PARA_ID: &str = "paraId";
68pub(crate) const BOOTSTRAP_CLIENT_ID: u64 = (1_u64 << 53) - 1;
69pub const MAX_SAFE_CLIENT_ID: u64 = BOOTSTRAP_CLIENT_ID - 1;
70pub const MAX_UPDATE_BYTES: usize = 64 * 1024 * 1024;
71const MAX_STATE_VECTOR_ENTRIES: u32 = 65_536;
72
73pub type UpdateSubscription = Subscription;
74
75#[derive(Clone, Debug, Eq, PartialEq)]
76pub struct CaretAnchor {
77    story_id: String,
78    position: StickyIndex,
79}
80
81pub struct DeckSession {
82    pub(crate) doc: Doc,
83    client_id: u64,
84    id_counter: AtomicU64,
85    package: Arc<PptxPackage>,
86    undo: RefCell<DeckUndoManager>,
87    proposals: RefCell<proposals::ProposalStore>,
88    /// Bumped on every committed transaction via `_epoch_observer`, so a value
89    /// uniquely identifies the doc's state for memoized computations.
90    epoch: Arc<AtomicU64>,
91    /// Scopes version tokens to this session object.
92    version_nonce: AtomicU64,
93    _epoch_observer: UpdateSubscription,
94    state_update: RefCell<Option<(u64, Arc<Vec<u8>>)>>,
95}
96
97fn watch_epoch(doc: &Doc) -> EditResult<(Arc<AtomicU64>, UpdateSubscription)> {
98    let epoch = Arc::new(AtomicU64::new(0));
99    let counter = Arc::clone(&epoch);
100    // After-transaction callbacks run before any update observer, so a version read from inside
101    // an update callback already reflects the transaction being published.
102    let observer = doc
103        .observe_after_transaction(move |txn| {
104            if !txn.delete_set().is_empty() || txn.after_state() != txn.before_state() {
105                counter.fetch_add(1, Ordering::Relaxed);
106            }
107        })
108        .map_err(|error| EditError::Observer(error.to_string()))?;
109    Ok((epoch, observer))
110}
111
112impl DeckSession {
113    pub fn open(bytes: &[u8], client_id: u64) -> EditResult<Self> {
114        let package =
115            pptx_parse::parse_pptx(bytes).map_err(|error| EditError::Parse(error.to_string()))?;
116        Self::from_package_with_source(package, bytes, client_id)
117    }
118
119    /// Opens an edit session from an already parsed package. The fingerprint
120    /// is taken from a re-zip of the package; prefer
121    /// [`Self::from_package_with_source`] when the file bytes are at hand, so
122    /// peers can match them against the update.
123    pub fn from_package(package: PptxPackage, client_id: u64) -> EditResult<Self> {
124        let bytes = pptx_parse::write_pptx(&package)
125            .map_err(|error| EditError::Parse(error.to_string()))?;
126        let fingerprint = format!("{:x}", Sha256::digest(bytes));
127        Self::from_package_with_fingerprint(package, fingerprint, client_id)
128    }
129
130    /// Opens an edit session from a parsed package, fingerprinting the file
131    /// bytes it was parsed from.
132    pub fn from_package_with_source(
133        package: PptxPackage,
134        source: &[u8],
135        client_id: u64,
136    ) -> EditResult<Self> {
137        let fingerprint = format!("{:x}", Sha256::digest(source));
138        Self::from_package_with_fingerprint(package, fingerprint, client_id)
139    }
140
141    fn from_package_with_fingerprint(
142        package: PptxPackage,
143        fingerprint: String,
144        client_id: u64,
145    ) -> EditResult<Self> {
146        validate_client_id(client_id)?;
147        let bootstrap = doc_with_client_id(BOOTSTRAP_CLIENT_ID);
148        deck::seed_doc(&bootstrap, &package, &fingerprint)?;
149        let baseline = bootstrap
150            .transact()
151            .encode_state_as_update_v1(&StateVector::default());
152        let doc = doc_with_client_id(client_id);
153        hydrate_doc(&doc, &baseline)?;
154        deck::validate_doc(&doc)?;
155        Self::assemble(
156            doc,
157            client_id,
158            0,
159            Arc::new(package),
160            batch::mint_nonce(client_id, 0),
161        )
162    }
163
164    pub fn open_from_update(update: &[u8], client_id: u64) -> EditResult<Self> {
165        validate_client_id(client_id)?;
166        if update.len() > MAX_UPDATE_BYTES {
167            return Err(EditError::InvalidUpdate(format!(
168                "update exceeds {MAX_UPDATE_BYTES} bytes"
169            )));
170        }
171        let doc = doc_with_client_id(client_id);
172        hydrate_doc(&doc, update)?;
173        deck::migrate_doc(&doc)?;
174        let (package, _snapshot) = deck::validate_doc(&doc)?;
175        Self::assemble(
176            doc,
177            client_id,
178            0,
179            Arc::new(package),
180            batch::mint_nonce(client_id, 0),
181        )
182    }
183
184    pub(crate) fn assemble(
185        doc: Doc,
186        client_id: u64,
187        id_counter: u64,
188        package: Arc<PptxPackage>,
189        version_nonce: u64,
190    ) -> EditResult<Self> {
191        let undo = DeckUndoManager::new(&doc, client_id)?;
192        let (epoch, _epoch_observer) = watch_epoch(&doc)?;
193        Ok(Self {
194            doc,
195            client_id,
196            id_counter: AtomicU64::new(id_counter),
197            package,
198            undo: RefCell::new(undo),
199            proposals: Default::default(),
200            epoch,
201            version_nonce: AtomicU64::new(version_nonce),
202            _epoch_observer,
203            state_update: RefCell::new(None),
204        })
205    }
206
207    /// Like [`Self::open_from_update`], but re-attaches the source file the
208    /// update was seeded from, so the session can save. The bytes must hash to
209    /// the fingerprint recorded in the update.
210    pub fn open_from_update_with_source(
211        update: &[u8],
212        source: &[u8],
213        client_id: u64,
214    ) -> EditResult<Self> {
215        let session = Self::open_from_update(update, client_id)?;
216        if !session.seeded_from(source)? {
217            return Err(EditError::Parse(
218                "source bytes do not match the fingerprint recorded in the update".to_owned(),
219            ));
220        }
221        session.attach_source(source)
222    }
223
224    /// Whether `source` hashes to the fingerprint recorded in the update.
225    pub(crate) fn seeded_from(&self, source: &[u8]) -> EditResult<bool> {
226        Ok(deck::fingerprint_from_doc(&self.doc)? == format!("{:x}", Sha256::digest(source)))
227    }
228
229    /// Re-attaches the matching source, importing what the stored package lacks.
230    pub(crate) fn attach_source(self, source: &[u8]) -> EditResult<Self> {
231        let package = if self.package.models_connectors() {
232            pptx_parse::parse_pptx(source)
233        } else {
234            pptx_parse::parse_pptx_without_connectors(source)
235        }
236        .map_err(|error| EditError::Parse(error.to_string()))?;
237        let mut import = deck::SourceImport::new(self.package().clone(), &package);
238        comments::import_source_comments(&self, &mut import)?;
239        deck::import_source_render_data(&self.doc, &mut import)?;
240        source_run_properties::import_source(
241            &self,
242            &mut import,
243            source_run_properties::SourceProperty::Baseline,
244        )?;
245        deck::import_source_ole_pictures(&self.doc, import.source)?;
246        effects::import_source(&mut import);
247        for property in [
248            source_run_properties::SourceProperty::Spacing,
249            source_run_properties::SourceProperty::Caps,
250            source_run_properties::SourceProperty::Color,
251        ] {
252            source_run_properties::import_source(&self, &mut import, property)?;
253        }
254        story::import_source_numbering_restarts(&self.doc, import.source)?;
255        outline_gradients::import_source(&self, &mut import)?;
256        import.sync_package_json(&self.doc, self.package())?;
257        Ok(Self {
258            package: Arc::new(package),
259            ..self
260        })
261    }
262
263    pub fn client_id(&self) -> u64 {
264        self.client_id
265    }
266
267    pub fn package(&self) -> &PptxPackage {
268        &self.package
269    }
270
271    pub fn yrs_doc(&self) -> &Doc {
272        &self.doc
273    }
274
275    pub fn encode_state_vector_v1(&self) -> Vec<u8> {
276        self.doc.transact().state_vector().encode_v1()
277    }
278
279    pub fn encode_state_as_update_v1(&self) -> Vec<u8> {
280        (*self.state_update_v1()).clone()
281    }
282
283    pub(crate) fn state_update_v1(&self) -> Arc<Vec<u8>> {
284        let epoch = self.epoch();
285        if let Some((cached_epoch, update)) = &*self.state_update.borrow()
286            && *cached_epoch == epoch
287        {
288            return Arc::clone(update);
289        }
290        let update = Arc::new(
291            self.doc
292                .transact()
293                .encode_state_as_update_v1(&StateVector::default()),
294        );
295        *self.state_update.borrow_mut() = Some((epoch, Arc::clone(&update)));
296        update
297    }
298
299    pub(crate) fn epoch(&self) -> u64 {
300        self.epoch.load(Ordering::Relaxed)
301    }
302
303    /// The optimistic-concurrency token of the committed deck state.
304    ///
305    /// It changes with every committed change, local or remote, undo and redo included. It is
306    /// scoped to this session: a token from another session, even of the same file, never
307    /// matches.
308    pub fn version(&self) -> DocumentVersion {
309        batch::version_token(self.version_nonce.load(Ordering::Relaxed), self.epoch())
310    }
311
312    /// Invalidates every version handed out so far; `entropy` is mixed into the new nonce.
313    #[cfg(feature = "wasm")]
314    pub(crate) fn rotate_version(&self, entropy: u64) {
315        self.version_nonce.store(
316            batch::mint_nonce(self.client_id, entropy),
317            Ordering::Relaxed,
318        );
319    }
320
321    pub fn encode_diff_v1(&self, remote_state_vector: &[u8]) -> EditResult<Vec<u8>> {
322        let state_vector =
323            decode_state_vector_v1(remote_state_vector).map_err(EditError::InvalidStateVector)?;
324        Ok(self.doc.transact().encode_diff_v1(&state_vector))
325    }
326
327    pub fn apply_update_v1(&self, bytes: &[u8]) -> EditResult<DeckSnapshot> {
328        if bytes.len() > MAX_UPDATE_BYTES {
329            return Err(EditError::InvalidUpdate(format!(
330                "update exceeds {MAX_UPDATE_BYTES} bytes"
331            )));
332        }
333        let incoming = decode_update_v1(bytes).map_err(EditError::InvalidUpdate)?;
334        let staged = doc_with_client_id(self.client_id);
335        hydrate_doc(&staged, &self.encode_state_as_update_v1())?;
336        staged
337            .transact_mut_with(REMOTE_ORIGIN)
338            .apply_update(incoming)
339            .map_err(|error| EditError::InvalidUpdate(error.to_string()))?;
340        let (_, snapshot) = deck::validate_doc(&staged)?;
341
342        let diff = staged
343            .transact()
344            .encode_state_as_update_v1(&self.doc.transact().state_vector());
345        let diff = decode_update_v1(&diff).map_err(EditError::InvalidUpdate)?;
346        self.doc
347            .transact_mut_with(REMOTE_ORIGIN)
348            .apply_update(diff)
349            .map_err(|error| EditError::InvalidUpdate(error.to_string()))?;
350        Ok(snapshot)
351    }
352
353    pub fn observe_update_v1<F>(&self, callback: F) -> EditResult<Subscription>
354    where
355        F: Fn(UpdateEvent) + 'static,
356    {
357        self.doc
358            .observe_update_v1(move |txn, event| {
359                let origin = if txn
360                    .origin()
361                    .is_some_and(|origin| origin.as_ref() == REMOTE_ORIGIN.as_bytes())
362                {
363                    UpdateOrigin::Remote
364                } else {
365                    UpdateOrigin::Local
366                };
367                let update = UpdateEvent {
368                    update: event.update.clone(),
369                    origin,
370                };
371                let _ = catch_unwind(AssertUnwindSafe(|| callback(update)));
372            })
373            .map_err(|error| EditError::Observer(error.to_string()))
374    }
375
376    pub fn undo(&self) -> bool {
377        self.undo.borrow_mut().undo()
378    }
379
380    pub fn redo(&self) -> bool {
381        self.undo.borrow_mut().redo()
382    }
383
384    pub fn can_undo(&self) -> bool {
385        self.undo.borrow().can_undo()
386    }
387
388    pub fn can_redo(&self) -> bool {
389        self.undo.borrow().can_redo()
390    }
391
392    pub fn undo_capture_mode(&self) -> UndoCaptureMode {
393        self.undo.borrow().capture_mode()
394    }
395
396    pub fn set_undo_capture_mode(&self, mode: UndoCaptureMode) {
397        self.undo.borrow_mut().set_capture_mode(mode);
398    }
399
400    pub(crate) fn automatic_undo_barrier(&self) {
401        if self.undo_capture_mode() == UndoCaptureMode::Auto {
402            self.add_undo_barrier();
403        }
404    }
405
406    pub fn add_undo_barrier(&self) {
407        self.undo.borrow_mut().add_undo_barrier();
408    }
409
410    pub(crate) fn transact_for(&self, context: &EditCtx) -> yrs::TransactionMut<'_> {
411        match context.origin {
412            EditOrigin::Local => self.doc.transact_mut_with(self.client_id),
413            EditOrigin::Agent => self.doc.transact_mut_with("pptx:agent"),
414            EditOrigin::Remote => self.doc.transact_mut_with(REMOTE_ORIGIN),
415            EditOrigin::System => self.doc.transact_mut_with("pptx:system"),
416        }
417    }
418
419    pub(crate) fn next_id(&self, prefix: &str) -> String {
420        let counter = self.id_counter.fetch_add(1, Ordering::Relaxed);
421        format!("{prefix}:{}:{counter}", self.client_id)
422    }
423}
424
425pub(crate) fn doc_with_client_id(client_id: u64) -> Doc {
426    let mut options = Options::with_client_id(ClientID::new(client_id));
427    options.offset_kind = OffsetKind::Utf16;
428    Doc::with_options(options)
429}
430
431fn validate_client_id(client_id: u64) -> EditResult<()> {
432    if client_id == 0 || client_id > MAX_SAFE_CLIENT_ID {
433        return Err(EditError::InvalidClientId(client_id));
434    }
435    Ok(())
436}
437
438fn hydrate_doc(doc: &Doc, bytes: &[u8]) -> EditResult<()> {
439    let update = decode_update_v1(bytes).map_err(EditError::InvalidUpdate)?;
440    let mut txn = doc.transact_mut_with(HYDRATE_ORIGIN);
441    txn.apply_update(update)
442        .map_err(|error| EditError::InvalidUpdate(error.to_string()))?;
443    // An empty container carries no items, so an update cannot carry it either.
444    // Every peer registers the roots the same way, which stays convergent.
445    txn.get_or_insert_array(SLIDE_ORDER);
446    for root in [META, SLIDES, SHAPES, STORIES, COMMENTS] {
447        txn.get_or_insert_map(root);
448    }
449    Ok(())
450}
451
452fn decode_update_v1(bytes: &[u8]) -> Result<Update, String> {
453    let mut decoder = DecoderV1::from(bytes);
454    let update = Update::decode(&mut decoder).map_err(|error| error.to_string())?;
455    if !decoder
456        .read_to_end()
457        .map_err(|error| error.to_string())?
458        .is_empty()
459    {
460        return Err("update contains trailing bytes".to_owned());
461    }
462    Ok(update)
463}
464
465fn decode_state_vector_v1(bytes: &[u8]) -> Result<StateVector, String> {
466    validate_state_vector_entry_count(bytes)?;
467    let mut decoder = DecoderV1::from(bytes);
468    let state_vector = StateVector::decode(&mut decoder).map_err(|error| error.to_string())?;
469    if !decoder
470        .read_to_end()
471        .map_err(|error| error.to_string())?
472        .is_empty()
473    {
474        return Err("state vector contains trailing bytes".to_owned());
475    }
476    Ok(state_vector)
477}
478
479fn validate_state_vector_entry_count(bytes: &[u8]) -> Result<(), String> {
480    let Some((&first, _)) = bytes.split_first() else {
481        return Err("state vector is empty".to_owned());
482    };
483    let mut value = u32::from(first & 0x7f);
484    let mut shift = 7;
485    let mut used = 1;
486    let mut byte = first;
487    while byte & 0x80 != 0 {
488        if used == 5 || used >= bytes.len() {
489            return Err("invalid state vector entry count".to_owned());
490        }
491        byte = bytes[used];
492        if used == 4 && byte > 0x0f {
493            return Err("invalid state vector entry count".to_owned());
494        }
495        value |= u32::from(byte & 0x7f) << shift;
496        shift += 7;
497        used += 1;
498    }
499    if value > MAX_STATE_VECTOR_ENTRIES {
500        return Err(format!(
501            "state vector contains {value} entries, exceeds the {MAX_STATE_VECTOR_ENTRIES}-entry limit"
502        ));
503    }
504    if value as usize > bytes.len().saturating_sub(used) / 2 {
505        return Err("state vector entry count exceeds its payload".to_owned());
506    }
507    Ok(())
508}