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::{TableColumnWidth, 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 mut 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        effects::preserve_theme_styles(&mut package, self.package());
284        collect_garbage(&self.doc, None);
285        Ok(Self {
286            package: Arc::new(package),
287            baseline_epoch: self.epoch(),
288            ..self
289        })
290    }
291
292    pub fn client_id(&self) -> u64 {
293        self.client_id
294    }
295
296    pub fn package(&self) -> &PptxPackage {
297        &self.package
298    }
299
300    pub fn yrs_doc(&self) -> &Doc {
301        &self.doc
302    }
303
304    pub fn encode_state_vector_v1(&self) -> Vec<u8> {
305        self.doc.transact().state_vector().encode_v1()
306    }
307
308    pub fn encode_state_as_update_v1(&self) -> Vec<u8> {
309        (*self.state_update_v1()).clone()
310    }
311
312    pub(crate) fn state_update_v1(&self) -> Arc<Vec<u8>> {
313        let epoch = self.epoch();
314        if let Some((cached_epoch, update)) = &*self.state_update.borrow()
315            && *cached_epoch == epoch
316        {
317            return Arc::clone(update);
318        }
319        let update = Arc::new(
320            self.doc
321                .transact()
322                .encode_state_as_update_v1(&StateVector::default()),
323        );
324        *self.state_update.borrow_mut() = Some((epoch, Arc::clone(&update)));
325        update
326    }
327
328    pub(crate) fn epoch(&self) -> u64 {
329        self.epoch.load(Ordering::Relaxed)
330    }
331
332    /// The optimistic-concurrency token of the committed deck state.
333    ///
334    /// It changes with every committed change, local or remote, undo and redo included. It is
335    /// scoped to this session: a token from another session, even of the same file, never
336    /// matches.
337    pub fn version(&self) -> DocumentVersion {
338        batch::version_token(self.version_nonce.load(Ordering::Relaxed), self.epoch())
339    }
340
341    /// Invalidates every version handed out so far; `entropy` is mixed into the new nonce.
342    #[cfg(feature = "wasm")]
343    pub(crate) fn rotate_version(&self, entropy: u64) {
344        self.version_nonce.store(
345            batch::mint_nonce(self.client_id, entropy),
346            Ordering::Relaxed,
347        );
348    }
349
350    pub fn encode_diff_v1(&self, remote_state_vector: &[u8]) -> EditResult<Vec<u8>> {
351        let state_vector =
352            decode_state_vector_v1(remote_state_vector).map_err(EditError::InvalidStateVector)?;
353        Ok(self.doc.transact().encode_diff_v1(&state_vector))
354    }
355
356    pub fn apply_update_v1(&self, bytes: &[u8]) -> EditResult<DeckSnapshot> {
357        if bytes.len() > MAX_UPDATE_BYTES {
358            return Err(EditError::InvalidUpdate(format!(
359                "update exceeds {MAX_UPDATE_BYTES} bytes"
360            )));
361        }
362        let incoming = decode_update_v1(bytes).map_err(EditError::InvalidUpdate)?;
363        let staged = doc_with_client_id(self.client_id);
364        hydrate_doc(&staged, &self.encode_state_as_update_v1())?;
365        let carried = remote_cleanup::apply_staged_update(
366            &mut staged.transact_mut_with(REMOTE_ORIGIN),
367            incoming,
368        )
369        .map_err(|error| EditError::InvalidUpdate(error.to_string()))?;
370        let (_, snapshot) = deck::validate_doc(&staged)?;
371        let watched: std::collections::BTreeSet<String> = self
372            .undo
373            .borrow()
374            .tracked_tables()
375            .chain(self.table_edits.borrow().keys())
376            .cloned()
377            .collect();
378        let logs: Vec<_> = watched
379            .iter()
380            .map(|id| (id, table::edit_log(&self.doc, id)))
381            .collect();
382
383        let diff = staged
384            .transact()
385            .encode_state_as_update_v1(&self.doc.transact().state_vector());
386        let diff = decode_update_v1(&diff).map_err(EditError::InvalidUpdate)?;
387        let mut integration = self
388            .remote_deletions
389            .integrate(self.doc.transact_mut_with(REMOTE_ORIGIN));
390        integration.carry(&carried);
391        integration
392            .apply_update(diff)
393            .map_err(|error| EditError::InvalidUpdate(error.to_string()))?;
394        drop(integration);
395        for (id, before) in logs {
396            let after = table::edit_log(&self.doc, id);
397            if after != before {
398                self.undo.borrow_mut().drop_table(&self.doc, id)?;
399                self.settle_table_edits(id, &before, &after);
400            }
401        }
402        if !self.undo.borrow().ever_kept() {
403            collect_garbage(&self.doc, Some(&carried));
404        }
405        Ok(snapshot)
406    }
407
408    /// A remote update whose integration also deleted formatting it did not carry is followed by
409    /// a local delete-only update with those deletions, for peers.
410    pub fn observe_update_v1<F>(&self, callback: F) -> EditResult<Subscription>
411    where
412        F: Fn(UpdateEvent) + 'static,
413    {
414        let remote_deletions = self.remote_deletions.clone();
415        self.doc
416            .observe_update_v1(move |txn, event| {
417                let remote = txn
418                    .origin()
419                    .is_some_and(|origin| origin.as_ref() == REMOTE_ORIGIN.as_bytes());
420                let origin = if remote {
421                    UpdateOrigin::Remote
422                } else {
423                    UpdateOrigin::Local
424                };
425                let update = UpdateEvent {
426                    update: event.update.clone(),
427                    origin,
428                };
429                let _ = catch_unwind(AssertUnwindSafe(|| callback(update)));
430                if !remote {
431                    return;
432                }
433                if let Some(update) = remote_deletions.residual_update_v1(txn) {
434                    let update = UpdateEvent {
435                        update,
436                        origin: UpdateOrigin::Local,
437                    };
438                    let _ = catch_unwind(AssertUnwindSafe(|| callback(update)));
439                }
440            })
441            .map_err(|error| EditError::Observer(error.to_string()))
442    }
443
444    pub fn undo(&self) -> bool {
445        self.undo.borrow_mut().undo()
446    }
447
448    pub fn redo(&self) -> bool {
449        self.undo.borrow_mut().redo()
450    }
451
452    /// Clears local undo and redo history.
453    pub fn clear_undo_history(&self) {
454        self.undo.borrow_mut().clear();
455    }
456
457    /// Marks this session as part of a shared session: structural table operations are refused
458    /// from now on, and local structural table undo and redo items are dropped.
459    pub fn set_collaborative(&self) -> EditResult<()> {
460        self.collaborative.set(true);
461        let mut undo = self.undo.borrow_mut();
462        let tables: Vec<_> = undo.tracked_tables().cloned().collect();
463        for id in &tables {
464            undo.drop_table(&self.doc, id)?;
465        }
466        Ok(())
467    }
468
469    pub fn is_collaborative(&self) -> bool {
470        self.collaborative.get()
471    }
472
473    /// Shape ids of tables whose local structural edit lost to a concurrent remote structural
474    /// edit since the last call. The remote structure is kept and the local edit is discarded.
475    pub fn take_discarded_table_edits(&self) -> Vec<String> {
476        std::mem::take(&mut *self.discarded_tables.borrow_mut())
477    }
478
479    fn settle_table_edits(&self, shape_id: &str, before: &table::EditLog, after: &table::EditLog) {
480        let mut edits = self.table_edits.borrow_mut();
481        let Some(local) = edits.get_mut(shape_id) else {
482            return;
483        };
484        let lineage = after.lineage();
485        let count = local.len();
486        local.retain(|generation| {
487            !before.edits.contains_key(generation) || lineage.contains(generation.as_str())
488        });
489        if local.len() < count {
490            let mut discarded = self.discarded_tables.borrow_mut();
491            if !discarded.iter().any(|id| id == shape_id) {
492                discarded.push(shape_id.to_owned());
493            }
494        }
495    }
496
497    pub fn can_undo(&self) -> bool {
498        self.undo.borrow().can_undo()
499    }
500
501    pub fn can_redo(&self) -> bool {
502        self.undo.borrow().can_redo()
503    }
504
505    pub fn undo_capture_mode(&self) -> UndoCaptureMode {
506        self.undo.borrow().capture_mode()
507    }
508
509    pub fn set_undo_capture_mode(&self, mode: UndoCaptureMode) {
510        self.undo.borrow_mut().set_capture_mode(mode);
511    }
512
513    pub(crate) fn automatic_undo_barrier(&self) {
514        if self.undo_capture_mode() == UndoCaptureMode::Auto {
515            self.add_undo_barrier();
516        }
517    }
518
519    pub fn add_undo_barrier(&self) {
520        self.undo.borrow_mut().add_undo_barrier();
521    }
522
523    pub(crate) fn transact_for(&self, context: &EditCtx) -> yrs::TransactionMut<'_> {
524        match context.origin {
525            EditOrigin::Local => self.doc.transact_mut_with(self.client_id),
526            EditOrigin::Agent => self.doc.transact_mut_with("pptx:agent"),
527            EditOrigin::Remote => self.doc.transact_mut_with(REMOTE_ORIGIN),
528            EditOrigin::System => self.doc.transact_mut_with("pptx:system"),
529        }
530    }
531
532    pub(crate) fn next_id(&self, prefix: &str) -> String {
533        let counter = self.id_counter.fetch_add(1, Ordering::Relaxed);
534        format!("{prefix}:{}:{counter}", self.client_id)
535    }
536}
537
538pub(crate) fn doc_with_client_id(client_id: u64) -> Doc {
539    Doc::with_options(doc_options(client_id))
540}
541
542/// Defers Yrs GC while undo items or keep flags left by replay reference deleted parents.
543pub(crate) fn session_doc(client_id: u64) -> Doc {
544    let mut options = doc_options(client_id);
545    options.skip_gc = true;
546    Doc::with_options(options)
547}
548
549fn collect_garbage(doc: &Doc, scope: Option<&IdSet>) {
550    doc.transact_mut_with(HYDRATE_ORIGIN).gc(scope);
551}
552
553fn doc_options(client_id: u64) -> Options {
554    let mut options = Options::with_client_id(ClientID::new(client_id));
555    options.offset_kind = OffsetKind::Utf16;
556    options
557}
558
559fn validate_client_id(client_id: u64) -> EditResult<()> {
560    if client_id == 0 || client_id > MAX_SAFE_CLIENT_ID {
561        return Err(EditError::InvalidClientId(client_id));
562    }
563    Ok(())
564}
565
566fn hydrate_doc(doc: &Doc, bytes: &[u8]) -> EditResult<()> {
567    let update = decode_update_v1(bytes).map_err(EditError::InvalidUpdate)?;
568    let mut txn = doc.transact_mut_with(HYDRATE_ORIGIN);
569    txn.apply_update(update)
570        .map_err(|error| EditError::InvalidUpdate(error.to_string()))?;
571    // An empty container carries no items, so an update cannot carry it either.
572    // Every peer registers the roots the same way, which stays convergent.
573    txn.get_or_insert_array(SLIDE_ORDER);
574    for root in [META, SLIDES, SHAPES, STORIES, COMMENTS] {
575        txn.get_or_insert_map(root);
576    }
577    Ok(())
578}
579
580fn decode_update_v1(bytes: &[u8]) -> Result<Update, String> {
581    let mut decoder = DecoderV1::from(bytes);
582    let update = Update::decode(&mut decoder).map_err(|error| error.to_string())?;
583    if !decoder
584        .read_to_end()
585        .map_err(|error| error.to_string())?
586        .is_empty()
587    {
588        return Err("update contains trailing bytes".to_owned());
589    }
590    Ok(update)
591}
592
593fn decode_state_vector_v1(bytes: &[u8]) -> Result<StateVector, String> {
594    validate_state_vector_entry_count(bytes)?;
595    let mut decoder = DecoderV1::from(bytes);
596    let state_vector = StateVector::decode(&mut decoder).map_err(|error| error.to_string())?;
597    if !decoder
598        .read_to_end()
599        .map_err(|error| error.to_string())?
600        .is_empty()
601    {
602        return Err("state vector contains trailing bytes".to_owned());
603    }
604    Ok(state_vector)
605}
606
607fn validate_state_vector_entry_count(bytes: &[u8]) -> Result<(), String> {
608    let Some((&first, _)) = bytes.split_first() else {
609        return Err("state vector is empty".to_owned());
610    };
611    let mut value = u32::from(first & 0x7f);
612    let mut shift = 7;
613    let mut used = 1;
614    let mut byte = first;
615    while byte & 0x80 != 0 {
616        if used == 5 || used >= bytes.len() {
617            return Err("invalid state vector entry count".to_owned());
618        }
619        byte = bytes[used];
620        if used == 4 && byte > 0x0f {
621            return Err("invalid state vector entry count".to_owned());
622        }
623        value |= u32::from(byte & 0x7f) << shift;
624        shift += 7;
625        used += 1;
626    }
627    if value > MAX_STATE_VECTOR_ENTRIES {
628        return Err(format!(
629            "state vector contains {value} entries, exceeds the {MAX_STATE_VECTOR_ENTRIES}-entry limit"
630        ));
631    }
632    if value as usize > bytes.len().saturating_sub(used) / 2 {
633        return Err("state vector entry count exceeds its payload".to_owned());
634    }
635    Ok(())
636}