use std::collections::{BTreeMap, VecDeque};
use std::sync::{Arc, Mutex};
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
use yrs::updates::decoder::Decode;
use yrs::updates::encoder::Encode;
use yrs::{StickyIndex, Subscription};
use crate::structured::{PptxExportOptions, export_outcome_json};
use crate::{
CaretAnchor, CommentFlavor, DeckSession, DeckSnapshot, EditCtx, EditRequest, FindRequest,
MAX_REQUEST_BYTES, PictureDraft, PresetShapeDraft, ReadRequest, ShapeDraft, ShapeReceipt,
ShapeRect, ShapeStroke, SlideReceipt, TextReceipt, TextStyle, TextStylePatch, TransformReceipt,
UpdateEvent, UpdateOrigin, outcome_json, oversized_request,
};
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = performance, js_name = now)]
fn performance_now() -> f64;
}
#[wasm_bindgen]
pub struct PptxDocument {
session: DeckSession,
update_observer: Option<UpdateObserver>,
}
struct UpdateObserver {
pending: Arc<Mutex<VecDeque<UpdateEvent>>>,
_subscription: Subscription,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SearchTextArgs {
query: String,
#[serde(default)]
case_sensitive: bool,
limit: Option<u32>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct StoryArgs {
story_id: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct StoryFormattingArgs {
slide_id: String,
shape_id: String,
story_id: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct AnchorCaretArgs {
story_id: String,
index: u32,
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct CaretAnchorJson {
story_id: String,
position: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct InsertTextArgs {
story_id: String,
index: u32,
text: String,
#[serde(default)]
style: TextStyle,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DeleteTextArgs {
story_id: String,
start: u32,
end: u32,
#[serde(default)]
allow_paragraph_join: bool,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct FormatTextArgs {
story_id: String,
start: u32,
end: u32,
#[serde(default)]
patch: TextStylePatch,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SetParagraphAlignmentArgs {
story_id: String,
start: u32,
end: u32,
#[serde(default)]
alignment: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct AddCommentArgs {
slide_id: String,
author: String,
#[serde(default)]
initials: String,
text: String,
created: String,
#[serde(default)]
x_emu: i64,
#[serde(default)]
y_emu: i64,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReplyCommentArgs {
comment_id: String,
author: String,
#[serde(default)]
initials: String,
text: String,
created: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommentPositionArgs {
comment_id: String,
x_emu: i64,
y_emu: i64,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommentStatusArgs {
comment_id: String,
resolved: bool,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommentIdArgs {
comment_id: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommentFlavorArgs {
flavor: CommentFlavor,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ParagraphBreakArgs {
story_id: String,
index: u32,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct InsertSlideArgs {
index: u32,
layout_part_path: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SlideArgs {
slide_id: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SetSlideNotesArgs {
slide_id: String,
text: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SetSlideLayoutArgs {
slide_id: String,
layout_part_path: String,
#[serde(default)]
mapping: Vec<(String, u32)>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct MoveSlideArgs {
slide_id: String,
to_index: u32,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct SetStoryParagraphsArgs {
story_id: String,
paragraphs: Vec<crate::TextParagraphDraft>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct AddTextBoxArgs {
slide_id: String,
draft: ShapeDraft,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct AddShapeArgs {
slide_id: String,
draft: PresetShapeDraft,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct AddPictureArgs {
slide_id: String,
name: String,
rect: ShapeRect,
content_type: String,
media_base64: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct TableCheckArgs {
edit: crate::TableEdit,
slide_id: String,
shape_id: String,
index: u32,
#[serde(default)]
position: Option<crate::TableInsertPosition>,
#[serde(default)]
cells: Vec<String>,
#[serde(default)]
width: crate::TableColumnWidth,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct TableArgs {
slide_id: String,
shape_id: String,
index: u32,
#[serde(default)]
position: Option<crate::TableInsertPosition>,
#[serde(default)]
cells: Vec<String>,
#[serde(default)]
width: crate::TableColumnWidth,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ShapeArgs {
slide_id: String,
shape_id: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct MoveShapeArgs {
slide_id: String,
shape_id: String,
x: i64,
y: i64,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ResizeShapeArgs {
slide_id: String,
shape_id: String,
width: i64,
height: i64,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SetShapeRectArgs {
slide_id: String,
shape_id: String,
rect: ShapeRect,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SetShapeFillArgs {
slide_id: String,
shape_id: String,
color: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SetShapeStrokeArgs {
slide_id: String,
shape_id: String,
#[serde(default)]
stroke: ShapeStroke,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SetShapeAdjustArgs {
slide_id: String,
shape_id: String,
adjustments: BTreeMap<String, f64>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct HistoryResult {
applied: bool,
snapshot: DeckSnapshot,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct EditProfile {
parse_ms: f64,
apply_ms: f64,
serialize_ms: f64,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct HistoryProfile {
undo_ms: f64,
snapshot_ms: f64,
serialize_ms: f64,
}
#[derive(Deserialize)]
struct ProposalIdArgs {
id: String,
#[serde(default)]
force: bool,
}
#[wasm_bindgen]
impl PptxDocument {
#[doc(hidden)]
#[wasm_bindgen(js_name = openReplayBaseline)]
pub fn open_replay_baseline(
source: &[u8],
client_id: Option<f64>,
initial_update: Option<Vec<u8>>,
) -> Result<PptxDocument, JsValue> {
let client_id = client_id
.map(|id| {
if !id.is_finite()
|| id.fract() != 0.0
|| id < 1.0
|| id > crate::MAX_SAFE_CLIENT_ID as f64
{
return Err(peer_error(crate::peer::PeerError::new(
"clientId",
"client id must be a positive safe integer below Number.MAX_SAFE_INTEGER",
)));
}
Ok(id as u64)
})
.transpose()?;
DeckSession::open_replay_baseline(source, client_id, initial_update.as_deref())
.map(Self::opened)
.map_err(peer_error)
}
#[doc(hidden)]
#[wasm_bindgen(js_name = peerHydrationJson)]
pub fn peer_hydration_json(&self, fonts_json: &str) -> Result<String, JsValue> {
let fonts = serde_json::from_str(fonts_json).map_err(|error| peer_error(error.into()))?;
self.session.peer_identity(fonts).map_err(peer_error)
}
#[doc(hidden)]
#[wasm_bindgen(js_name = openPeerDeckJson)]
pub fn open_peer_deck_json(
source: &[u8],
identity_json: &str,
initial_update: Option<Vec<u8>>,
) -> Result<PptxDocument, JsValue> {
DeckSession::open_peer_deck(source, identity_json, initial_update.as_deref())
.map(|session| Self {
session,
update_observer: None,
})
.map_err(peer_error)
}
#[doc(hidden)]
#[wasm_bindgen(js_name = registerPeerFontsJson)]
pub fn register_peer_fonts_json(&self, fonts_json: &str) -> Result<(), JsValue> {
let fonts = serde_json::from_str(fonts_json).map_err(|error| peer_error(error.into()))?;
self.session.register_peer_fonts(fonts).map_err(peer_error)
}
#[doc(hidden)]
#[wasm_bindgen(js_name = adoptPeerIdentity)]
pub fn adopt_peer_identity(&self) -> Result<(), JsValue> {
self.session.adopt_peer_identity().map_err(peer_error)
}
#[doc(hidden)]
#[wasm_bindgen(js_name = replayJson)]
pub fn replay_json(&self, envelope: &str) -> Result<String, JsValue> {
self.session.replay_json(envelope).map_err(peer_error)
}
#[wasm_bindgen(js_name = proposeJson)]
pub fn propose_json(&self, args: &str) -> Result<String, JsValue> {
json(
self.session
.propose(parse_args(args)?)
.map_err(proposal_error)?,
)
}
#[wasm_bindgen(js_name = listProposalsJson)]
pub fn list_proposals_json(&self) -> Result<String, JsValue> {
json(self.session.proposals().map_err(proposal_error)?)
}
#[wasm_bindgen(js_name = previewProposalJson)]
pub fn preview_proposal_json(&self, args: &str) -> Result<String, JsValue> {
let args: ProposalIdArgs = parse_args(args)?;
json(
self.session
.preview_proposal(&args.id)
.map_err(proposal_error)?,
)
}
#[wasm_bindgen(js_name = acceptProposalJson)]
pub fn accept_proposal_json(&self, args: &str) -> Result<String, JsValue> {
let args: ProposalIdArgs = parse_args(args)?;
json(
self.session
.accept_proposal(&args.id, args.force)
.map_err(proposal_error)?,
)
}
#[wasm_bindgen(js_name = rejectProposalJson)]
pub fn reject_proposal_json(&self, args: &str) -> Result<String, JsValue> {
let args: ProposalIdArgs = parse_args(args)?;
json(self.session.reject_proposal(&args.id))
}
#[wasm_bindgen(js_name = openCollaborative)]
pub fn open_collaborative(bytes: &[u8], client_id: f64) -> Result<PptxDocument, JsValue> {
let client_id = parse_client_id(client_id)?;
DeckSession::open(bytes, client_id)
.map(Self::opened)
.map_err(js_error)
}
#[wasm_bindgen(js_name = openCollaborativeFromUpdate)]
pub fn open_collaborative_from_update(
update: &[u8],
client_id: f64,
source: Option<Vec<u8>>,
) -> Result<PptxDocument, JsValue> {
let client_id = parse_client_id(client_id)?;
let session = DeckSession::open_from_update(update, client_id).map_err(js_error)?;
let session = match source {
Some(source) if session.seeded_from(&source).map_err(js_error)? => {
session.attach_source(&source).map_err(js_error)?
}
_ => session,
};
Ok(Self::opened(session))
}
#[wasm_bindgen(getter, js_name = clientId)]
pub fn client_id(&self) -> f64 {
self.session.client_id() as f64
}
#[wasm_bindgen(js_name = snapshotJson)]
pub fn snapshot_json(&self) -> Result<String, JsValue> {
json(self.session.snapshot().map_err(js_error)?)
}
#[wasm_bindgen(js_name = sessionMetadataJson)]
pub fn session_metadata_json(&self) -> Result<String, JsValue> {
json(self.session.session_metadata().map_err(js_error)?)
}
#[wasm_bindgen(js_name = documentVersion)]
pub fn document_version(&self) -> String {
self.session.version().to_string()
}
#[wasm_bindgen(js_name = readContentJson)]
pub fn read_content_json(&self, request: &str) -> Result<String, JsValue> {
if request.len() > MAX_REQUEST_BYTES {
return self.oversized();
}
let request: ReadRequest = parse_args(request)?;
outcome_json(&self.session.read_content(&request).map_err(js_error)?).map_err(js_error)
}
#[wasm_bindgen(js_name = readCommandContentJson)]
pub fn read_command_content_json(&self, request: &str) -> Result<String, JsValue> {
if request.len() > MAX_REQUEST_BYTES {
return self.oversized();
}
let request: ReadRequest = parse_args(request)?;
outcome_json(
&self
.session
.read_command_content(&request)
.map_err(js_error)?,
)
.map_err(js_error)
}
#[wasm_bindgen(js_name = findTextJson)]
pub fn find_text_json(&self, request: &str) -> Result<String, JsValue> {
if request.len() > MAX_REQUEST_BYTES {
return self.oversized();
}
let request: FindRequest = parse_args(request)?;
outcome_json(&self.session.find_text(&request).map_err(js_error)?).map_err(js_error)
}
#[wasm_bindgen(js_name = validateEditsJson)]
pub fn validate_edits_json(&self, request: &str) -> Result<String, JsValue> {
if request.len() > MAX_REQUEST_BYTES {
return self.oversized();
}
let request: EditRequest = serde_json::from_str(request).map_err(|error| {
let exception = js_sys::Error::new(&error.to_string());
exception.set_name("PptxEditDecodeError");
JsValue::from(exception)
})?;
outcome_json(&self.session.validate_edits(&request).map_err(js_error)?).map_err(js_error)
}
#[wasm_bindgen(js_name = applyEditsJson)]
pub fn apply_edits_json(&self, request: &str) -> Result<String, JsValue> {
if request.len() > MAX_REQUEST_BYTES {
return self.oversized();
}
let request: EditRequest = parse_args(request)?;
outcome_json(&self.session.apply_edits(&request).map_err(js_error)?).map_err(js_error)
}
#[wasm_bindgen(js_name = exportStructuredJson)]
pub fn export_structured_json(&self, options: &str) -> Result<String, JsValue> {
let options: PptxExportOptions = parse_args(options)?;
export_outcome_json(&self.session.export_structured(&options).map_err(js_error)?)
.map_err(js_error)
}
#[wasm_bindgen(js_name = exportMarkdownJson)]
pub fn export_markdown_json(&self, options: &str) -> Result<String, JsValue> {
let options: PptxExportOptions = parse_args(options)?;
export_outcome_json(&self.session.export_markdown(&options).map_err(js_error)?)
.map_err(js_error)
}
#[wasm_bindgen(js_name = searchTextJson)]
pub fn search_text_json(&self, args: &str) -> Result<String, JsValue> {
let args: SearchTextArgs = parse_args(args)?;
json(
self.session
.search_text(
&args.query,
args.case_sensitive,
args.limit.map(|value| value as usize),
)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = storyJson)]
pub fn story_json(&self, args: &str) -> Result<String, JsValue> {
let args: StoryArgs = parse_args(args)?;
json(self.session.story(&args.story_id).map_err(js_error)?)
}
#[wasm_bindgen(js_name = storyFormattingJson)]
pub fn story_formatting_json(&self, args: &str) -> Result<String, JsValue> {
let args: StoryFormattingArgs = parse_args(args)?;
json(
self.session
.story_formatting(&args.slide_id, &args.shape_id, &args.story_id)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = anchorCaretJson)]
pub fn anchor_caret_json(&self, args: &str) -> Result<String, JsValue> {
let args: AnchorCaretArgs = parse_args(args)?;
let anchor = self
.session
.anchor_caret(&args.story_id, args.index)
.map_err(js_error)?;
json(CaretAnchorJson {
position: base64::engine::general_purpose::STANDARD.encode(anchor.position.encode_v1()),
story_id: anchor.story_id,
})
}
#[wasm_bindgen(js_name = resolveCaretAnchorJson)]
pub fn resolve_caret_anchor_json(&self, args: &str) -> Result<String, JsValue> {
let args: CaretAnchorJson = parse_args(args)?;
let bytes = base64::engine::general_purpose::STANDARD
.decode(args.position)
.map_err(js_error)?;
let position = StickyIndex::decode_v1(&bytes).map_err(js_error)?;
json(self.session.resolve_caret_anchor(&CaretAnchor {
story_id: args.story_id,
position,
}))
}
#[wasm_bindgen(js_name = mediaBytes)]
pub fn media_bytes(&self, part_path: &str) -> Result<Vec<u8>, JsValue> {
self.session.media_bytes(part_path).map_err(js_error)
}
#[wasm_bindgen(js_name = saveBytes)]
pub fn save_bytes(&self) -> Result<Vec<u8>, JsValue> {
self.session.save().map_err(js_error)
}
#[wasm_bindgen(js_name = encodeStateVector)]
pub fn encode_state_vector(&self) -> Vec<u8> {
self.session.encode_state_vector_v1()
}
#[wasm_bindgen(js_name = encodeStateAsUpdate)]
pub fn encode_state_as_update(&self) -> Vec<u8> {
self.session.encode_state_as_update_v1()
}
#[wasm_bindgen(js_name = encodeDiff)]
pub fn encode_diff(&self, remote_state_vector: &[u8]) -> Result<Vec<u8>, JsValue> {
self.session
.encode_diff_v1(remote_state_vector)
.map_err(js_error)
}
#[wasm_bindgen(js_name = setCollaborative)]
pub fn set_collaborative(&self) -> Result<(), JsValue> {
self.session.set_collaborative().map_err(js_error)
}
#[wasm_bindgen(js_name = applyUpdateJson)]
pub fn apply_update_json(&self, update: &[u8]) -> Result<String, JsValue> {
json(self.session.apply_update_v1(update).map_err(js_error)?)
}
#[wasm_bindgen(js_name = startUpdateObservation)]
pub fn start_update_observation(&mut self) -> Result<(), JsValue> {
if self.update_observer.is_some() {
return Ok(());
}
let pending = Arc::new(Mutex::new(VecDeque::new()));
let observed = Arc::clone(&pending);
let subscription = self
.session
.observe_update_v1(move |event| {
observed
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.push_back(event);
})
.map_err(js_error)?;
self.update_observer = Some(UpdateObserver {
pending,
_subscription: subscription,
});
Ok(())
}
#[wasm_bindgen(js_name = clearUpdateObservation)]
pub fn clear_update_observation(&mut self) {
self.update_observer = None;
}
#[wasm_bindgen(js_name = drainUpdateEvent)]
pub fn drain_update_event(&self) -> Vec<u8> {
let Some(observer) = &self.update_observer else {
return Vec::new();
};
let event = observer
.pending
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.pop_front();
let Some(event) = event else {
return Vec::new();
};
let mut encoded = Vec::with_capacity(event.update.len() + 1);
encoded.push(match event.origin {
UpdateOrigin::Local => 0,
UpdateOrigin::Remote => 1,
});
encoded.extend_from_slice(&event.update);
encoded
}
#[wasm_bindgen(js_name = insertTextJson)]
pub fn insert_text_json(&self, args: &str) -> Result<String, JsValue> {
json(self.insert_text(parse_args(args)?)?)
}
#[wasm_bindgen(js_name = insertTextProfiledJson)]
pub fn insert_text_profiled_json(&self, args: &str) -> Result<String, JsValue> {
profiled(args, &mut performance_now, |args| self.insert_text(args))
}
#[wasm_bindgen(js_name = deleteTextJson)]
pub fn delete_text_json(&self, args: &str) -> Result<String, JsValue> {
json(self.delete_text(parse_args(args)?)?)
}
#[wasm_bindgen(js_name = deleteTextProfiledJson)]
pub fn delete_text_profiled_json(&self, args: &str) -> Result<String, JsValue> {
profiled(args, &mut performance_now, |args| self.delete_text(args))
}
#[wasm_bindgen(js_name = formatTextJson)]
pub fn format_text_json(&self, args: &str) -> Result<String, JsValue> {
let args: FormatTextArgs = parse_args(args)?;
json(
self.session
.format_text(
&local_context(),
&args.story_id,
args.start,
args.end,
&args.patch,
)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = setParagraphAlignmentJson)]
pub fn set_paragraph_alignment_json(&self, args: &str) -> Result<String, JsValue> {
let args: SetParagraphAlignmentArgs = parse_args(args)?;
json(
self.session
.set_paragraph_alignment(
&local_context(),
&args.story_id,
args.start,
args.end,
args.alignment.as_deref(),
)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = addCommentJson)]
pub fn add_comment_json(&self, args: &str) -> Result<String, JsValue> {
let args: AddCommentArgs = parse_args(args)?;
json(
self.session
.add_comment(
&local_context(),
&args.slide_id,
&args.author,
&args.initials,
&args.text,
&args.created,
args.x_emu,
args.y_emu,
)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = replyToCommentJson)]
pub fn reply_to_comment_json(&self, args: &str) -> Result<String, JsValue> {
let args: ReplyCommentArgs = parse_args(args)?;
json(
self.session
.reply_to_comment(
&local_context(),
&args.comment_id,
&args.author,
&args.initials,
&args.text,
&args.created,
)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = setCommentPositionJson)]
pub fn set_comment_position_json(&self, args: &str) -> Result<String, JsValue> {
let args: CommentPositionArgs = parse_args(args)?;
json(
self.session
.set_comment_position(&local_context(), &args.comment_id, args.x_emu, args.y_emu)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = setCommentStatusJson)]
pub fn set_comment_status_json(&self, args: &str) -> Result<String, JsValue> {
let args: CommentStatusArgs = parse_args(args)?;
json(
self.session
.set_comment_status(&local_context(), &args.comment_id, args.resolved)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = removeCommentJson)]
pub fn remove_comment_json(&self, args: &str) -> Result<String, JsValue> {
let args: CommentIdArgs = parse_args(args)?;
json(
self.session
.remove_comment(&local_context(), &args.comment_id)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = setCommentFlavorJson)]
pub fn set_comment_flavor_json(&self, args: &str) -> Result<String, JsValue> {
let args: CommentFlavorArgs = parse_args(args)?;
json(
self.session
.set_comment_flavor(&local_context(), args.flavor)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = commentsJson)]
pub fn comments_json(&self) -> Result<String, JsValue> {
json(self.session.comments().map_err(js_error)?)
}
#[wasm_bindgen(js_name = insertParagraphBreakJson)]
pub fn insert_paragraph_break_json(&self, args: &str) -> Result<String, JsValue> {
let args: ParagraphBreakArgs = parse_args(args)?;
json(
self.session
.insert_paragraph_break(&local_context(), &args.story_id, args.index)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = setStoryParagraphsJson)]
pub fn set_story_paragraphs_json(&self, args: &str) -> Result<String, JsValue> {
let args: SetStoryParagraphsArgs = parse_args(args)?;
json(
self.session
.set_story_paragraphs(&local_context(), &args.story_id, &args.paragraphs)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = insertTableRowJson)]
pub fn insert_table_row_json(&self, args: &str) -> Result<String, JsValue> {
let args: TableArgs = parse_args(args)?;
json(
self.session
.insert_table_row(
&local_context(),
&args.slide_id,
&args.shape_id,
args.index,
args.position
.ok_or_else(|| js_error("position must be before or after"))?,
&args.cells,
)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = checkTableEditJson)]
pub fn check_table_edit_json(&self, args: &str) -> Result<(), JsValue> {
let args: TableCheckArgs = parse_args(args)?;
let insert = matches!(
args.edit,
crate::TableEdit::InsertRow | crate::TableEdit::InsertColumn
);
let position = match args.position {
Some(position) => position,
None if insert => return Err(js_error("position must be before or after")),
None => crate::TableInsertPosition::After,
};
self.session
.check_table_edit_with_width(
args.edit,
&args.slide_id,
&args.shape_id,
args.index,
position,
&args.cells,
args.width,
)
.map_err(js_error)
}
#[wasm_bindgen(js_name = deleteTableRowJson)]
pub fn delete_table_row_json(&self, args: &str) -> Result<String, JsValue> {
let args: TableArgs = parse_args(args)?;
json(
self.session
.delete_table_row(&local_context(), &args.slide_id, &args.shape_id, args.index)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = insertTableColumnJson)]
pub fn insert_table_column_json(&self, args: &str) -> Result<String, JsValue> {
let args: TableArgs = parse_args(args)?;
json(
self.session
.insert_table_column_with_width(
&local_context(),
&args.slide_id,
&args.shape_id,
args.index,
args.position
.ok_or_else(|| js_error("position must be before or after"))?,
&args.cells,
args.width,
)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = deleteTableColumnJson)]
pub fn delete_table_column_json(&self, args: &str) -> Result<String, JsValue> {
let args: TableArgs = parse_args(args)?;
json(
self.session
.delete_table_column(&local_context(), &args.slide_id, &args.shape_id, args.index)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = insertSlideJson)]
pub fn insert_slide_json(&self, args: &str) -> Result<String, JsValue> {
json(self.insert_slide(parse_args(args)?)?)
}
#[wasm_bindgen(js_name = duplicateSlideJson)]
pub fn duplicate_slide_json(&self, args: &str) -> Result<String, JsValue> {
let args: MoveSlideArgs = parse_args(args)?;
json(
self.session
.duplicate_slide(&local_context(), &args.slide_id, args.to_index)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = insertSlideProfiledJson)]
pub fn insert_slide_profiled_json(&self, args: &str) -> Result<String, JsValue> {
profiled(args, &mut performance_now, |args| self.insert_slide(args))
}
#[wasm_bindgen(js_name = deleteSlideJson)]
pub fn delete_slide_json(&self, args: &str) -> Result<String, JsValue> {
let args: SlideArgs = parse_args(args)?;
json(
self.session
.delete_slide(&local_context(), &args.slide_id)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = setSlideLayoutJson)]
pub fn set_slide_layout_json(&self, args: &str) -> Result<String, JsValue> {
let args: SetSlideLayoutArgs = parse_args(args)?;
self.session
.set_slide_layout(
&local_context(),
&args.slide_id,
&args.layout_part_path,
&args.mapping,
)
.map_err(js_error)?;
json(())
}
#[wasm_bindgen(js_name = setSlideNotesJson)]
pub fn set_slide_notes_json(&self, args: &str) -> Result<String, JsValue> {
let args: SetSlideNotesArgs = parse_args(args)?;
self.session
.set_slide_notes(&local_context(), &args.slide_id, &args.text)
.map_err(js_error)?;
json(())
}
#[wasm_bindgen(js_name = moveSlideJson)]
pub fn move_slide_json(&self, args: &str) -> Result<String, JsValue> {
let args: MoveSlideArgs = parse_args(args)?;
json(
self.session
.move_slide(&local_context(), &args.slide_id, args.to_index)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = addTextBoxJson)]
pub fn add_text_box_json(&self, args: &str) -> Result<String, JsValue> {
json(self.add_text_box(parse_args(args)?)?)
}
#[wasm_bindgen(js_name = addTextBoxProfiledJson)]
pub fn add_text_box_profiled_json(&self, args: &str) -> Result<String, JsValue> {
profiled(args, &mut performance_now, |args| self.add_text_box(args))
}
#[wasm_bindgen(js_name = addShapeJson)]
pub fn add_shape_json(&self, args: &str) -> Result<String, JsValue> {
let args: AddShapeArgs = parse_args(args)?;
json(
self.session
.add_shape(&local_context(), &args.slide_id, &args.draft)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = addPictureJson)]
pub fn add_picture_json(&self, args: &str) -> Result<String, JsValue> {
let args: AddPictureArgs = parse_args(args)?;
let media_bytes = base64::engine::general_purpose::STANDARD
.decode(&args.media_base64)
.map_err(|error| js_error(format!("invalid image data: {error}")))?;
let draft = PictureDraft {
name: args.name,
rect: args.rect,
content_type: args.content_type,
media_bytes,
};
json(
self.session
.add_picture(&local_context(), &args.slide_id, &draft)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = removeShapeJson)]
pub fn remove_shape_json(&self, args: &str) -> Result<String, JsValue> {
let args: ShapeArgs = parse_args(args)?;
json(
self.session
.remove_shape(&local_context(), &args.slide_id, &args.shape_id)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = bringShapeToFrontJson)]
pub fn bring_shape_to_front_json(&self, args: &str) -> Result<String, JsValue> {
let args: ShapeArgs = parse_args(args)?;
json(
self.session
.bring_to_front(&local_context(), &args.slide_id, &args.shape_id)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = sendShapeToBackJson)]
pub fn send_shape_to_back_json(&self, args: &str) -> Result<String, JsValue> {
let args: ShapeArgs = parse_args(args)?;
json(
self.session
.send_to_back(&local_context(), &args.slide_id, &args.shape_id)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = bringShapeForwardJson)]
pub fn bring_shape_forward_json(&self, args: &str) -> Result<String, JsValue> {
let args: ShapeArgs = parse_args(args)?;
json(
self.session
.bring_forward(&local_context(), &args.slide_id, &args.shape_id)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = sendShapeBackwardJson)]
pub fn send_shape_backward_json(&self, args: &str) -> Result<String, JsValue> {
let args: ShapeArgs = parse_args(args)?;
json(
self.session
.send_backward(&local_context(), &args.slide_id, &args.shape_id)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = moveShapeJson)]
pub fn move_shape_json(&self, args: &str) -> Result<String, JsValue> {
json(self.move_shape(parse_args(args)?)?)
}
#[wasm_bindgen(js_name = moveShapeProfiledJson)]
pub fn move_shape_profiled_json(&self, args: &str) -> Result<String, JsValue> {
profiled(args, &mut performance_now, |args| self.move_shape(args))
}
#[wasm_bindgen(js_name = resizeShapeJson)]
pub fn resize_shape_json(&self, args: &str) -> Result<String, JsValue> {
let args: ResizeShapeArgs = parse_args(args)?;
json(
self.session
.resize_shape(
&local_context(),
&args.slide_id,
&args.shape_id,
args.width,
args.height,
)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = setShapeRectJson)]
pub fn set_shape_rect_json(&self, args: &str) -> Result<String, JsValue> {
let args: SetShapeRectArgs = parse_args(args)?;
json(
self.session
.set_shape_rect(&local_context(), &args.slide_id, &args.shape_id, args.rect)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = setShapeFillJson)]
pub fn set_shape_fill_json(&self, args: &str) -> Result<String, JsValue> {
let args: SetShapeFillArgs = parse_args(args)?;
json(
self.session
.set_shape_fill(
&local_context(),
&args.slide_id,
&args.shape_id,
args.color.as_deref(),
)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = setShapeStrokeJson)]
pub fn set_shape_stroke_json(&self, args: &str) -> Result<String, JsValue> {
let args: SetShapeStrokeArgs = parse_args(args)?;
json(
self.session
.set_shape_stroke(
&local_context(),
&args.slide_id,
&args.shape_id,
&args.stroke,
)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = setShapeAdjustJson)]
pub fn set_shape_adjust_json(&self, args: &str) -> Result<String, JsValue> {
let args: SetShapeAdjustArgs = parse_args(args)?;
json(
self.session
.set_shape_adjust(
&local_context(),
&args.slide_id,
&args.shape_id,
&args.adjustments,
)
.map_err(js_error)?,
)
}
#[wasm_bindgen(js_name = undoCaptureMode)]
pub fn undo_capture_mode(&self) -> String {
match self.session.undo_capture_mode() {
crate::UndoCaptureMode::Auto => "auto",
crate::UndoCaptureMode::Manual => "manual",
}
.to_owned()
}
#[wasm_bindgen(js_name = setUndoCaptureMode)]
pub fn set_undo_capture_mode(&self, mode: &str) -> Result<(), JsValue> {
let mode = match mode {
"auto" => crate::UndoCaptureMode::Auto,
"manual" => crate::UndoCaptureMode::Manual,
_ => return Err(js_error("undo capture mode must be auto or manual")),
};
self.session.set_undo_capture_mode(mode);
Ok(())
}
#[wasm_bindgen(js_name = addUndoBoundary)]
pub fn add_undo_boundary(&self) {
self.session.add_undo_barrier();
}
#[wasm_bindgen(js_name = undoJson)]
pub fn undo_json(&self) -> Result<String, JsValue> {
json(HistoryResult {
applied: self.session.undo(),
snapshot: self.session.snapshot().map_err(js_error)?,
})
}
#[wasm_bindgen(js_name = undoReceiptJson)]
pub fn undo_receipt_json(&self) -> Result<String, JsValue> {
json(self.session.history_step(false).map_err(js_error)?)
}
#[wasm_bindgen(js_name = redoReceiptJson)]
pub fn redo_receipt_json(&self) -> Result<String, JsValue> {
json(self.session.history_step(true).map_err(js_error)?)
}
#[wasm_bindgen(js_name = undoProfiledJson)]
pub fn undo_profiled_json(&self) -> Result<String, JsValue> {
let started = performance_now();
let applied = self.session.undo();
let undone = performance_now();
let snapshot = self.session.snapshot().map_err(js_error)?;
let snapshotted = performance_now();
let receipt = json(HistoryResult { applied, snapshot })?;
let serialized = performance_now();
let profile = json(HistoryProfile {
undo_ms: undone - started,
snapshot_ms: snapshotted - undone,
serialize_ms: serialized - snapshotted,
})?;
Ok(format!("{{\"receipt\":{receipt},\"profile\":{profile}}}"))
}
#[wasm_bindgen(js_name = redoJson)]
pub fn redo_json(&self) -> Result<String, JsValue> {
json(HistoryResult {
applied: self.session.redo(),
snapshot: self.session.snapshot().map_err(js_error)?,
})
}
#[wasm_bindgen(js_name = canUndo)]
pub fn can_undo(&self) -> bool {
self.session.can_undo()
}
#[wasm_bindgen(js_name = canRedo)]
pub fn can_redo(&self) -> bool {
self.session.can_redo()
}
pub fn version() -> String {
env!("CARGO_PKG_VERSION").to_owned()
}
}
impl PptxDocument {
fn oversized(&self) -> Result<String, JsValue> {
outcome_json::<()>(&Err(oversized_request(self.session.version()))).map_err(js_error)
}
fn opened(session: DeckSession) -> Self {
session.rotate_version(js_entropy());
Self {
session,
update_observer: None,
}
}
pub fn session(&self) -> &DeckSession {
&self.session
}
fn insert_text(&self, args: InsertTextArgs) -> Result<TextReceipt, JsValue> {
self.session
.insert_text(
&local_context(),
&args.story_id,
args.index,
&args.text,
&args.style,
)
.map_err(js_error)
}
fn delete_text(&self, args: DeleteTextArgs) -> Result<TextReceipt, JsValue> {
if args.allow_paragraph_join {
return self
.session
.delete_text_joining_paragraphs(
&local_context(),
&args.story_id,
args.start,
args.end,
)
.map_err(js_error);
}
self.session
.delete_text(&local_context(), &args.story_id, args.start, args.end)
.map_err(js_error)
}
fn insert_slide(&self, args: InsertSlideArgs) -> Result<SlideReceipt, JsValue> {
self.session
.insert_slide(
&local_context(),
args.index,
args.layout_part_path.as_deref(),
)
.map_err(js_error)
}
fn add_text_box(&self, args: AddTextBoxArgs) -> Result<ShapeReceipt, JsValue> {
self.session
.add_text_box(&local_context(), &args.slide_id, &args.draft)
.map_err(js_error)
}
fn move_shape(&self, args: MoveShapeArgs) -> Result<TransformReceipt, JsValue> {
self.session
.move_shape(
&local_context(),
&args.slide_id,
&args.shape_id,
args.x,
args.y,
)
.map_err(js_error)
}
}
fn profiled<A: serde::de::DeserializeOwned, R: Serialize>(
args: &str,
now: &mut impl FnMut() -> f64,
apply: impl FnOnce(A) -> Result<R, JsValue>,
) -> Result<String, JsValue> {
let started = now();
let args = parse_args(args)?;
let parsed = now();
let receipt = apply(args)?;
let applied = now();
let receipt = json(receipt)?;
let serialized = now();
let profile = json(EditProfile {
parse_ms: parsed - started,
apply_ms: applied - parsed,
serialize_ms: serialized - applied,
})?;
Ok(format!("{{\"receipt\":{receipt},\"profile\":{profile}}}"))
}
fn local_context() -> EditCtx {
EditCtx::local("wasm")
}
fn js_entropy() -> u64 {
#[cfg(target_arch = "wasm32")]
{
((js_sys::Math::random() * 9_007_199_254_740_992.0) as u64)
^ (js_sys::Date::now() as u64).rotate_left(21)
}
#[cfg(not(target_arch = "wasm32"))]
{
0
}
}
fn parse_args<T: serde::de::DeserializeOwned>(args: &str) -> Result<T, JsValue> {
serde_json::from_str(args).map_err(js_error)
}
fn json(value: impl Serialize) -> Result<String, JsValue> {
serde_json::to_string(&value).map_err(js_error)
}
fn parse_client_id(client_id: f64) -> Result<u64, JsValue> {
if !client_id.is_finite()
|| client_id.fract() != 0.0
|| client_id < 1.0
|| client_id > super::MAX_SAFE_CLIENT_ID as f64
{
return Err(JsValue::from_str(
"client ID must be a positive safe integer below Number.MAX_SAFE_INTEGER",
));
}
Ok(client_id as u64)
}
fn js_error(error: impl std::fmt::Display) -> JsValue {
JsValue::from_str(&error.to_string())
}
fn peer_error(error: crate::peer::PeerError) -> JsValue {
let exception = js_sys::Error::new(&error.message);
exception.set_name("PresentationPeerError");
let _ = js_sys::Reflect::set(
exception.as_ref(),
&JsValue::from_str("code"),
&JsValue::from_str(&error.code),
);
exception.into()
}
fn proposal_error(error: crate::ProposalError) -> JsValue {
match error {
crate::ProposalError::Stale(targets) => JsValue::from_str(
&serde_json::json!({
"code": "staleProposal", "targets": targets,
})
.to_string(),
),
other => js_error(other),
}
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use super::PptxDocument;
const DECK: &[u8] = include_bytes!("../../../apps/demo/public/betteroffice-demo.pptx");
fn envelope(json: &str) -> Value {
serde_json::from_str(json).unwrap()
}
#[test]
fn batch_envelopes_default_and_refuse_as_data() {
let document = PptxDocument::open_collaborative(DECK, 91.0).unwrap();
let version = document.document_version();
let read = envelope(&document.read_content_json("{}").unwrap());
assert_eq!(read["ok"], true);
assert_eq!(read["version"], version.as_str());
let story = &read["stories"][0];
let target = json!({
"kind": "range", "slideId": story["slideId"], "shapeId": story["shapeId"],
"storyId": story["storyId"], "start": 0, "end": 0,
});
let request = json!({
"expectVersion": version,
"steps": [{"op": "insertText", "target": target, "at": "start", "text": "Draft: "}],
})
.to_string();
let validated = envelope(&document.validate_edits_json(&request).unwrap());
assert_eq!(validated["ok"], true);
assert_eq!(validated["wouldApply"], true);
assert_eq!(document.document_version(), version);
let applied = envelope(&document.apply_edits_json(&request).unwrap());
assert_eq!(applied["ok"], true);
assert_eq!(applied["applied"], true);
assert_eq!(applied["source"], "host");
assert_eq!(applied["baseVersion"], version.as_str());
assert_eq!(applied["version"], document.document_version().as_str());
assert_eq!(applied["receipts"][0]["target"]["end"], 7);
let stale = envelope(&document.apply_edits_json(&request).unwrap());
assert_eq!(stale["ok"], false);
assert_eq!(stale["failure"]["code"], "stale-version");
assert_eq!(stale["version"], document.document_version().as_str());
assert!(document.can_undo());
let found = envelope(
&document
.find_text_json(r#"{"text":"Draft: ","limit":1}"#)
.unwrap(),
);
assert_eq!(found["ok"], true);
assert_eq!(found["matches"][0]["range"]["start"], 0);
let empty = envelope(&document.find_text_json(r#"{"text":""}"#).unwrap());
assert_eq!(empty["failure"]["code"], "invalid-step");
let oversized = " ".repeat(crate::MAX_REQUEST_BYTES + 1);
let refused = envelope(&document.apply_edits_json(&oversized).unwrap());
assert_eq!(refused["ok"], false);
assert_eq!(refused["failure"]["code"], "limit-exceeded");
assert_eq!(refused["version"], document.document_version().as_str());
}
#[test]
fn export_envelopes_carry_the_version_or_a_refusal() {
let document = PptxDocument::open_collaborative(DECK, 94.0).unwrap();
let version = document.document_version();
let read = envelope(&document.export_structured_json("{}").unwrap());
assert_eq!(read["ok"], true);
assert_eq!(read["version"], version.as_str());
assert_eq!(read["content"]["schemaVersion"], 1);
assert_eq!(read["content"]["anchorScope"], "session");
assert_eq!(read["content"]["readingOrder"], "shapeTree");
let markdown = envelope(&document.export_markdown_json("{}").unwrap());
assert_eq!(markdown["ok"], true);
assert!(
markdown["content"]["markdown"]
.as_str()
.unwrap()
.starts_with("<!-- pptx-export:0 -->")
);
let refused = envelope(
&document
.export_structured_json(r#"{"maxBytes":8}"#)
.unwrap(),
);
assert_eq!(refused["ok"], false);
assert_eq!(refused["version"], version.as_str());
assert_eq!(refused["failure"]["code"], "invalid-options");
assert!(
refused["failure"]
.as_object()
.unwrap()
.get("target")
.is_some_and(Value::is_null)
);
assert_eq!(document.document_version(), version);
}
#[test]
fn documents_opened_from_the_same_bytes_never_share_versions() {
let first = PptxDocument::open_collaborative(DECK, 92.0).unwrap();
let second = PptxDocument::open_collaborative(DECK, 92.0).unwrap();
assert_ne!(first.document_version(), second.document_version());
let joined = PptxDocument::open_collaborative_from_update(
&first.encode_state_as_update(),
93.0,
None,
)
.unwrap();
assert_ne!(joined.document_version(), first.document_version());
}
}