use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::Arc;
use ooxml_drawingml::{
ColorValue, ShapeFill, ShapeOutline, Theme, preset_geometry_default_adjustments,
preset_geometry_to_path, resolve_color_value_to_hex, resolve_color_value_to_hex_with_theme,
};
use pptx_parse::{ChartAxis, ChartSpace, PptxPackage, ShapeBase, ShapeNode, Slide};
use serde::de::DeserializeOwned;
use yrs::{
Any, Array, ArrayPrelim, ArrayRef, Doc, Map, MapPrelim, MapRef, Out, ReadTxn, TextRef,
Transact, TransactionMut, WriteTxn,
};
use crate::comments::{flavor_key, seed_comments, snapshot_comments, snapshot_flavor};
use crate::story::{seed_plain_story, seed_story, snapshot_story, validate_story};
use crate::{
DeckSession, DeckSnapshot, EditCtx, EditError, EditResult, META, MIGRATE_ORIGIN,
PresetShapeDraft, SHAPES, SLIDE_ORDER, SLIDES, STORIES, ShapeAdjustReceipt, ShapeDraft,
ShapeFillReceipt, ShapeKind, ShapeReceipt, ShapeRect, ShapeSnapshot, ShapeStroke,
ShapeStrokeReceipt, SlideReceipt, SlideSnapshot, TransformReceipt,
};
const SCHEMA_VERSION: f64 = 2.1;
const MIGRATABLE_SCHEMA_VERSIONS: [f64; 3] = [1.0, 2.0, SCHEMA_VERSION];
const MAX_GEOMETRY: i64 = 1_000_000_000_000_000;
const MAX_SHAPE_DEPTH: usize = 128;
const EMU_PER_POINT: f64 = 12_700.0;
const MAX_ADJUSTMENTS: usize = 32;
const MAX_ADJUSTMENT_INDEX: usize = 32;
pub(crate) fn seed_doc(doc: &Doc, package: &PptxPackage, fingerprint: &str) -> EditResult<()> {
let package_json =
serde_json::to_vec(package).map_err(|error| EditError::Json(error.to_string()))?;
let mut txn = doc.transact_mut_with("pptx:bootstrap");
let meta = txn.get_or_insert_map(META);
meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION);
meta.insert(&mut txn, "fingerprint", fingerprint);
meta.insert(&mut txn, "widthEmu", package.presentation.width_emu as f64);
meta.insert(
&mut txn,
"heightEmu",
package.presentation.height_emu as f64,
);
meta.insert(
&mut txn,
"packageJson",
Any::Buffer(Arc::from(package_json)),
);
meta.insert(
&mut txn,
"commentFlavor",
flavor_key(package.comment_flavor.unwrap_or_default()),
);
let order = txn.get_or_insert_array(SLIDE_ORDER);
let slides = txn.get_or_insert_map(SLIDES);
let shapes = txn.get_or_insert_map(SHAPES);
let stories = txn.get_or_insert_map(STORIES);
let mut slide_id_by_part: HashMap<String, String> = HashMap::new();
for (slide_index, slide) in package.slides.iter().enumerate() {
let theme = slide_theme(package, slide);
let slide_id = seeded_slide_id(slide_index, package.presentation.slides[slide_index].id);
order.push_back(&mut txn, slide_id.as_str());
let slide_map = slides.insert(&mut txn, slide_id.as_str(), MapPrelim::default());
slide_map.insert(&mut txn, "id", slide_id.as_str());
slide_map.insert(&mut txn, "sourcePartPath", slide.part_path.as_str());
if let Some(layout) = &slide.layout_part_path {
slide_map.insert(&mut txn, "layoutPartPath", layout.as_str());
}
if let Some(name) = &slide.name {
slide_map.insert(&mut txn, "name", name.as_str());
}
if !slide.notes.is_empty() {
slide_map.insert(&mut txn, "notes", slide.notes.as_str());
}
let shape_order = slide_map.insert(&mut txn, "shapes", ArrayPrelim::default());
for (shape_index, shape) in slide.shapes.iter().enumerate() {
let shape_id = seed_shape(
&shapes,
&stories,
&mut txn,
&slide_id,
&shape_index.to_string(),
shape,
theme,
)?;
shape_order.push_back(&mut txn, shape_id.as_str());
}
slide_id_by_part.insert(slide.part_path.clone(), slide_id);
}
seed_comments(&mut txn, package, &|part| {
slide_id_by_part.get(part).cloned()
})?;
Ok(())
}
fn seed_shape(
shapes: &MapRef,
stories: &MapRef,
txn: &mut TransactionMut<'_>,
slide_id: &str,
path: &str,
shape: &ShapeNode,
theme: Option<&Theme>,
) -> EditResult<String> {
let shape_id = seeded_shape_id(slide_id, path);
let shape_map = shapes.insert(txn, shape_id.as_str(), MapPrelim::default());
let base = shape_base(shape);
shape_map.insert(txn, "id", shape_id.as_str());
shape_map.insert(txn, "sourceId", base.id as f64);
shape_map.insert(txn, "name", base.name.as_str());
shape_map.insert(txn, "x", base.transform.x as f64);
shape_map.insert(txn, "y", base.transform.y as f64);
shape_map.insert(txn, "width", base.transform.width as f64);
shape_map.insert(txn, "height", base.transform.height as f64);
shape_map.insert(txn, "rotationDeg", base.transform.rotation_deg);
shape_map.insert(txn, "flipH", base.transform.flip_h);
shape_map.insert(txn, "flipV", base.transform.flip_v);
if base.hidden {
shape_map.insert(txn, "hidden", true);
}
insert_json(
&shape_map,
txn,
"placeholderJson",
base.placeholder.as_ref(),
)?;
let mut text_story_ids = Vec::new();
let mut child_ids = Vec::new();
match shape {
ShapeNode::Shape(shape) => {
shape_map.insert(txn, "kind", "shape");
shape_map.insert(txn, "geometry", shape.geometry.as_str());
if !shape.has_preset_geometry {
shape_map.insert(txn, "hasPresetGeometry", false);
}
let mut adjust_values = preset_geometry_default_adjustments(&shape.geometry)
.into_iter()
.collect::<BTreeMap<_, _>>();
adjust_values.extend(shape.adjust_values.clone());
insert_json(&shape_map, txn, "adjustValuesJson", Some(&adjust_values))?;
insert_json(&shape_map, txn, "fillJson", shape.fill.as_ref())?;
insert_json(&shape_map, txn, "outlineJson", shape.outline.as_ref())?;
if let Some(body) = &shape.text {
let story_id = format!("story:{shape_id}:0");
seed_story(stories, txn, &story_id, body, theme)?;
text_story_ids.push(story_id);
}
}
ShapeNode::Picture(picture) => {
shape_map.insert(txn, "kind", "picture");
shape_map.insert(txn, "geometry", "rect");
insert_json(&shape_map, txn, "fillJson", picture.fill.as_ref())?;
insert_json(&shape_map, txn, "outlineJson", picture.outline.as_ref())?;
if !picture.effects.is_empty() {
insert_json(&shape_map, txn, "blipEffectsJson", Some(&picture.effects))?;
}
if let Some(media) = &picture.media_part_path {
shape_map.insert(txn, "mediaPartPath", media.as_str());
}
}
ShapeNode::GraphicFrame(frame) => {
shape_map.insert(txn, "kind", "graphicFrame");
shape_map.insert(txn, "geometry", "rect");
insert_json(&shape_map, txn, "graphicJson", Some(&frame.data))?;
if let pptx_parse::GraphicFrameData::Table(table) = &frame.data {
for (row_index, row) in table.rows.iter().enumerate() {
for (cell_index, cell) in row.cells.iter().enumerate() {
let story_id = format!("story:{shape_id}:table:{row_index}:{cell_index}");
seed_story(stories, txn, &story_id, &cell.text, theme)?;
text_story_ids.push(story_id);
}
}
}
}
ShapeNode::Group(group) => {
shape_map.insert(txn, "kind", "group");
shape_map.insert(txn, "geometry", "group");
for (child_index, child) in group.children.iter().enumerate() {
child_ids.push(seed_shape(
shapes,
stories,
txn,
slide_id,
&seeded_child_path(path, child_index),
child,
theme,
)?);
}
}
}
shape_map.insert(txn, "textStories", string_array(&text_story_ids));
shape_map.insert(txn, "children", string_array(&child_ids));
Ok(shape_id)
}
fn seeded_slide_id(slide_index: usize, reference_id: u32) -> String {
format!("slide:{slide_index}:{reference_id}")
}
fn seeded_shape_id(slide_id: &str, path: &str) -> String {
format!("{slide_id}:shape:{path}")
}
fn seeded_child_path(path: &str, child_index: usize) -> String {
format!("{path}.{child_index}")
}
pub(crate) fn shape_base(shape: &ShapeNode) -> &ShapeBase {
match shape {
ShapeNode::Shape(shape) => &shape.base,
ShapeNode::Picture(shape) => &shape.base,
ShapeNode::GraphicFrame(shape) => &shape.base,
ShapeNode::Group(shape) => &shape.base,
}
}
fn slide_theme<'a>(package: &'a PptxPackage, slide: &Slide) -> Option<&'a Theme> {
theme_for_layout(package, slide.layout_part_path.as_deref())
}
fn theme_for_layout<'a>(
package: &'a PptxPackage,
layout_part_path: Option<&str>,
) -> Option<&'a Theme> {
let layout = layout_part_path
.and_then(|path| {
package
.layouts
.iter()
.find(|layout| layout.part_path == path)
})
.or_else(|| package.layouts.first());
let master = layout
.and_then(|layout| layout.master_part_path.as_deref())
.and_then(|path| {
package
.masters
.iter()
.find(|master| master.part_path == path)
})
.or_else(|| {
layout.and_then(|layout| {
package.masters.iter().find(|master| {
master
.layout_part_paths
.iter()
.any(|path| path == &layout.part_path)
})
})
})
.or_else(|| package.masters.first());
master
.and_then(|master| master.theme_part_path.as_deref())
.and_then(|path| package.themes.iter().find(|theme| theme.part_path == path))
.map(|part| &part.theme)
.or_else(|| package.themes.first().map(|part| &part.theme))
}
fn insert_json<T: serde::Serialize>(
map: &MapRef,
txn: &mut TransactionMut<'_>,
key: &str,
value: Option<&T>,
) -> EditResult<()> {
if let Some(value) = value {
let json =
serde_json::to_string(value).map_err(|error| EditError::Json(error.to_string()))?;
map.insert(txn, key, json);
}
Ok(())
}
impl DeckSession {
pub fn snapshot(&self) -> EditResult<DeckSnapshot> {
snapshot_doc(&self.doc, &self.package)
}
pub fn insert_slide(
&self,
context: &EditCtx,
index: u32,
layout_part_path: Option<&str>,
) -> EditResult<SlideReceipt> {
if let Some(path) = layout_part_path
&& !self
.package
.layouts
.iter()
.any(|layout| layout.part_path == path)
{
return Err(EditError::InvalidState(format!(
"unknown slide layout {path:?}"
)));
}
let slide_id = self.next_id("slide");
let mut txn = self.transact_for(context);
let order = required_order(&txn)?;
let length = order.len(&txn);
if index > length {
return Err(EditError::OutOfBounds { index, length });
}
let slides = required_map(&txn, SLIDES)?;
let slide = slides.insert(&mut txn, slide_id.as_str(), MapPrelim::default());
slide.insert(&mut txn, "id", slide_id.as_str());
slide.insert(&mut txn, "name", format!("Slide {}", length + 1));
if let Some(layout_part_path) = layout_part_path {
slide.insert(&mut txn, "layoutPartPath", layout_part_path);
}
slide.insert(&mut txn, "shapes", ArrayPrelim::default());
order.insert(&mut txn, index, slide_id.as_str());
Ok(SlideReceipt {
slide_id,
from_index: None,
to_index: Some(index),
})
}
pub fn set_slide_notes(&self, context: &EditCtx, slide_id: &str, text: &str) -> EditResult<()> {
crate::model::validate_xml_text(text)?;
self.add_undo_barrier();
let mut txn = self.transact_for(context);
let slide = slide_ref(&txn, slide_id)?;
slide.insert(&mut txn, "notes", text);
drop(txn);
self.add_undo_barrier();
Ok(())
}
pub fn delete_slide(&self, context: &EditCtx, slide_id: &str) -> EditResult<SlideReceipt> {
let mut txn = self.transact_for(context);
let order = required_order(&txn)?;
let index = array_index(&order, &txn, slide_id)
.ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))?;
let slides = required_map(&txn, SLIDES)?;
let slide = slide_ref(&txn, slide_id)?;
let shape_order = slide_shape_order(&slide, &txn)?;
let shape_ids = string_array_ref(&shape_order, &txn);
remove_shape_entries(&mut txn, &shape_ids)?;
let comments = required_map(&txn, crate::COMMENTS)?;
let comment_ids: Vec<String> = comments
.iter(&txn)
.filter_map(|(id, value)| {
let entry = value.cast::<MapRef>().ok()?;
(map_string(&entry, &txn, "slideId").as_deref() == Some(slide_id))
.then(|| id.to_owned())
})
.collect();
for id in comment_ids {
comments.remove(&mut txn, &id);
}
order.remove(&mut txn, index);
slides.remove(&mut txn, slide_id);
Ok(SlideReceipt {
slide_id: slide_id.to_owned(),
from_index: Some(index),
to_index: None,
})
}
pub fn move_slide(
&self,
context: &EditCtx,
slide_id: &str,
to_index: u32,
) -> EditResult<SlideReceipt> {
let mut txn = self.transact_for(context);
let order = required_order(&txn)?;
let length = order.len(&txn);
if to_index >= length {
return Err(EditError::OutOfBounds {
index: to_index,
length,
});
}
let from_index = array_index(&order, &txn, slide_id)
.ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))?;
if from_index != to_index {
order.remove(&mut txn, from_index);
order.insert(&mut txn, to_index, slide_id);
}
Ok(SlideReceipt {
slide_id: slide_id.to_owned(),
from_index: Some(from_index),
to_index: Some(to_index),
})
}
pub fn add_text_box(
&self,
context: &EditCtx,
slide_id: &str,
draft: &ShapeDraft,
) -> EditResult<ShapeReceipt> {
validate_rect(draft.rect)?;
crate::model::validate_xml_text(&draft.name)?;
crate::model::validate_xml_text(&draft.text)?;
crate::story::validate_style_values(
draft.style.font_family.as_deref(),
draft.style.underline.as_deref(),
draft.style.color.as_deref(),
draft.style.font_size_pt,
draft.style.spacing_pt,
draft.style.baseline_pct,
)?;
let shape_id = self.next_id("shape");
let story_id = format!("story:{shape_id}:0");
let paragraph_id = self.next_id("para");
let mut txn = self.transact_for(context);
let slide = slide_ref(&txn, slide_id)?;
let order = slide_shape_order(&slide, &txn)?;
let index = order.len(&txn);
let shapes = required_map(&txn, SHAPES)?;
let stories = required_map(&txn, STORIES)?;
seed_plain_story(
&stories,
&mut txn,
&story_id,
¶graph_id,
&draft.text,
&draft.style,
);
let shape = shapes.insert(&mut txn, shape_id.as_str(), MapPrelim::default());
shape.insert(&mut txn, "id", shape_id.as_str());
shape.insert(&mut txn, "sourceId", 0_f64);
shape.insert(&mut txn, "kind", "shape");
shape.insert(&mut txn, "name", draft.name.as_str());
shape.insert(&mut txn, "x", draft.rect.x as f64);
shape.insert(&mut txn, "y", draft.rect.y as f64);
shape.insert(&mut txn, "width", draft.rect.width as f64);
shape.insert(&mut txn, "height", draft.rect.height as f64);
shape.insert(&mut txn, "rotationDeg", 0_f64);
shape.insert(&mut txn, "flipH", false);
shape.insert(&mut txn, "flipV", false);
shape.insert(&mut txn, "geometry", "rect");
insert_json(
&shape,
&mut txn,
"fillJson",
Some(&ShapeFill::named("none")),
)?;
shape.insert(
&mut txn,
"textStories",
string_array(std::slice::from_ref(&story_id)),
);
shape.insert(&mut txn, "children", string_array(&[]));
order.push_back(&mut txn, shape_id.as_str());
Ok(ShapeReceipt {
slide_id: slide_id.to_owned(),
shape_id,
index,
})
}
pub fn add_shape(
&self,
context: &EditCtx,
slide_id: &str,
draft: &PresetShapeDraft,
) -> EditResult<ShapeReceipt> {
validate_rect(draft.rect)?;
crate::model::validate_xml_text(&draft.name)?;
let aspect_ratio = draft.rect.width as f64 / draft.rect.height as f64;
if preset_geometry_to_path(&draft.geometry, &Default::default(), aspect_ratio).is_none() {
return Err(EditError::InvalidGeometry(format!(
"unsupported preset geometry {}",
draft.geometry
)));
}
let fill = shape_fill(draft.fill.as_deref())?;
let adjust_values = preset_geometry_default_adjustments(&draft.geometry)
.into_iter()
.collect::<BTreeMap<_, _>>();
let shape_id = self.next_id("shape");
let mut txn = self.transact_for(context);
let slide = slide_ref(&txn, slide_id)?;
let order = slide_shape_order(&slide, &txn)?;
let index = order.len(&txn);
let shapes = required_map(&txn, SHAPES)?;
let shape = shapes.insert(&mut txn, shape_id.as_str(), MapPrelim::default());
shape.insert(&mut txn, "id", shape_id.as_str());
shape.insert(&mut txn, "sourceId", 0_f64);
shape.insert(&mut txn, "kind", "shape");
shape.insert(&mut txn, "name", draft.name.as_str());
shape.insert(&mut txn, "x", draft.rect.x as f64);
shape.insert(&mut txn, "y", draft.rect.y as f64);
shape.insert(&mut txn, "width", draft.rect.width as f64);
shape.insert(&mut txn, "height", draft.rect.height as f64);
shape.insert(&mut txn, "rotationDeg", 0_f64);
shape.insert(&mut txn, "flipH", false);
shape.insert(&mut txn, "flipV", false);
shape.insert(&mut txn, "geometry", draft.geometry.as_str());
insert_json(&shape, &mut txn, "adjustValuesJson", Some(&adjust_values))?;
insert_json(&shape, &mut txn, "fillJson", Some(&fill))?;
shape.insert(&mut txn, "textStories", string_array(&[]));
shape.insert(&mut txn, "children", string_array(&[]));
order.push_back(&mut txn, shape_id.as_str());
Ok(ShapeReceipt {
slide_id: slide_id.to_owned(),
shape_id,
index,
})
}
pub fn set_shape_fill(
&self,
context: &EditCtx,
slide_id: &str,
shape_id: &str,
color: Option<&str>,
) -> EditResult<ShapeFillReceipt> {
let fill = shape_fill(color)?;
let mut txn = self.transact_for(context);
require_shape_membership(&txn, slide_id, shape_id)?;
let shape = shape_ref(&txn, shape_id)?;
require_shape_kind(&shape, &txn)?;
let before = optional_json::<ShapeFill, _>(&shape, &txn, "fillJson")?
.as_ref()
.and_then(fill_color);
insert_json(&shape, &mut txn, "fillJson", Some(&fill))?;
Ok(ShapeFillReceipt {
slide_id: slide_id.to_owned(),
shape_id: shape_id.to_owned(),
before,
after: fill_color(&fill),
})
}
pub fn set_shape_stroke(
&self,
context: &EditCtx,
slide_id: &str,
shape_id: &str,
stroke: &ShapeStroke,
) -> EditResult<ShapeStrokeReceipt> {
let color = stroke.color.as_deref().map(color_value).transpose()?;
if let Some(width) = stroke.width_pt
&& (!width.is_finite() || !(0.0..=1_000.0).contains(&width))
{
return Err(EditError::InvalidGeometry(format!(
"stroke width {width}pt is outside the safe range"
)));
}
let mut txn = self.transact_for(context);
require_shape_membership(&txn, slide_id, shape_id)?;
let shape = shape_ref(&txn, shape_id)?;
require_shape_kind(&shape, &txn)?;
let existing = optional_json::<ShapeOutline, _>(&shape, &txn, "outlineJson")?;
let before = existing.as_ref().and_then(outline_stroke);
let outline = if stroke.color.is_none() && stroke.width_pt.is_none() {
ShapeOutline::default()
} else {
let mut outline = existing.unwrap_or_default();
if let Some(color) = color {
if outline.width.is_none() {
outline.width = Some(EMU_PER_POINT);
}
outline.color = Some(color);
outline.gradient = None;
} else if outline.color.is_none() && outline.gradient.is_none() {
outline.color = Some(color_value("#000000")?);
}
if let Some(width) = stroke.width_pt {
outline.width = Some(width * EMU_PER_POINT);
}
outline
};
insert_json(&shape, &mut txn, "outlineJson", Some(&outline))?;
Ok(ShapeStrokeReceipt {
slide_id: slide_id.to_owned(),
shape_id: shape_id.to_owned(),
before,
after: outline_stroke(&outline),
})
}
pub fn set_shape_adjust(
&self,
context: &EditCtx,
slide_id: &str,
shape_id: &str,
adjustments: &BTreeMap<String, f64>,
) -> EditResult<ShapeAdjustReceipt> {
validate_adjustments(adjustments)?;
let mut txn = self.transact_for(context);
require_shape_membership(&txn, slide_id, shape_id)?;
let shape = shape_ref(&txn, shape_id)?;
require_shape_kind(&shape, &txn)?;
let geometry = required_string(&shape, &txn, "geometry")?;
if geometry == "custom" {
return Err(EditError::InvalidGeometry(
"custom geometry does not support shape adjustments".to_owned(),
));
}
if !map_bool(&shape, &txn, "hasPresetGeometry").unwrap_or(true) {
return Err(EditError::InvalidGeometry(
"shape without preset geometry does not support shape adjustments".to_owned(),
));
}
let before = optional_json(&shape, &txn, "adjustValuesJson")?.unwrap_or_else(BTreeMap::new);
let mut after = BTreeMap::new();
for (name, value) in adjustments {
let maximum = if geometry == "roundRect" && name == "adj" {
0.5
} else {
1.0
};
after.insert(name.clone(), value.clamp(0.0, maximum));
}
insert_json(&shape, &mut txn, "adjustValuesJson", Some(&after))?;
Ok(ShapeAdjustReceipt {
slide_id: slide_id.to_owned(),
shape_id: shape_id.to_owned(),
before,
after,
})
}
pub fn remove_shape(
&self,
context: &EditCtx,
slide_id: &str,
shape_id: &str,
) -> EditResult<ShapeReceipt> {
let mut txn = self.transact_for(context);
let slide = slide_ref(&txn, slide_id)?;
let order = slide_shape_order(&slide, &txn)?;
let index = array_index(&order, &txn, shape_id)
.ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))?;
remove_shape_entries(&mut txn, &[shape_id.to_owned()])?;
order.remove(&mut txn, index);
Ok(ShapeReceipt {
slide_id: slide_id.to_owned(),
shape_id: shape_id.to_owned(),
index,
})
}
pub fn move_shape(
&self,
context: &EditCtx,
slide_id: &str,
shape_id: &str,
x: i64,
y: i64,
) -> EditResult<TransformReceipt> {
validate_coordinate(x)?;
validate_coordinate(y)?;
let mut txn = self.transact_for(context);
require_shape_membership(&txn, slide_id, shape_id)?;
let shape = shape_ref(&txn, shape_id)?;
let before = shape_rect(&shape, &txn)?;
shape.insert(&mut txn, "x", x as f64);
shape.insert(&mut txn, "y", y as f64);
Ok(TransformReceipt {
slide_id: slide_id.to_owned(),
shape_id: shape_id.to_owned(),
before,
after: ShapeRect { x, y, ..before },
})
}
pub fn resize_shape(
&self,
context: &EditCtx,
slide_id: &str,
shape_id: &str,
width: i64,
height: i64,
) -> EditResult<TransformReceipt> {
let rect = ShapeRect {
width,
height,
..ShapeRect::default()
};
validate_rect(rect)?;
let mut txn = self.transact_for(context);
require_shape_membership(&txn, slide_id, shape_id)?;
let shape = shape_ref(&txn, shape_id)?;
let before = shape_rect(&shape, &txn)?;
shape.insert(&mut txn, "width", width as f64);
shape.insert(&mut txn, "height", height as f64);
Ok(TransformReceipt {
slide_id: slide_id.to_owned(),
shape_id: shape_id.to_owned(),
before,
after: ShapeRect {
width,
height,
..before
},
})
}
pub fn set_shape_rect(
&self,
context: &EditCtx,
slide_id: &str,
shape_id: &str,
rect: ShapeRect,
) -> EditResult<TransformReceipt> {
validate_rect(rect)?;
let mut txn = self.transact_for(context);
require_shape_membership(&txn, slide_id, shape_id)?;
let shape = shape_ref(&txn, shape_id)?;
let before = shape_rect(&shape, &txn)?;
shape.insert(&mut txn, "x", rect.x as f64);
shape.insert(&mut txn, "y", rect.y as f64);
shape.insert(&mut txn, "width", rect.width as f64);
shape.insert(&mut txn, "height", rect.height as f64);
Ok(TransformReceipt {
slide_id: slide_id.to_owned(),
shape_id: shape_id.to_owned(),
before,
after: rect,
})
}
}
pub(crate) fn validate_doc(doc: &Doc) -> EditResult<()> {
let package = {
let txn = doc.transact();
let meta = required_map(&txn, META)?;
validate_schema_version(&meta, &txn)?;
if map_string(&meta, &txn, "fingerprint").is_none() {
return Err(EditError::InvalidState("missing fingerprint".to_owned()));
}
package_from_meta(&meta, &txn)?
};
let snapshot = snapshot_doc(doc, &package)?;
if snapshot.width_emu <= 0 || snapshot.height_emu <= 0 {
return Err(EditError::InvalidState(
"slide dimensions must be positive".to_owned(),
));
}
let txn = doc.transact();
let stories = required_map(&txn, STORIES)?;
for (story_id, value) in stories.iter(&txn) {
let story = value
.cast::<TextRef>()
.map_err(|_| EditError::InvalidState(format!("story {story_id} is not text")))?;
validate_story(&story, &txn, story_id)?;
}
Ok(())
}
pub(crate) fn package_from_doc(doc: &Doc) -> EditResult<PptxPackage> {
let txn = doc.transact();
let meta = required_map(&txn, META)?;
validate_schema_version(&meta, &txn)?;
package_from_meta(&meta, &txn)
}
pub(crate) fn import_source_render_data(doc: &Doc, source: &PptxPackage) -> EditResult<()> {
let mut package = package_from_doc(doc)?;
let sources = source
.slides
.iter()
.map(|part| (&part.part_path, &part.shapes))
.chain(
source
.layouts
.iter()
.map(|part| (&part.part_path, &part.shapes)),
)
.chain(
source
.masters
.iter()
.map(|part| (&part.part_path, &part.shapes)),
)
.collect::<HashMap<_, _>>();
let mut changed = false;
for (path, shapes) in package
.slides
.iter_mut()
.map(|part| (&part.part_path, &mut part.shapes))
.chain(
package
.layouts
.iter_mut()
.map(|part| (&part.part_path, &mut part.shapes)),
)
.chain(
package
.masters
.iter_mut()
.map(|part| (&part.part_path, &mut part.shapes)),
)
{
if let Some(source) = sources.get(path) {
changed |= merge_source_render_shapes(shapes, source);
}
}
for master in &mut package.masters {
if let Some(source) = source
.masters
.iter()
.find(|source| source.part_path == master.part_path)
{
let target = &mut master.text_styles;
let source = &source.text_styles;
for (target, source) in target
.title
.iter_mut()
.zip(&source.title)
.chain(target.body.iter_mut().zip(&source.body))
.chain(target.other.iter_mut().zip(&source.other))
{
changed |= merge_source_paragraph_properties(target, source);
}
}
}
for chart in &mut package.charts {
if let Some(source) = source
.charts
.iter()
.find(|source| source.part_path == chart.part_path)
{
changed |= merge_source_chart_properties(&mut chart.chart, &source.chart);
}
}
for slide in &mut package.slides {
if let Some(source) = source
.slides
.iter()
.find(|source| source.part_path == slide.part_path)
&& slide.notes != source.notes
{
slide.notes.clone_from(&source.notes);
changed = true;
}
}
if package.table_styles != source.table_styles {
package.table_styles.clone_from(&source.table_styles);
changed = true;
}
if !changed {
return Ok(());
}
let bytes = serde_json::to_vec(&package).map_err(|error| EditError::Json(error.to_string()))?;
let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
let meta = required_map(&txn, META)?;
meta.insert(&mut txn, "packageJson", Any::Buffer(Arc::from(bytes)));
backfill_blip_effects(&mut txn, &package)?;
backfill_tables(&mut txn, &package)?;
Ok(())
}
fn merge_source_render_shapes(target: &mut [ShapeNode], source: &[ShapeNode]) -> bool {
let mut changed = false;
for source in source {
let Some(target) = target
.iter_mut()
.find(|target| shape_base(target).id == shape_base(source).id)
else {
continue;
};
match (target, source) {
(ShapeNode::Shape(target), ShapeNode::Shape(source)) => {
changed |= target.picture_fill != source.picture_fill;
target.picture_fill.clone_from(&source.picture_fill);
if let (Some(target), Some(source)) = (&mut target.text, &source.text) {
changed |= merge_source_text_body(target, source);
}
}
(ShapeNode::Picture(target), ShapeNode::Picture(source)) => {
if target.effects.is_empty() && !source.effects.is_empty() {
target.effects.clone_from(&source.effects);
changed = true;
}
}
(ShapeNode::Group(target), ShapeNode::Group(source)) => {
changed |= merge_source_render_shapes(&mut target.children, &source.children);
}
(ShapeNode::GraphicFrame(target), ShapeNode::GraphicFrame(source)) => {
if matches!(
target.data,
pptx_parse::GraphicFrameData::Unknown { picture: None, .. }
) && matches!(
source.data,
pptx_parse::GraphicFrameData::Unknown {
picture: Some(_),
..
}
) {
target.data.clone_from(&source.data);
changed = true;
}
if let (
pptx_parse::GraphicFrameData::Table(target),
pptx_parse::GraphicFrameData::Table(source),
) = (&mut target.data, &source.data)
{
changed |= merge_source_table(target, source);
}
}
_ => {}
}
}
changed
}
fn merge_source_chart_properties(target: &mut ChartSpace, source: &ChartSpace) -> bool {
let mut changed = target.fill != source.fill;
target.fill.clone_from(&source.fill);
changed |= target.text != source.text || target.title_text != source.title_text;
target.text.clone_from(&source.text);
target.title_text.clone_from(&source.title_text);
if let (Some(target), Some(source)) = (&mut target.legend, &source.legend) {
changed |= target.text != source.text;
target.text.clone_from(&source.text);
}
for (target, source) in target.plot_groups.iter_mut().zip(&source.plot_groups) {
for (target, source) in target.series.iter_mut().zip(&source.series) {
changed |= target.line != source.line;
target.line.clone_from(&source.line);
}
}
let mut merge_axis = |target: Option<&mut ChartAxis>, source: Option<&ChartAxis>| {
if let (Some(target), Some(source)) = (target, source) {
changed |= target.line != source.line || target.text != source.text;
target.line.clone_from(&source.line);
target.text.clone_from(&source.text);
}
};
if let (Some(target), Some(source)) = (&mut target.axes, &source.axes) {
merge_axis(target.category.as_mut(), source.category.as_ref());
merge_axis(target.value.as_mut(), source.value.as_ref());
}
if let (Some(target), Some(source)) = (&mut target.axis_list, &source.axis_list) {
for (target, source) in target.iter_mut().zip(source) {
merge_axis(Some(target), Some(source));
}
}
changed
}
fn backfill_tables(txn: &mut TransactionMut<'_>, package: &PptxPackage) -> EditResult<()> {
let shapes = required_map(txn, SHAPES)?;
for (index, (slide, reference)) in package
.slides
.iter()
.zip(&package.presentation.slides)
.enumerate()
{
let slide_id = seeded_slide_id(index, reference.id);
for (index, node) in slide.shapes.iter().enumerate() {
backfill_table(txn, &shapes, &slide_id, &index.to_string(), node)?;
}
}
Ok(())
}
fn backfill_table(
txn: &mut TransactionMut<'_>,
shapes: &MapRef,
slide_id: &str,
path: &str,
node: &ShapeNode,
) -> EditResult<()> {
if let ShapeNode::GraphicFrame(frame) = node
&& let pptx_parse::GraphicFrameData::Table(table) = &frame.data
&& !table.grid.is_empty()
&& let Some(shape) = shapes
.get(txn, &seeded_shape_id(slide_id, path))
.and_then(|value| value.cast::<MapRef>().ok())
&& matches!(
optional_json::<pptx_parse::GraphicFrameData, _>(&shape, txn, "graphicJson")?,
Some(pptx_parse::GraphicFrameData::Table(stored)) if stored.grid.is_empty()
)
{
insert_json(&shape, txn, "graphicJson", Some(&frame.data))?;
}
if let ShapeNode::Group(group) = node {
for (index, child) in group.children.iter().enumerate() {
backfill_table(
txn,
shapes,
slide_id,
&seeded_child_path(path, index),
child,
)?;
}
}
Ok(())
}
fn merge_source_table(target: &mut pptx_parse::Table, source: &pptx_parse::Table) -> bool {
let mut changed = target.grid != source.grid || target.properties != source.properties;
target.grid.clone_from(&source.grid);
target.properties.clone_from(&source.properties);
for (target, source) in target.rows.iter_mut().zip(&source.rows) {
changed |= target.height != source.height;
target.height = source.height;
for (target, source) in target.cells.iter_mut().zip(&source.cells) {
changed |= target.grid_span != source.grid_span
|| target.row_span != source.row_span
|| target.merged != source.merged
|| target.fill != source.fill
|| target.borders != source.borders;
target.grid_span = source.grid_span;
target.row_span = source.row_span;
target.merged = source.merged;
target.fill.clone_from(&source.fill);
target.borders.clone_from(&source.borders);
changed |= merge_source_text_body(&mut target.text, &source.text);
}
}
changed
}
fn merge_source_text_body(
target: &mut pptx_parse::TextBody,
source: &pptx_parse::TextBody,
) -> bool {
let mut changed = target.list_style != source.list_style
|| target.default_list_style != source.default_list_style
|| target.compat_line_spacing != source.compat_line_spacing
|| target.vertical_overflow != source.vertical_overflow
|| target.horizontal_overflow != source.horizontal_overflow;
target.vertical_overflow = source.vertical_overflow;
target.horizontal_overflow = source.horizontal_overflow;
target.compat_line_spacing = source.compat_line_spacing;
target.list_style.clone_from(&source.list_style);
target
.default_list_style
.clone_from(&source.default_list_style);
for (target, source) in target.paragraphs.iter_mut().zip(&source.paragraphs) {
changed |= merge_source_paragraph_properties(&mut target.properties, &source.properties);
}
changed
}
fn merge_source_paragraph_properties(
target: &mut pptx_parse::ParagraphProperties,
source: &pptx_parse::ParagraphProperties,
) -> bool {
let mut changed = target.bullet_font != source.bullet_font
|| target.bullet_color != source.bullet_color
|| target.bullet_size != source.bullet_size
|| target.line_spacing != source.line_spacing
|| target.margin_right != source.margin_right
|| target.space_before != source.space_before
|| target.space_after != source.space_after;
target.line_spacing = source.line_spacing;
target.margin_right = source.margin_right;
target.space_before = source.space_before;
target.space_after = source.space_after;
if let (
Some(pptx_parse::Bullet::AutoNumber {
restart: target, ..
}),
Some(pptx_parse::Bullet::AutoNumber {
restart: source, ..
}),
) = (&mut target.bullet, &source.bullet)
{
changed |= *target != *source;
*target = *source;
}
target.bullet_font.clone_from(&source.bullet_font);
target.bullet_color.clone_from(&source.bullet_color);
target.bullet_size.clone_from(&source.bullet_size);
changed
}
pub(crate) fn fingerprint_from_doc(doc: &Doc) -> EditResult<String> {
let txn = doc.transact();
let meta = required_map(&txn, META)?;
map_string(&meta, &txn, "fingerprint")
.ok_or_else(|| EditError::InvalidState("missing fingerprint".to_owned()))
}
pub(crate) fn migrate_doc(doc: &Doc) -> EditResult<()> {
let version = {
let txn = doc.transact();
let meta = required_map(&txn, META)?;
schema_version(&meta, &txn)?
};
if version < SCHEMA_VERSION {
migrate_doc_to_v2_1(doc)?;
}
Ok(())
}
fn migrate_doc_to_v2_1(doc: &Doc) -> EditResult<()> {
let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
let meta = required_map(&txn, META)?;
let package = package_from_meta(&meta, &txn)?;
let package_json =
serde_json::to_vec(&package).map_err(|error| EditError::Json(error.to_string()))?;
meta.insert(
&mut txn,
"packageJson",
Any::Buffer(Arc::from(package_json)),
);
backfill_hidden(&mut txn, &package)?;
record_comment_flavor(&mut txn, &meta, &package);
meta.insert(&mut txn, "baselinesPendingSource", true);
meta.insert(&mut txn, "outlineGradientsPendingSource", true);
backfill_blip_effects(&mut txn, &package)?;
backfill_tables(&mut txn, &package)?;
meta.insert(&mut txn, "spacingPendingSource", true);
if package_needs_ole_source(&package) {
meta.insert(&mut txn, "olePicturesPendingSource", true);
}
meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION);
Ok(())
}
fn record_comment_flavor(txn: &mut TransactionMut<'_>, meta: &MapRef, package: &PptxPackage) {
if meta.contains_key(txn, "commentFlavor") {
return;
}
if !package.comments.is_empty()
|| package
.relationships
.values()
.flatten()
.any(|relationship| {
relationship.is_type(pptx_parse::relationship_types::COMMENTS)
|| relationship.is_type(pptx_parse::relationship_types::MODERN_COMMENTS)
})
{
meta.insert(txn, "commentsPendingSource", true);
}
meta.insert(
txn,
"commentFlavor",
flavor_key(package.comment_flavor.unwrap_or_default()),
);
}
fn backfill_blip_effects(txn: &mut TransactionMut<'_>, package: &PptxPackage) -> EditResult<()> {
let shapes = required_map(txn, SHAPES)?;
for (index, (slide, reference)) in package
.slides
.iter()
.zip(&package.presentation.slides)
.enumerate()
{
let slide_id = seeded_slide_id(index, reference.id);
for (index, node) in slide.shapes.iter().enumerate() {
backfill_picture_effects(txn, &shapes, &slide_id, &index.to_string(), node)?;
}
}
Ok(())
}
fn backfill_picture_effects(
txn: &mut TransactionMut<'_>,
shapes: &MapRef,
slide_id: &str,
path: &str,
node: &ShapeNode,
) -> EditResult<()> {
match node {
ShapeNode::Picture(picture) if !picture.effects.is_empty() => {
let id = seeded_shape_id(slide_id, path);
if let Some(shape) = shapes
.get(txn, &id)
.and_then(|shape| shape.cast::<MapRef>().ok())
&& !shape.contains_key(txn, "blipEffectsJson")
{
insert_json(&shape, txn, "blipEffectsJson", Some(&picture.effects))?;
}
}
ShapeNode::Group(group) => {
for (index, child) in group.children.iter().enumerate() {
backfill_picture_effects(
txn,
shapes,
slide_id,
&seeded_child_path(path, index),
child,
)?;
}
}
_ => {}
}
Ok(())
}
fn package_needs_ole_source(package: &PptxPackage) -> bool {
package
.slides
.iter()
.any(|part| needs_ole_source(&part.shapes))
|| package
.layouts
.iter()
.any(|part| needs_ole_source(&part.shapes))
|| package
.masters
.iter()
.any(|part| needs_ole_source(&part.shapes))
}
fn needs_ole_source(nodes: &[ShapeNode]) -> bool {
nodes.iter().any(|node| match node {
ShapeNode::GraphicFrame(frame) => matches!(&frame.data,
pptx_parse::GraphicFrameData::Unknown { uri: Some(uri), picture: None }
if uri == "http://schemas.openxmlformats.org/presentationml/2006/ole"),
ShapeNode::Group(group) => needs_ole_source(&group.children),
_ => false,
})
}
pub(crate) fn import_source_ole_pictures(doc: &Doc, source: &PptxPackage) -> EditResult<()> {
let mut txn = doc.transact_mut_with(MIGRATE_ORIGIN);
let meta = required_map(&txn, META)?;
if map_bool(&meta, &txn, "olePicturesPendingSource") != Some(true) {
return Ok(());
}
let shapes = required_map(&txn, SHAPES)?;
for (index, (slide, reference)) in source
.slides
.iter()
.zip(&source.presentation.slides)
.enumerate()
{
let slide_id = seeded_slide_id(index, reference.id);
for (index, node) in slide.shapes.iter().enumerate() {
backfill_ole_picture(&mut txn, &shapes, &slide_id, &index.to_string(), node)?;
}
}
meta.remove(&mut txn, "olePicturesPendingSource");
Ok(())
}
fn backfill_ole_picture(
txn: &mut TransactionMut<'_>,
shapes: &MapRef,
slide_id: &str,
path: &str,
node: &ShapeNode,
) -> EditResult<()> {
if let ShapeNode::GraphicFrame(frame) = node
&& matches!(
frame.data,
pptx_parse::GraphicFrameData::Unknown {
picture: Some(_),
..
}
)
&& let Some(shape) = shapes
.get(txn, &seeded_shape_id(slide_id, path))
.and_then(|value| value.cast::<MapRef>().ok())
&& matches!(
optional_json::<pptx_parse::GraphicFrameData, _>(&shape, txn, "graphicJson")?,
Some(pptx_parse::GraphicFrameData::Unknown { picture: None, .. })
)
{
insert_json(&shape, txn, "graphicJson", Some(&frame.data))?;
}
if let ShapeNode::Group(group) = node {
for (index, child) in group.children.iter().enumerate() {
backfill_ole_picture(
txn,
shapes,
slide_id,
&seeded_child_path(path, index),
child,
)?;
}
}
Ok(())
}
fn backfill_hidden(txn: &mut TransactionMut<'_>, package: &PptxPackage) -> EditResult<()> {
let shapes = required_map(txn, SHAPES)?;
for shape_id in seeded_hidden_shape_ids(package) {
if let Some(shape) = shapes
.get(txn, &shape_id)
.and_then(|value| value.cast::<MapRef>().ok())
{
shape.insert(txn, "hidden", true);
}
}
Ok(())
}
fn seeded_hidden_shape_ids(package: &PptxPackage) -> Vec<String> {
let mut ids = Vec::new();
for (slide_index, (slide, reference)) in package
.slides
.iter()
.zip(&package.presentation.slides)
.enumerate()
{
let slide_id = seeded_slide_id(slide_index, reference.id);
for (shape_index, shape) in slide.shapes.iter().enumerate() {
collect_hidden_shape_ids(&slide_id, &shape_index.to_string(), shape, &mut ids);
}
}
ids
}
fn collect_hidden_shape_ids(slide_id: &str, path: &str, shape: &ShapeNode, ids: &mut Vec<String>) {
if shape_base(shape).hidden {
ids.push(seeded_shape_id(slide_id, path));
}
if let ShapeNode::Group(group) = shape {
for (child_index, child) in group.children.iter().enumerate() {
collect_hidden_shape_ids(slide_id, &seeded_child_path(path, child_index), child, ids);
}
}
}
fn schema_version<T: ReadTxn>(meta: &MapRef, txn: &T) -> EditResult<f64> {
map_number(meta, txn, "schemaVersion")
.filter(|version| MIGRATABLE_SCHEMA_VERSIONS.contains(version))
.ok_or_else(|| EditError::InvalidState("unsupported deck schema version".to_owned()))
}
fn validate_schema_version<T: ReadTxn>(meta: &MapRef, txn: &T) -> EditResult<()> {
schema_version(meta, txn).map(|_| ())
}
fn package_from_meta<T: ReadTxn>(meta: &MapRef, txn: &T) -> EditResult<PptxPackage> {
let Some(Out::Any(Any::Buffer(bytes))) = meta.get(txn, "packageJson") else {
return Err(EditError::InvalidState("missing package data".to_owned()));
};
serde_json::from_slice(&bytes).map_err(|error| EditError::InvalidState(error.to_string()))
}
pub(crate) fn snapshot_doc(doc: &Doc, package: &PptxPackage) -> EditResult<DeckSnapshot> {
let txn = doc.transact();
let meta = required_map(&txn, META)?;
let order = required_order(&txn)?;
let slides = required_map(&txn, SLIDES)?;
let shapes = required_map(&txn, SHAPES)?;
let stories = required_map(&txn, STORIES)?;
let mut seen_slides = HashSet::new();
let mut slide_snapshots = Vec::new();
for slide_id in string_array_ref(&order, &txn) {
if !seen_slides.insert(slide_id.clone()) {
continue;
}
let slide = slides
.get(&txn, &slide_id)
.and_then(|value| value.cast::<MapRef>().ok())
.ok_or_else(|| EditError::InvalidState(format!("missing slide {slide_id}")))?;
let source_part_path = map_string(&slide, &txn, "sourcePartPath");
let layout_part_path = map_string(&slide, &txn, "layoutPartPath");
let theme = theme_for_layout(package, layout_part_path.as_deref());
let shape_order = slide_shape_order(&slide, &txn)?;
let mut seen_shapes = HashSet::new();
let mut shape_snapshots = Vec::new();
for shape_id in string_array_ref(&shape_order, &txn) {
if seen_shapes.insert(shape_id.clone()) {
shape_snapshots.push(snapshot_shape(
&shapes,
&stories,
&txn,
&shape_id,
&mut HashSet::new(),
theme,
)?);
}
}
let notes = map_string(&slide, &txn, "notes").unwrap_or_else(|| {
package
.slides
.iter()
.find(|source| Some(&source.part_path) == source_part_path.as_ref())
.map(|source| source.notes.clone())
.unwrap_or_default()
});
slide_snapshots.push(SlideSnapshot {
id: slide_id,
source_part_path,
layout_part_path,
name: map_string(&slide, &txn, "name"),
notes,
shapes: shape_snapshots,
});
}
Ok(DeckSnapshot {
width_emu: required_i64(&meta, &txn, "widthEmu")?,
height_emu: required_i64(&meta, &txn, "heightEmu")?,
slides: slide_snapshots,
comment_flavor: snapshot_flavor(&txn)?,
comments: snapshot_comments(&txn)?,
})
}
fn snapshot_shape<T: ReadTxn>(
shapes: &MapRef,
stories: &MapRef,
txn: &T,
shape_id: &str,
visiting: &mut HashSet<String>,
theme: Option<&Theme>,
) -> EditResult<ShapeSnapshot> {
if visiting.len() >= MAX_SHAPE_DEPTH {
return Err(EditError::InvalidState(format!(
"shape nesting exceeds {MAX_SHAPE_DEPTH} levels"
)));
}
if !visiting.insert(shape_id.to_owned()) {
return Err(EditError::InvalidState(format!(
"shape cycle at {shape_id}"
)));
}
let shape = shapes
.get(txn, shape_id)
.and_then(|value| value.cast::<MapRef>().ok())
.ok_or_else(|| EditError::InvalidState(format!("missing shape {shape_id}")))?;
let mut text_snapshots = Vec::new();
for story_id in map_string_array(&shape, txn, "textStories")? {
let story = stories
.get(txn, &story_id)
.and_then(|value| value.cast::<TextRef>().ok())
.ok_or_else(|| EditError::InvalidState(format!("missing story {story_id}")))?;
text_snapshots.push(snapshot_story(&story, txn, &story_id)?);
}
let mut children = Vec::new();
for child_id in map_string_array(&shape, txn, "children")? {
children.push(snapshot_shape(
shapes, stories, txn, &child_id, visiting, theme,
)?);
}
visiting.remove(shape_id);
let fill: Option<ShapeFill> = optional_json(&shape, txn, "fillJson")?;
let outline: Option<ShapeOutline> = optional_json(&shape, txn, "outlineJson")?;
let resolved_fill_color = fill
.as_ref()
.filter(|fill| fill.fill_type != "none")
.and_then(|fill| resolve_color_value_to_hex_with_theme(fill.color.as_ref(), theme));
let resolved_outline_color = outline
.as_ref()
.and_then(|outline| resolve_color_value_to_hex_with_theme(outline.color.as_ref(), theme));
Ok(ShapeSnapshot {
id: shape_id.to_owned(),
source_id: required_u32(&shape, txn, "sourceId")?,
kind: parse_shape_kind(&required_string(&shape, txn, "kind")?)?,
name: required_string(&shape, txn, "name")?,
x: required_i64(&shape, txn, "x")?,
y: required_i64(&shape, txn, "y")?,
width: required_i64(&shape, txn, "width")?,
height: required_i64(&shape, txn, "height")?,
rotation_deg: map_number(&shape, txn, "rotationDeg").unwrap_or_default(),
flip_h: map_bool(&shape, txn, "flipH").unwrap_or_default(),
flip_v: map_bool(&shape, txn, "flipV").unwrap_or_default(),
hidden: map_bool(&shape, txn, "hidden").unwrap_or_default(),
geometry: required_string(&shape, txn, "geometry")?,
adjust_values: optional_json(&shape, txn, "adjustValuesJson")?.unwrap_or_default(),
placeholder: optional_json(&shape, txn, "placeholderJson")?,
fill,
resolved_fill_color,
outline,
resolved_outline_color,
media_part_path: map_string(&shape, txn, "mediaPartPath"),
blip_effects: optional_json(&shape, txn, "blipEffectsJson")?.unwrap_or_default(),
graphic: optional_json(&shape, txn, "graphicJson")?,
text_stories: text_snapshots,
children,
})
}
fn parse_shape_kind(value: &str) -> EditResult<ShapeKind> {
match value {
"shape" => Ok(ShapeKind::Shape),
"picture" => Ok(ShapeKind::Picture),
"graphicFrame" => Ok(ShapeKind::GraphicFrame),
"group" => Ok(ShapeKind::Group),
_ => Err(EditError::InvalidState(format!(
"unknown shape kind {value}"
))),
}
}
fn required_order<T: ReadTxn>(txn: &T) -> EditResult<ArrayRef> {
txn.get_array(SLIDE_ORDER)
.ok_or_else(|| EditError::InvalidState("missing slide order".to_owned()))
}
pub(crate) fn required_map<T: ReadTxn>(txn: &T, name: &str) -> EditResult<MapRef> {
txn.get_map(name)
.ok_or_else(|| EditError::InvalidState(format!("missing {name}")))
}
pub(crate) fn slide_ref<T: ReadTxn>(txn: &T, slide_id: &str) -> EditResult<MapRef> {
required_map(txn, SLIDES)?
.get(txn, slide_id)
.and_then(|value| value.cast::<MapRef>().ok())
.ok_or_else(|| EditError::SlideNotFound(slide_id.to_owned()))
}
fn shape_ref<T: ReadTxn>(txn: &T, shape_id: &str) -> EditResult<MapRef> {
required_map(txn, SHAPES)?
.get(txn, shape_id)
.and_then(|value| value.cast::<MapRef>().ok())
.ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))
}
fn require_shape_kind<T: ReadTxn>(shape: &MapRef, txn: &T) -> EditResult<()> {
if required_string(shape, txn, "kind")? == "shape" {
Ok(())
} else {
Err(EditError::InvalidGeometry(
"only preset shapes support shape styling".to_owned(),
))
}
}
fn slide_shape_order<T: ReadTxn>(slide: &MapRef, txn: &T) -> EditResult<ArrayRef> {
slide
.get(txn, "shapes")
.and_then(|value| value.cast::<ArrayRef>().ok())
.ok_or_else(|| EditError::InvalidState("slide has no shape order".to_owned()))
}
fn require_shape_membership<T: ReadTxn>(txn: &T, slide_id: &str, shape_id: &str) -> EditResult<()> {
let slide = slide_ref(txn, slide_id)?;
let order = slide_shape_order(&slide, txn)?;
if array_index(&order, txn, shape_id).is_some() {
Ok(())
} else {
Err(EditError::ShapeNotFound(shape_id.to_owned()))
}
}
fn remove_shape_entries(txn: &mut TransactionMut<'_>, root_shape_ids: &[String]) -> EditResult<()> {
let shapes = required_map(txn, SHAPES)?;
let stories = required_map(txn, STORIES)?;
let mut entries = ShapeEntries::default();
for shape_id in root_shape_ids {
collect_shape_entries(&shapes, txn, shape_id, &mut HashSet::new(), &mut entries)?;
}
for story_id in entries.story_ids {
stories.remove(txn, &story_id);
}
for shape_id in entries.shape_ids {
shapes.remove(txn, &shape_id);
}
Ok(())
}
#[derive(Default)]
struct ShapeEntries {
shape_ids: Vec<String>,
seen_shape_ids: HashSet<String>,
story_ids: Vec<String>,
seen_story_ids: HashSet<String>,
}
fn collect_shape_entries<T: ReadTxn>(
shapes: &MapRef,
txn: &T,
shape_id: &str,
visiting: &mut HashSet<String>,
entries: &mut ShapeEntries,
) -> EditResult<()> {
if visiting.len() >= MAX_SHAPE_DEPTH {
return Err(EditError::InvalidState(format!(
"shape nesting exceeds {MAX_SHAPE_DEPTH} levels"
)));
}
if !visiting.insert(shape_id.to_owned()) {
return Err(EditError::InvalidState(format!(
"shape cycle at {shape_id}"
)));
}
if entries.seen_shape_ids.contains(shape_id) {
visiting.remove(shape_id);
return Ok(());
}
let shape = shapes
.get(txn, shape_id)
.and_then(|value| value.cast::<MapRef>().ok())
.ok_or_else(|| EditError::ShapeNotFound(shape_id.to_owned()))?;
for story_id in map_string_array(&shape, txn, "textStories")? {
if entries.seen_story_ids.insert(story_id.clone()) {
entries.story_ids.push(story_id);
}
}
for child_id in map_string_array(&shape, txn, "children")? {
collect_shape_entries(shapes, txn, &child_id, visiting, entries)?;
}
visiting.remove(shape_id);
entries.seen_shape_ids.insert(shape_id.to_owned());
entries.shape_ids.push(shape_id.to_owned());
Ok(())
}
fn array_index<T: ReadTxn>(array: &ArrayRef, txn: &T, value: &str) -> Option<u32> {
array
.iter(txn)
.enumerate()
.find(|(_, item)| out_string(item).as_deref() == Some(value))
.map(|(index, _)| index as u32)
}
fn string_array_ref<T: ReadTxn>(array: &ArrayRef, txn: &T) -> Vec<String> {
array
.iter(txn)
.filter_map(|value| out_string(&value))
.collect()
}
fn map_string_array<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<Vec<String>> {
match map.get(txn, key) {
Some(Out::Any(Any::Array(values))) => Ok(values
.iter()
.filter_map(|value| match value {
Any::String(value) => Some(value.to_string()),
_ => None,
})
.collect()),
None => Ok(Vec::new()),
_ => Err(EditError::InvalidState(format!("{key} is not an array"))),
}
}
fn string_array(values: &[String]) -> Any {
Any::Array(Arc::from(
values
.iter()
.map(|value| Any::from(value.as_str()))
.collect::<Vec<_>>(),
))
}
fn shape_rect<T: ReadTxn>(shape: &MapRef, txn: &T) -> EditResult<ShapeRect> {
Ok(ShapeRect {
x: required_i64(shape, txn, "x")?,
y: required_i64(shape, txn, "y")?,
width: required_i64(shape, txn, "width")?,
height: required_i64(shape, txn, "height")?,
})
}
fn required_u32<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<u32> {
let value = required_i64(map, txn, key)?;
u32::try_from(value).map_err(|_| {
EditError::InvalidState(format!("{key} value {value} is outside the u32 range"))
})
}
fn validate_rect(rect: ShapeRect) -> EditResult<()> {
validate_coordinate(rect.x)?;
validate_coordinate(rect.y)?;
if rect.width <= 0 || rect.height <= 0 {
return Err(EditError::InvalidGeometry(
"shape width and height must be positive".to_owned(),
));
}
validate_coordinate(rect.width)?;
validate_coordinate(rect.height)
}
fn validate_coordinate(value: i64) -> EditResult<()> {
if value.unsigned_abs() > MAX_GEOMETRY as u64 {
return Err(EditError::InvalidGeometry(format!(
"coordinate {value} exceeds the safe range"
)));
}
Ok(())
}
fn validate_adjustments(adjustments: &BTreeMap<String, f64>) -> EditResult<()> {
if adjustments.len() > MAX_ADJUSTMENTS {
return Err(EditError::InvalidAdjustment(format!(
"at most {MAX_ADJUSTMENTS} values are allowed"
)));
}
for (name, value) in adjustments {
if !valid_adjustment_name(name) {
return Err(EditError::InvalidAdjustment(format!(
"unrecognized guide name {name:?}"
)));
}
if !value.is_finite() {
return Err(EditError::InvalidAdjustment(format!(
"guide {name:?} must be finite"
)));
}
}
Ok(())
}
fn valid_adjustment_name(name: &str) -> bool {
if name == "adj" {
return true;
}
let Some(index) = name.strip_prefix("adj") else {
return false;
};
index
.parse::<usize>()
.ok()
.filter(|value| value.to_string() == index)
.is_some_and(|value| (1..=MAX_ADJUSTMENT_INDEX).contains(&value))
}
fn shape_fill(color: Option<&str>) -> EditResult<ShapeFill> {
Ok(match color {
Some(color) => ShapeFill {
fill_type: "solid".to_owned(),
color: Some(color_value(color)?),
gradient: None,
},
None => ShapeFill::named("none"),
})
}
fn fill_color(fill: &ShapeFill) -> Option<String> {
(fill.fill_type != "none")
.then(|| resolve_color_value_to_hex(fill.color.as_ref()))
.flatten()
}
fn outline_stroke(outline: &ShapeOutline) -> Option<ShapeStroke> {
Some(ShapeStroke {
color: Some(resolve_color_value_to_hex(outline.color.as_ref())?),
width_pt: outline.width.map(|width| width / EMU_PER_POINT),
})
}
fn color_value(color: &str) -> EditResult<ColorValue> {
let rgb = color.strip_prefix('#').unwrap_or(color);
if rgb.len() != 6 || !rgb.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(EditError::InvalidGeometry(format!(
"color {color:?} must be a six-digit hex value"
)));
}
Ok(ColorValue {
rgb: Some(rgb.to_ascii_uppercase()),
..ColorValue::default()
})
}
fn required_string<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<String> {
map_string(map, txn, key)
.ok_or_else(|| EditError::InvalidState(format!("missing string {key}")))
}
pub(crate) fn map_string<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<String> {
map.get(txn, key).and_then(|value| out_string(&value))
}
fn out_string(value: &Out) -> Option<String> {
match value {
Out::Any(Any::String(value)) => Some(value.to_string()),
_ => None,
}
}
fn required_i64<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> EditResult<i64> {
let number = map_number(map, txn, key)
.ok_or_else(|| EditError::InvalidState(format!("missing number {key}")))?;
if !number.is_finite() || number.fract() != 0.0 || number.abs() > MAX_GEOMETRY as f64 {
return Err(EditError::InvalidState(format!("invalid integer {key}")));
}
Ok(number as i64)
}
pub(crate) fn map_number<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<f64> {
match map.get(txn, key) {
Some(Out::Any(Any::Number(value))) if value.is_finite() => Some(value),
Some(Out::Any(Any::BigInt(value))) => Some(value as f64),
_ => None,
}
}
pub(crate) fn map_bool<T: ReadTxn>(map: &MapRef, txn: &T, key: &str) -> Option<bool> {
match map.get(txn, key) {
Some(Out::Any(Any::Bool(value))) => Some(value),
_ => None,
}
}
fn optional_json<T: DeserializeOwned, R: ReadTxn>(
map: &MapRef,
txn: &R,
key: &str,
) -> EditResult<Option<T>> {
map_string(map, txn, key)
.map(|json| {
serde_json::from_str(&json).map_err(|error| EditError::InvalidState(error.to_string()))
})
.transpose()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::TextStyle;
const FIXTURE: &[u8] = include_bytes!("../../../apps/demo/public/betteroffice-demo.pptx");
const HIDDEN_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/hidden-shapes.pptx");
#[test]
fn a_reattached_source_restores_the_series_lines_a_stored_package_lacks() {
let source = ChartSpace {
plot_groups: vec![pptx_parse::ChartPlotGroup {
series: vec![pptx_parse::ChartSeries {
line: Some(ooxml_drawingml::chart::ChartLine {
none: false,
color: Some("#FFFFFF".to_owned()),
width_emu: Some(41275.0),
}),
..pptx_parse::ChartSeries::default()
}],
..pptx_parse::ChartPlotGroup::default()
}],
..ChartSpace::default()
};
let mut target = source.clone();
target.plot_groups[0].series[0].line = None;
assert!(merge_source_chart_properties(&mut target, &source));
assert_eq!(target, source);
assert!(!merge_source_chart_properties(&mut target, &source));
}
#[test]
fn a_reattached_source_restores_the_right_margin_a_stored_package_lacks() {
let source = pptx_parse::ParagraphProperties {
margin_right: Some(914_400),
..pptx_parse::ParagraphProperties::default()
};
let mut target = pptx_parse::ParagraphProperties::default();
assert!(merge_source_paragraph_properties(&mut target, &source));
assert_eq!(target.margin_right, Some(914_400));
assert!(!merge_source_paragraph_properties(&mut target, &source));
}
#[test]
fn a_reattached_source_restores_the_paragraph_spacing_a_stored_package_lacks() {
let source = pptx_parse::ParagraphProperties {
space_before: Some(pptx_parse::LineSpacing::Points { value: 10.0 }),
space_after: Some(pptx_parse::LineSpacing::Percent { value: 0.2 }),
..pptx_parse::ParagraphProperties::default()
};
let mut target = pptx_parse::ParagraphProperties::default();
assert!(merge_source_paragraph_properties(&mut target, &source));
assert_eq!(target.space_before, source.space_before);
assert_eq!(target.space_after, source.space_after);
assert!(!merge_source_paragraph_properties(&mut target, &source));
}
#[test]
fn attaching_a_source_refreshes_chart_text_a_stored_package_never_carried() {
use ooxml_drawingml::chart::{ChartLegend, ChartTextProperties};
let styled = ChartTextProperties {
spacing_pt: Some(3.0),
..ChartTextProperties::default()
};
let source = ChartSpace {
text: Some(styled.clone()),
title_text: Some(styled.clone()),
legend: Some(ChartLegend {
visible: true,
text: Some(styled.clone()),
..ChartLegend::default()
}),
axis_list: Some(vec![ChartAxis {
text: Some(styled.clone()),
..ChartAxis::default()
}]),
..ChartSpace::default()
};
let mut target = ChartSpace {
legend: Some(ChartLegend {
visible: true,
..ChartLegend::default()
}),
axis_list: Some(vec![ChartAxis::default()]),
..ChartSpace::default()
};
assert!(merge_source_chart_properties(&mut target, &source));
assert_eq!(target.text.as_ref(), Some(&styled));
assert_eq!(target.title_text.as_ref(), Some(&styled));
assert_eq!(target.legend.unwrap().text.as_ref(), Some(&styled));
assert_eq!(target.axis_list.unwrap()[0].text.as_ref(), Some(&styled));
assert!(!merge_source_chart_properties(&mut source.clone(), &source));
}
#[test]
fn an_unmigratable_schema_version_is_reported_before_package_json() {
let session = DeckSession::open(FIXTURE, 100).unwrap();
{
let mut txn = session.doc.transact_mut();
let meta = required_map(&txn, META).unwrap();
meta.insert(&mut txn, "schemaVersion", SCHEMA_VERSION + 1.0);
meta.insert(
&mut txn,
"packageJson",
Any::Buffer(Arc::from(b"{}".to_vec())),
);
}
assert!(matches!(
validate_doc(&session.doc),
Err(EditError::InvalidState(message))
if message == "unsupported deck schema version"
));
assert!(matches!(
DeckSession::open_from_update(&session.encode_state_as_update_v1(), 101),
Err(EditError::InvalidState(message))
if message == "unsupported deck schema version"
));
}
#[test]
fn migration_leaves_a_current_document_untouched() {
let session = DeckSession::open(FIXTURE, 102).unwrap();
let before = session.encode_state_as_update_v1();
migrate_doc(&session.doc).unwrap();
assert_eq!(session.encode_state_as_update_v1(), before);
}
#[test]
fn legacy_migrations_commit_once_and_normalise_the_package() {
use std::sync::Mutex;
const V1: &[u8] = include_bytes!("../tests/fixtures/deck-schema-v1.update.bin");
const V2: &[u8] = include_bytes!("../tests/fixtures/deck-schema-v2-connectors.update.bin");
for update in [V1, V2] {
let doc = crate::doc_with_client_id(920);
crate::hydrate_doc(&doc, update).unwrap();
let before = snapshot_doc(&doc, &package_from_doc(&doc).unwrap()).unwrap();
let observed = Arc::new(Mutex::new(Vec::new()));
let events = observed.clone();
let _subscription = doc
.observe_update_v1(move |txn, _| {
let meta = required_map(txn, META).unwrap();
let Some(Out::Any(Any::Buffer(package))) = meta.get(txn, "packageJson") else {
panic!("missing package data");
};
events
.lock()
.unwrap()
.push((map_number(&meta, txn, "schemaVersion").unwrap(), package));
})
.unwrap();
migrate_doc(&doc).unwrap();
let events = observed.lock().unwrap();
assert_eq!(
events
.iter()
.map(|(version, _)| *version)
.collect::<Vec<_>>(),
[SCHEMA_VERSION]
);
for (_, package) in events.iter() {
let json: serde_json::Value = serde_json::from_slice(package).unwrap();
assert!(json.get("charts").unwrap().is_array());
assert!(json.get("shapeElements").is_none());
assert!(json["presentation"].get("firstSlideNum").is_none());
}
let package = package_from_doc(&doc).unwrap();
assert_eq!(snapshot_doc(&doc, &package).unwrap(), before);
assert!(!package.models_connectors());
assert_eq!(package.presentation.first_slide_num, 1);
}
}
#[test]
fn a_v2_hidden_snapshot_backfills_flags_without_rewriting_the_package() {
use std::sync::Mutex;
use yrs::Update;
use yrs::updates::decoder::Decode;
const V2: &[u8] = include_bytes!("../tests/fixtures/deck-schema-v2-hidden.update.bin");
let doc = crate::doc_with_client_id(9430);
doc.transact_mut()
.apply_update(Update::decode_v1(V2).unwrap())
.unwrap();
let before = package_from_doc(&doc).unwrap();
let observed = Arc::new(Mutex::new(Vec::new()));
let events = observed.clone();
let _subscription = doc
.observe_update_v1(move |txn, _| {
let meta = required_map(txn, META).unwrap();
let shapes = required_map(txn, SHAPES).unwrap();
let mut hidden: Vec<String> = shapes
.iter(txn)
.filter_map(|(id, value)| value.cast::<MapRef>().ok().map(|shape| (id, shape)))
.filter(|(_, shape)| map_bool(shape, txn, "hidden").unwrap_or(false))
.map(|(id, _)| id.to_owned())
.collect();
hidden.sort();
events.lock().unwrap().push((
map_number(&meta, txn, "schemaVersion").unwrap(),
package_from_meta(&meta, txn).unwrap(),
hidden,
));
})
.unwrap();
migrate_doc(&doc).unwrap();
let events = observed.lock().unwrap();
assert_eq!(
events
.iter()
.map(|(version, _, _)| *version)
.collect::<Vec<_>>(),
[SCHEMA_VERSION]
);
for (_, package, hidden) in events.iter() {
assert_eq!(
serde_json::to_vec(package).unwrap(),
serde_json::to_vec(&before).unwrap()
);
assert_eq!(
hidden,
&[
"slide:0:256:shape:0",
"slide:0:256:shape:8",
"slide:0:256:shape:8.13",
"slide:1:257:shape:16",
]
);
}
}
#[test]
fn seeding_stores_hidden_only_for_hidden_shapes() {
assert!(hidden_keys(&DeckSession::open(FIXTURE, 103).unwrap()).is_empty());
let session = DeckSession::open(HIDDEN_FIXTURE, 104).unwrap();
assert_eq!(
hidden_keys(&session),
[
"slide:0:256:shape:0",
"slide:0:256:shape:8",
"slide:0:256:shape:8.13",
"slide:1:257:shape:16",
"slide:1:257:shape:4",
]
);
let snapshot = session.snapshot().unwrap();
let group = &snapshot.slides[0].shapes[8];
assert!(snapshot.slides[0].shapes[0].hidden);
assert!(group.hidden);
assert_eq!(group.children.len(), 14);
assert!(group.children[..13].iter().all(|child| !child.hidden));
assert!(group.children[13].hidden);
}
#[test]
fn hidden_flags_serialise_sparsely_and_round_trip() {
let snapshot = DeckSession::open(HIDDEN_FIXTURE, 106)
.unwrap()
.snapshot()
.unwrap();
let json = serde_json::to_string(&snapshot).unwrap();
assert_eq!(json.matches("\"hidden\":true").count(), 5);
assert!(!json.contains("\"hidden\":false"));
assert_eq!(
serde_json::from_str::<DeckSnapshot>(&json).unwrap(),
snapshot
);
}
fn hidden_keys(session: &DeckSession) -> Vec<String> {
let txn = session.doc.transact();
let shapes = required_map(&txn, SHAPES).unwrap();
let mut ids: Vec<String> = shapes
.iter(&txn)
.filter_map(|(id, value)| value.cast::<MapRef>().ok().map(|shape| (id, shape)))
.filter(|(_, shape)| shape.get(&txn, "hidden").is_some())
.map(|(id, _)| id.to_owned())
.collect();
ids.sort();
ids
}
#[test]
fn delete_operations_remove_owned_map_entries() {
let session = DeckSession::open(FIXTURE, 101).unwrap();
let context = EditCtx::local("test");
let baseline = map_lengths(&session);
for cycle in 0..3 {
let index = session.snapshot().unwrap().slides.len() as u32;
let slide = session.insert_slide(&context, index, None).unwrap();
let first = session
.add_text_box(&context, &slide.slide_id, &draft(cycle, 0))
.unwrap();
session
.add_text_box(&context, &slide.slide_id, &draft(cycle, 1))
.unwrap();
assert_eq!(map_lengths(&session), add_lengths(baseline, (1, 2, 2)));
session
.remove_shape(&context, &slide.slide_id, &first.shape_id)
.unwrap();
assert_eq!(map_lengths(&session), add_lengths(baseline, (1, 1, 1)));
session.delete_slide(&context, &slide.slide_id).unwrap();
assert_eq!(map_lengths(&session), baseline);
}
}
#[test]
fn deleting_seeded_slide_cascades_descendant_entries() {
let session = DeckSession::open(FIXTURE, 202).unwrap();
let slide = session.snapshot().unwrap().slides[0].clone();
let before = map_lengths(&session);
let owned = slide.shapes.iter().fold((0, 0), |total, shape| {
let next = shape_entry_lengths(shape);
(total.0 + next.0, total.1 + next.1)
});
session
.delete_slide(&EditCtx::local("test"), &slide.id)
.unwrap();
assert!(owned.0 > slide.shapes.len() as u32);
assert_eq!(
map_lengths(&session),
(before.0 - 1, before.1 - owned.0, before.2 - owned.1)
);
}
fn draft(cycle: u32, index: u32) -> ShapeDraft {
ShapeDraft {
name: format!("Shape {cycle}:{index}"),
rect: ShapeRect {
x: 100_000,
y: 100_000,
width: 1_000_000,
height: 500_000,
},
text: "Text".to_owned(),
style: TextStyle::default(),
}
}
fn map_lengths(session: &DeckSession) -> (u32, u32, u32) {
let txn = session.doc.transact();
(
required_map(&txn, SLIDES).unwrap().len(&txn),
required_map(&txn, SHAPES).unwrap().len(&txn),
required_map(&txn, STORIES).unwrap().len(&txn),
)
}
fn shape_entry_lengths(shape: &ShapeSnapshot) -> (u32, u32) {
shape.children.iter().fold(
(1, shape.text_stories.len() as u32),
|(shape_count, story_count), child| {
let child_counts = shape_entry_lengths(child);
(shape_count + child_counts.0, story_count + child_counts.1)
},
)
}
fn add_lengths(left: (u32, u32, u32), right: (u32, u32, u32)) -> (u32, u32, u32) {
(left.0 + right.0, left.1 + right.1, left.2 + right.2)
}
#[test]
fn a_v2_comment_snapshot_records_its_flavour_while_deferring_the_import() {
use std::sync::Mutex;
use yrs::Update;
use yrs::updates::decoder::Decode;
const V2: &[u8] = include_bytes!("../tests/fixtures/deck-schema-v2-comments.update.bin");
let doc = crate::doc_with_client_id(9620);
doc.transact_mut()
.apply_update(Update::decode_v1(V2).unwrap())
.unwrap();
let before = {
let txn = doc.transact();
let meta = required_map(&txn, META).unwrap();
assert_eq!(map_number(&meta, &txn, "schemaVersion"), Some(2.0));
assert!(meta.get(&txn, "commentFlavor").is_none());
package_from_meta(&meta, &txn).unwrap()
};
let observed = Arc::new(Mutex::new(Vec::new()));
let events = observed.clone();
let _subscription = doc
.observe_update_v1(move |txn, _| {
let meta = required_map(txn, META).unwrap();
events.lock().unwrap().push((
map_number(&meta, txn, "schemaVersion").unwrap(),
package_from_meta(&meta, txn).unwrap(),
map_string(&meta, txn, "commentFlavor"),
map_bool(&meta, txn, "commentsPendingSource"),
));
})
.unwrap();
migrate_doc(&doc).unwrap();
let events = observed.lock().unwrap();
assert_eq!(
*events,
[(
SCHEMA_VERSION,
before,
Some("legacy".to_owned()),
Some(true)
)]
);
}
}