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, IdSet, OffsetKind, Options, ReadTxn, StateVector, StickyIndex, Subscription,
14    Transact, Update, WriteTxn,
15};
16
17mod batch;
18mod comments;
19mod deck;
20mod effects;
21mod inherit;
22mod model;
23mod outline_gradients;
24pub mod paragraph;
25mod peer;
26mod proposal_diff;
27mod proposals;
28mod remote_cleanup;
29mod replay;
30mod save;
31mod search;
32mod source_run_properties;
33mod staging;
34mod story;
35pub mod structured;
36mod table;
37mod target;
38mod undo;
39
40pub use batch::{
41    BackgroundGuard, DocumentVersion, EditApplication, EditFailure, EditFailureCode, EditHistory,
42    EditOutcome, EditPreview, EditReceipt, EditRefusal, EditRequest, EditSource, EditStep,
43    EditTarget, EditValidation, FillGuard, MAX_REQUEST_BYTES, OutlineGuard, RectGuard, SlideTarget,
44    TargetEdge, TextGuard, ValidationOutcome, outcome_json, oversized_request,
45};
46#[doc(hidden)]
47pub use deck::baseline_snapshot;
48pub use model::*;
49pub use ooxml_drawingml::{ColorValue, GradientFill, GradientStop, ShapeFill};
50#[doc(hidden)]
51pub use peer::{PeerError, PeerFont};
52pub use proposal_diff::*;
53pub use proposals::*;
54pub use search::TextSearchMatch;
55#[doc(hidden)]
56pub use story::baseline_story;
57pub use table::{TableEdit, TableInsertPosition, TableReceipt};
58pub use target::{
59    FindMatch, FindOutcome, FindRequest, FindResponse, FindScope, ParagraphText, ReadOutcome,
60    ReadRequest, ReadResponse, ShapeTarget, StoryTarget, StoryText, TextField, TextRange,
61    TextTarget,
62};
63pub use undo::{DeckUndoManager, UndoCaptureMode};
64
65#[cfg(feature = "wasm")]
66pub mod wasm;
67
68pub(crate) const META: &str = "pptx:meta";
69pub(crate) const SLIDE_ORDER: &str = "pptx:slide-order";
70pub(crate) const SLIDES: &str = "pptx:slides";
71pub(crate) const SHAPES: &str = "pptx:shapes";
72pub(crate) const STORIES: &str = "pptx:stories";
73pub(crate) const COMMENTS: &str = "pptx:comments";
74pub(crate) const REMOTE_ORIGIN: &str = "pptx:remote";
75pub(crate) const HYDRATE_ORIGIN: &str = "pptx:hydrate";
76pub(crate) const MIGRATE_ORIGIN: &str = "pptx:migrate";
77pub(crate) const PILCROW_KIND: &str = "pilcrow";
78pub(crate) const KIND: &str = "_kind";
79pub(crate) const PARA_ID: &str = "paraId";
80pub(crate) const BOOTSTRAP_CLIENT_ID: u64 = (1_u64 << 53) - 1;
81pub const MAX_SAFE_CLIENT_ID: u64 = BOOTSTRAP_CLIENT_ID - 1;
82pub const MAX_UPDATE_BYTES: usize = 64 * 1024 * 1024;
83const MAX_STATE_VECTOR_ENTRIES: u32 = 65_536;
84
85pub type UpdateSubscription = Subscription;
86
87#[derive(Clone, Debug, Eq, PartialEq)]
88pub struct CaretAnchor {
89    story_id: String,
90    position: StickyIndex,
91}
92
93pub struct DeckSession {
94    pub(crate) doc: Doc,
95    client_id: u64,
96    id_counter: AtomicU64,
97    package: Arc<PptxPackage>,
98    undo: RefCell<DeckUndoManager>,
99    table_edits: RefCell<std::collections::HashMap<String, Vec<String>>>,
100    discarded_tables: RefCell<Vec<String>>,
101    collaborative: std::cell::Cell<bool>,
102    proposals: RefCell<proposals::ProposalStore>,
103    /// Bumped on every committed transaction via `_epoch_observer`, so a value
104    /// uniquely identifies the doc's state for memoized computations.
105    epoch: Arc<AtomicU64>,
106    /// Scopes version tokens to this session object.
107    version_nonce: AtomicU64,
108    baseline_epoch: u64,
109    replay_state: RefCell<replay::ReplayState>,
110    _epoch_observer: UpdateSubscription,
111    state_update: RefCell<Option<(u64, Arc<Vec<u8>>)>>,
112    remote_deletions: remote_cleanup::RemoteDeletions,
113}
114
115fn watch_epoch(doc: &Doc) -> EditResult<(Arc<AtomicU64>, UpdateSubscription)> {
116    let epoch = Arc::new(AtomicU64::new(0));
117    let counter = Arc::clone(&epoch);
118    // After-transaction callbacks run before any update observer, so a version read from inside
119    // an update callback already reflects the transaction being published.
120    let observer = doc
121        .observe_after_transaction(move |txn| {
122            if !txn.delete_set().is_empty() || txn.after_state() != txn.before_state() {
123                counter.fetch_add(1, Ordering::Relaxed);
124            }
125        })
126        .map_err(|error| EditError::Observer(error.to_string()))?;
127    Ok((epoch, observer))
128}
129
130impl DeckSession {
131    pub fn open(bytes: &[u8], client_id: u64) -> EditResult<Self> {
132        let package =
133            pptx_parse::parse_pptx(bytes).map_err(|error| EditError::Parse(error.to_string()))?;
134        Self::from_package_with_source(package, bytes, client_id)
135    }
136
137    /// Opens an edit session from an already parsed package. The fingerprint
138    /// is taken from a re-zip of the package; prefer
139    /// [`Self::from_package_with_source`] when the file bytes are at hand, so
140    /// peers can match them against the update.
141    pub fn from_package(package: PptxPackage, client_id: u64) -> EditResult<Self> {
142        let bytes = pptx_parse::write_pptx(&package)
143            .map_err(|error| EditError::Parse(error.to_string()))?;
144        let fingerprint = format!("{:x}", Sha256::digest(bytes));
145        Self::from_package_with_fingerprint(package, fingerprint, client_id)
146    }
147
148    /// Opens an edit session from a parsed package, fingerprinting the file
149    /// bytes it was parsed from.
150    pub fn from_package_with_source(
151        package: PptxPackage,
152        source: &[u8],
153        client_id: u64,
154    ) -> EditResult<Self> {
155        let fingerprint = format!("{:x}", Sha256::digest(source));
156        Self::from_package_with_fingerprint(package, fingerprint, client_id)
157    }
158
159    fn from_package_with_fingerprint(
160        package: PptxPackage,
161        fingerprint: String,
162        client_id: u64,
163    ) -> EditResult<Self> {
164        validate_client_id(client_id)?;
165        let bootstrap = doc_with_client_id(BOOTSTRAP_CLIENT_ID);
166        deck::seed_doc(&bootstrap, &package, &fingerprint)?;
167        let baseline = bootstrap
168            .transact()
169            .encode_state_as_update_v1(&StateVector::default());
170        let doc = session_doc(client_id);
171        hydrate_doc(&doc, &baseline)?;
172        deck::validate_doc(&doc)?;
173        Self::assemble(
174            doc,
175            client_id,
176            0,
177            Arc::new(package),
178            batch::mint_nonce(client_id, 0),
179        )
180    }
181
182    pub fn open_from_update(update: &[u8], client_id: u64) -> EditResult<Self> {
183        validate_client_id(client_id)?;
184        if update.len() > MAX_UPDATE_BYTES {
185            return Err(EditError::InvalidUpdate(format!(
186                "update exceeds {MAX_UPDATE_BYTES} bytes"
187            )));
188        }
189        let doc = session_doc(client_id);
190        hydrate_doc(&doc, update)?;
191        deck::migrate_doc(&doc)?;
192        collect_garbage(&doc, None);
193        let (package, _snapshot) = deck::validate_doc(&doc)?;
194        Self::assemble(
195            doc,
196            client_id,
197            0,
198            Arc::new(package),
199            batch::mint_nonce(client_id, 0),
200        )
201    }
202
203    pub(crate) fn assemble(
204        doc: Doc,
205        client_id: u64,
206        id_counter: u64,
207        package: Arc<PptxPackage>,
208        version_nonce: u64,
209    ) -> EditResult<Self> {
210        let undo = DeckUndoManager::new(&doc, client_id)?;
211        let (epoch, _epoch_observer) = watch_epoch(&doc)?;
212        Ok(Self {
213            doc,
214            client_id,
215            id_counter: AtomicU64::new(id_counter),
216            package,
217            undo: RefCell::new(undo),
218            table_edits: Default::default(),
219            discarded_tables: Default::default(),
220            collaborative: Default::default(),
221            proposals: Default::default(),
222            epoch,
223            version_nonce: AtomicU64::new(version_nonce),
224            baseline_epoch: 0,
225            replay_state: Default::default(),
226            _epoch_observer,
227            state_update: RefCell::new(None),
228            remote_deletions: Default::default(),
229        })
230    }
231
232    /// Like [`Self::open_from_update`], but re-attaches the source file the
233    /// update was seeded from, so the session can save. The bytes must hash to
234    /// the fingerprint recorded in the update.
235    pub fn open_from_update_with_source(
236        update: &[u8],
237        source: &[u8],
238        client_id: u64,
239    ) -> EditResult<Self> {
240        let session = Self::open_from_update(update, client_id)?;
241        if !session.seeded_from(source)? {
242            return Err(EditError::Parse(
243                "source bytes do not match the fingerprint recorded in the update".to_owned(),
244            ));
245        }
246        session.attach_source(source)
247    }
248
249    /// Whether `source` hashes to the fingerprint recorded in the update.
250    pub(crate) fn seeded_from(&self, source: &[u8]) -> EditResult<bool> {
251        Ok(deck::fingerprint_from_doc(&self.doc)? == format!("{:x}", Sha256::digest(source)))
252    }
253
254    /// Re-attaches the matching source, importing what the stored package lacks.
255    pub(crate) fn attach_source(self, source: &[u8]) -> EditResult<Self> {
256        let package = if self.package.models_connectors() {
257            pptx_parse::parse_pptx(source)
258        } else {
259            pptx_parse::parse_pptx_without_connectors(source)
260        }
261        .map_err(|error| EditError::Parse(error.to_string()))?;
262        let mut import = deck::SourceImport::new(self.package().clone(), &package);
263        comments::import_source_comments(&self, &mut import)?;
264        deck::import_source_render_data(&self.doc, &mut import)?;
265        source_run_properties::import_source(
266            &self,
267            &mut import,
268            source_run_properties::SourceProperty::Baseline,
269        )?;
270        deck::import_source_ole_pictures(&self.doc, import.source)?;
271        effects::import_source(&mut import);
272        for property in [
273            source_run_properties::SourceProperty::Spacing,
274            source_run_properties::SourceProperty::Kern,
275            source_run_properties::SourceProperty::Caps,
276            source_run_properties::SourceProperty::Color,
277        ] {
278            source_run_properties::import_source(&self, &mut import, property)?;
279        }
280        story::import_source_numbering_restarts(&self.doc, import.source)?;
281        outline_gradients::import_source(&self, &mut import)?;
282        import.sync_package_json(&self.doc, self.package())?;
283        collect_garbage(&self.doc, None);
284        Ok(Self {
285            package: Arc::new(package),
286            baseline_epoch: self.epoch(),
287            ..self
288        })
289    }
290
291    pub fn client_id(&self) -> u64 {
292        self.client_id
293    }
294
295    pub fn package(&self) -> &PptxPackage {
296        &self.package
297    }
298
299    pub fn yrs_doc(&self) -> &Doc {
300        &self.doc
301    }
302
303    pub fn encode_state_vector_v1(&self) -> Vec<u8> {
304        self.doc.transact().state_vector().encode_v1()
305    }
306
307    pub fn encode_state_as_update_v1(&self) -> Vec<u8> {
308        (*self.state_update_v1()).clone()
309    }
310
311    pub(crate) fn state_update_v1(&self) -> Arc<Vec<u8>> {
312        let epoch = self.epoch();
313        if let Some((cached_epoch, update)) = &*self.state_update.borrow()
314            && *cached_epoch == epoch
315        {
316            return Arc::clone(update);
317        }
318        let update = Arc::new(
319            self.doc
320                .transact()
321                .encode_state_as_update_v1(&StateVector::default()),
322        );
323        *self.state_update.borrow_mut() = Some((epoch, Arc::clone(&update)));
324        update
325    }
326
327    pub(crate) fn epoch(&self) -> u64 {
328        self.epoch.load(Ordering::Relaxed)
329    }
330
331    /// The optimistic-concurrency token of the committed deck state.
332    ///
333    /// It changes with every committed change, local or remote, undo and redo included. It is
334    /// scoped to this session: a token from another session, even of the same file, never
335    /// matches.
336    pub fn version(&self) -> DocumentVersion {
337        batch::version_token(self.version_nonce.load(Ordering::Relaxed), self.epoch())
338    }
339
340    /// Invalidates every version handed out so far; `entropy` is mixed into the new nonce.
341    #[cfg(feature = "wasm")]
342    pub(crate) fn rotate_version(&self, entropy: u64) {
343        self.version_nonce.store(
344            batch::mint_nonce(self.client_id, entropy),
345            Ordering::Relaxed,
346        );
347    }
348
349    pub fn encode_diff_v1(&self, remote_state_vector: &[u8]) -> EditResult<Vec<u8>> {
350        let state_vector =
351            decode_state_vector_v1(remote_state_vector).map_err(EditError::InvalidStateVector)?;
352        Ok(self.doc.transact().encode_diff_v1(&state_vector))
353    }
354
355    pub fn apply_update_v1(&self, bytes: &[u8]) -> EditResult<DeckSnapshot> {
356        if bytes.len() > MAX_UPDATE_BYTES {
357            return Err(EditError::InvalidUpdate(format!(
358                "update exceeds {MAX_UPDATE_BYTES} bytes"
359            )));
360        }
361        let incoming = decode_update_v1(bytes).map_err(EditError::InvalidUpdate)?;
362        let staged = doc_with_client_id(self.client_id);
363        hydrate_doc(&staged, &self.encode_state_as_update_v1())?;
364        let carried = remote_cleanup::apply_staged_update(
365            &mut staged.transact_mut_with(REMOTE_ORIGIN),
366            incoming,
367        )
368        .map_err(|error| EditError::InvalidUpdate(error.to_string()))?;
369        let (_, snapshot) = deck::validate_doc(&staged)?;
370        let watched: std::collections::BTreeSet<String> = self
371            .undo
372            .borrow()
373            .tracked_tables()
374            .chain(self.table_edits.borrow().keys())
375            .cloned()
376            .collect();
377        let logs: Vec<_> = watched
378            .iter()
379            .map(|id| (id, table::edit_log(&self.doc, id)))
380            .collect();
381
382        let diff = staged
383            .transact()
384            .encode_state_as_update_v1(&self.doc.transact().state_vector());
385        let diff = decode_update_v1(&diff).map_err(EditError::InvalidUpdate)?;
386        let mut integration = self
387            .remote_deletions
388            .integrate(self.doc.transact_mut_with(REMOTE_ORIGIN));
389        integration.carry(&carried);
390        integration
391            .apply_update(diff)
392            .map_err(|error| EditError::InvalidUpdate(error.to_string()))?;
393        drop(integration);
394        for (id, before) in logs {
395            let after = table::edit_log(&self.doc, id);
396            if after != before {
397                self.undo.borrow_mut().drop_table(&self.doc, id)?;
398                self.settle_table_edits(id, &before, &after);
399            }
400        }
401        if !self.undo.borrow().ever_kept() {
402            collect_garbage(&self.doc, Some(&carried));
403        }
404        Ok(snapshot)
405    }
406
407    /// A remote update whose integration also deleted formatting it did not carry is followed by
408    /// a local delete-only update with those deletions, for peers.
409    pub fn observe_update_v1<F>(&self, callback: F) -> EditResult<Subscription>
410    where
411        F: Fn(UpdateEvent) + 'static,
412    {
413        let remote_deletions = self.remote_deletions.clone();
414        self.doc
415            .observe_update_v1(move |txn, event| {
416                let remote = txn
417                    .origin()
418                    .is_some_and(|origin| origin.as_ref() == REMOTE_ORIGIN.as_bytes());
419                let origin = if remote {
420                    UpdateOrigin::Remote
421                } else {
422                    UpdateOrigin::Local
423                };
424                let update = UpdateEvent {
425                    update: event.update.clone(),
426                    origin,
427                };
428                let _ = catch_unwind(AssertUnwindSafe(|| callback(update)));
429                if !remote {
430                    return;
431                }
432                if let Some(update) = remote_deletions.residual_update_v1(txn) {
433                    let update = UpdateEvent {
434                        update,
435                        origin: UpdateOrigin::Local,
436                    };
437                    let _ = catch_unwind(AssertUnwindSafe(|| callback(update)));
438                }
439            })
440            .map_err(|error| EditError::Observer(error.to_string()))
441    }
442
443    pub fn undo(&self) -> bool {
444        self.undo.borrow_mut().undo()
445    }
446
447    pub fn redo(&self) -> bool {
448        self.undo.borrow_mut().redo()
449    }
450
451    /// Clears local undo and redo history.
452    pub fn clear_undo_history(&self) {
453        self.undo.borrow_mut().clear();
454    }
455
456    /// Marks this session as part of a shared session: structural table operations are refused
457    /// from now on, and local structural table undo and redo items are dropped.
458    pub fn set_collaborative(&self) -> EditResult<()> {
459        self.collaborative.set(true);
460        let mut undo = self.undo.borrow_mut();
461        let tables: Vec<_> = undo.tracked_tables().cloned().collect();
462        for id in &tables {
463            undo.drop_table(&self.doc, id)?;
464        }
465        Ok(())
466    }
467
468    pub fn is_collaborative(&self) -> bool {
469        self.collaborative.get()
470    }
471
472    /// Shape ids of tables whose local structural edit lost to a concurrent remote structural
473    /// edit since the last call. The remote structure is kept and the local edit is discarded.
474    pub fn take_discarded_table_edits(&self) -> Vec<String> {
475        std::mem::take(&mut *self.discarded_tables.borrow_mut())
476    }
477
478    fn settle_table_edits(&self, shape_id: &str, before: &table::EditLog, after: &table::EditLog) {
479        let mut edits = self.table_edits.borrow_mut();
480        let Some(local) = edits.get_mut(shape_id) else {
481            return;
482        };
483        let lineage = after.lineage();
484        let count = local.len();
485        local.retain(|generation| {
486            !before.edits.contains_key(generation) || lineage.contains(generation.as_str())
487        });
488        if local.len() < count {
489            let mut discarded = self.discarded_tables.borrow_mut();
490            if !discarded.iter().any(|id| id == shape_id) {
491                discarded.push(shape_id.to_owned());
492            }
493        }
494    }
495
496    pub fn can_undo(&self) -> bool {
497        self.undo.borrow().can_undo()
498    }
499
500    pub fn can_redo(&self) -> bool {
501        self.undo.borrow().can_redo()
502    }
503
504    pub fn undo_capture_mode(&self) -> UndoCaptureMode {
505        self.undo.borrow().capture_mode()
506    }
507
508    pub fn set_undo_capture_mode(&self, mode: UndoCaptureMode) {
509        self.undo.borrow_mut().set_capture_mode(mode);
510    }
511
512    pub(crate) fn automatic_undo_barrier(&self) {
513        if self.undo_capture_mode() == UndoCaptureMode::Auto {
514            self.add_undo_barrier();
515        }
516    }
517
518    pub fn add_undo_barrier(&self) {
519        self.undo.borrow_mut().add_undo_barrier();
520    }
521
522    pub(crate) fn transact_for(&self, context: &EditCtx) -> yrs::TransactionMut<'_> {
523        match context.origin {
524            EditOrigin::Local => self.doc.transact_mut_with(self.client_id),
525            EditOrigin::Agent => self.doc.transact_mut_with("pptx:agent"),
526            EditOrigin::Remote => self.doc.transact_mut_with(REMOTE_ORIGIN),
527            EditOrigin::System => self.doc.transact_mut_with("pptx:system"),
528        }
529    }
530
531    pub(crate) fn next_id(&self, prefix: &str) -> String {
532        let counter = self.id_counter.fetch_add(1, Ordering::Relaxed);
533        format!("{prefix}:{}:{counter}", self.client_id)
534    }
535}
536
537pub(crate) fn doc_with_client_id(client_id: u64) -> Doc {
538    Doc::with_options(doc_options(client_id))
539}
540
541/// Defers Yrs GC while undo items or keep flags left by replay reference deleted parents.
542pub(crate) fn session_doc(client_id: u64) -> Doc {
543    let mut options = doc_options(client_id);
544    options.skip_gc = true;
545    Doc::with_options(options)
546}
547
548fn collect_garbage(doc: &Doc, scope: Option<&IdSet>) {
549    doc.transact_mut_with(HYDRATE_ORIGIN).gc(scope);
550}
551
552fn doc_options(client_id: u64) -> Options {
553    let mut options = Options::with_client_id(ClientID::new(client_id));
554    options.offset_kind = OffsetKind::Utf16;
555    options
556}
557
558fn validate_client_id(client_id: u64) -> EditResult<()> {
559    if client_id == 0 || client_id > MAX_SAFE_CLIENT_ID {
560        return Err(EditError::InvalidClientId(client_id));
561    }
562    Ok(())
563}
564
565fn hydrate_doc(doc: &Doc, bytes: &[u8]) -> EditResult<()> {
566    let update = decode_update_v1(bytes).map_err(EditError::InvalidUpdate)?;
567    let mut txn = doc.transact_mut_with(HYDRATE_ORIGIN);
568    txn.apply_update(update)
569        .map_err(|error| EditError::InvalidUpdate(error.to_string()))?;
570    // An empty container carries no items, so an update cannot carry it either.
571    // Every peer registers the roots the same way, which stays convergent.
572    txn.get_or_insert_array(SLIDE_ORDER);
573    for root in [META, SLIDES, SHAPES, STORIES, COMMENTS] {
574        txn.get_or_insert_map(root);
575    }
576    Ok(())
577}
578
579fn decode_update_v1(bytes: &[u8]) -> Result<Update, String> {
580    let mut decoder = DecoderV1::from(bytes);
581    let update = Update::decode(&mut decoder).map_err(|error| error.to_string())?;
582    if !decoder
583        .read_to_end()
584        .map_err(|error| error.to_string())?
585        .is_empty()
586    {
587        return Err("update contains trailing bytes".to_owned());
588    }
589    Ok(update)
590}
591
592fn decode_state_vector_v1(bytes: &[u8]) -> Result<StateVector, String> {
593    validate_state_vector_entry_count(bytes)?;
594    let mut decoder = DecoderV1::from(bytes);
595    let state_vector = StateVector::decode(&mut decoder).map_err(|error| error.to_string())?;
596    if !decoder
597        .read_to_end()
598        .map_err(|error| error.to_string())?
599        .is_empty()
600    {
601        return Err("state vector contains trailing bytes".to_owned());
602    }
603    Ok(state_vector)
604}
605
606fn validate_state_vector_entry_count(bytes: &[u8]) -> Result<(), String> {
607    let Some((&first, _)) = bytes.split_first() else {
608        return Err("state vector is empty".to_owned());
609    };
610    let mut value = u32::from(first & 0x7f);
611    let mut shift = 7;
612    let mut used = 1;
613    let mut byte = first;
614    while byte & 0x80 != 0 {
615        if used == 5 || used >= bytes.len() {
616            return Err("invalid state vector entry count".to_owned());
617        }
618        byte = bytes[used];
619        if used == 4 && byte > 0x0f {
620            return Err("invalid state vector entry count".to_owned());
621        }
622        value |= u32::from(byte & 0x7f) << shift;
623        shift += 7;
624        used += 1;
625    }
626    if value > MAX_STATE_VECTOR_ENTRIES {
627        return Err(format!(
628            "state vector contains {value} entries, exceeds the {MAX_STATE_VECTOR_ENTRIES}-entry limit"
629        ));
630    }
631    if value as usize > bytes.len().saturating_sub(used) / 2 {
632        return Err("state vector entry count exceeds its payload".to_owned());
633    }
634    Ok(())
635}