use std::collections::{BTreeMap, HashMap};
use base64::Engine as _;
use ooxml_drawingml::{ColorValue, ShapeFill};
use pptx_parse::{
BackgroundWrite, Bullet, CommentAuthorWrite, CommentFlavor, CommentSlide, CommentWrite,
CommentsWrite, DeckWrite, InheritedTransform, NotesWrite, ParagraphWrite, PictureAdd,
PptxPackage, RunProperties, RunWrite, ShapeAdd, ShapeNode, ShapePatch, ShapeWrite, SlideWrite,
TextAutofit, TextTarget, TextWrite,
};
use crate::comments::{derived_guid, seeded_comment_id};
use crate::deck::baseline_snapshot;
use crate::paragraph::{SlideParents, find_placeholder};
use crate::{
CommentSnapshot, DeckSession, DeckSnapshot, EditError, EditResult, ParagraphSnapshot,
ShapeKind, ShapeSnapshot, SlideSnapshot, StorySnapshot, TextRunSnapshot,
};
impl DeckSession {
pub fn save(&self) -> EditResult<Vec<u8>> {
self.save_with_autofit(&HashMap::new())
}
#[doc(hidden)]
pub fn save_with_autofit(&self, autofit: &HashMap<String, TextAutofit>) -> EditResult<Vec<u8>> {
self.save_with_autofit_resolver(|_, _| Ok(autofit.clone()))
}
#[doc(hidden)]
pub fn save_with_autofit_resolver(
&self,
resolve: impl FnOnce(&DeckSnapshot, &DeckSnapshot) -> EditResult<HashMap<String, TextAutofit>>,
) -> EditResult<Vec<u8>> {
if !self.package.has_parts() {
return Err(EditError::Write(
"this session carries no source file bytes; open it from the \
file, or from the update plus the source file, to save"
.to_owned(),
));
}
let current = self.snapshot()?;
if let Some(attribute) =
crate::source_run_properties::unrecovered_attribute(&self.doc, ¤t)
{
return Err(EditError::Write(format!(
"run {attribute} could not be recovered from the source file for some \
restored text, and saving now would drop it"
)));
}
let baseline = baseline_snapshot(&self.package)?;
if current == baseline {
return pptx_parse::write_pptx(&self.package)
.map_err(|error| EditError::Write(error.to_string()));
}
let autofit = resolve(¤t, &baseline)?;
let deck = deck_write(¤t, &baseline, &self.package, &autofit)?;
pptx_parse::write_pptx_with_edits(&self.package, &deck)
.map_err(|error| EditError::Write(error.to_string()))
}
}
fn deck_write(
current: &DeckSnapshot,
baseline: &DeckSnapshot,
package: &PptxPackage,
autofit: &HashMap<String, TextAutofit>,
) -> EditResult<DeckWrite> {
let baseline_slides: HashMap<&str, &SlideSnapshot> = baseline
.slides
.iter()
.map(|slide| (slide.id.as_str(), slide))
.collect();
let mut slides = Vec::with_capacity(current.slides.len());
for slide in ¤t.slides {
let context = SlideParents::resolve(
package,
slide.source_part_path.as_deref(),
slide.layout_part_path.as_deref(),
);
let write = match baseline_slides.get(slide.id.as_str()) {
Some(base) if *base == slide => SlideWrite::Keep {
part_path: source_part_path(slide)?,
},
Some(base) => SlideWrite::Patch {
part_path: source_part_path(slide)?,
shapes: shape_writes(
&slide.shapes,
&base.shapes,
source_shapes(package, slide),
autofit,
&context,
)?,
background: background_write(slide, base),
layout_part_path: changed_layout(slide, base),
},
None if slide.source_part_path.is_some() => {
let mut base = baseline
.slides
.iter()
.find(|base| base.source_part_path == slide.source_part_path)
.ok_or_else(|| EditError::Write("duplicate source slide is missing".into()))?
.clone();
let old_id = base.id.clone();
for shape in &mut base.shapes {
rename_duplicate_shape(shape, &old_id, &slide.id);
}
SlideWrite::Clone {
part_path: source_part_path(slide)?,
shapes: shape_writes(
&slide.shapes,
&base.shapes,
source_shapes(package, slide),
autofit,
&context,
)?,
background: background_write(slide, &base),
layout_part_path: changed_layout(slide, &base),
}
}
None => SlideWrite::Add {
name: slide.name.clone(),
layout_part_path: slide.layout_part_path.clone(),
shapes: slide
.shapes
.iter()
.map(shape_add)
.collect::<EditResult<Vec<_>>>()?,
background: slide.background.clone().map(Box::new),
},
};
slides.push(write);
}
Ok(DeckWrite {
slides,
comments: comments_write(current, baseline, package),
notes: notes_write(current, baseline),
})
}
fn changed_layout(current: &SlideSnapshot, baseline: &SlideSnapshot) -> Option<String> {
(current.layout_part_path != baseline.layout_part_path)
.then(|| current.layout_part_path.clone())
.flatten()
}
fn rename_duplicate_shape(shape: &mut ShapeSnapshot, old_slide: &str, new_slide: &str) {
shape.id = shape.id.replacen(old_slide, new_slide, 1);
for story in &mut shape.text_stories {
let old_prefix = format!("para:{}:", story.id);
story.id = story.id.replacen(old_slide, new_slide, 1);
for paragraph in &mut story.paragraphs {
if let Some(index) = paragraph.id.strip_prefix(&old_prefix) {
paragraph.id = format!("para:{}:{index}", story.id);
}
}
}
for child in &mut shape.children {
rename_duplicate_shape(child, old_slide, new_slide);
}
}
fn background_write(current: &SlideSnapshot, baseline: &SlideSnapshot) -> Option<BackgroundWrite> {
match ¤t.background {
Some(fill) if current.background != baseline.background => {
Some(BackgroundWrite::Set(Box::new(fill.clone())))
}
None if current.background_edited || baseline.background.is_some() => {
Some(BackgroundWrite::Clear)
}
_ => None,
}
}
fn notes_write(current: &DeckSnapshot, baseline: &DeckSnapshot) -> Option<NotesWrite> {
let baseline_notes: HashMap<&str, &str> = baseline
.slides
.iter()
.map(|slide| (slide.id.as_str(), slide.notes.as_str()))
.collect();
let mut per_slide = Vec::new();
for (index, slide) in current.slides.iter().enumerate() {
let changed = baseline_notes
.get(slide.id.as_str())
.copied()
.or_else(|| {
slide.source_part_path.as_ref().and_then(|path| {
baseline
.slides
.iter()
.find(|base| base.source_part_path.as_ref() == Some(path))
.map(|base| base.notes.as_str())
})
})
.is_none_or(|base| base != slide.notes.as_str());
if !changed {
continue;
}
let target = match slide
.source_part_path
.clone()
.filter(|_| baseline.slides.iter().any(|base| base.id == slide.id))
{
Some(part_path) => CommentSlide::Existing(part_path),
None => CommentSlide::Added(index),
};
per_slide.push((target, slide.notes.clone()));
}
(!per_slide.is_empty()).then_some(NotesWrite { per_slide })
}
fn comments_write(
current: &DeckSnapshot,
baseline: &DeckSnapshot,
package: &PptxPackage,
) -> Option<CommentsWrite> {
if current.comments == baseline.comments && current.comment_flavor == baseline.comment_flavor {
return None;
}
let baseline_live: Vec<&CommentSnapshot> = baseline
.comments
.iter()
.filter(|comment| {
current
.slides
.iter()
.any(|slide| slide.id == comment.slide_id)
})
.collect();
if !current.comments.is_empty()
&& current.comments.len() < baseline.comments.len()
&& current.comment_flavor == baseline.comment_flavor
&& current.comments.iter().eq(baseline_live)
{
return None;
}
let flavor = current.comment_flavor;
let live: Vec<&CommentSnapshot> = current
.comments
.iter()
.filter(|comment| {
current
.slides
.iter()
.any(|slide| slide.id == comment.slide_id)
})
.collect();
let source: HashMap<_, _> = package
.comments
.iter()
.enumerate()
.filter(|(_, comment)| comment.flavor == flavor)
.map(|(index, comment)| (seeded_comment_id(index, &comment.id), comment))
.collect();
let mut authors: Vec<CommentAuthorWrite> = if live.is_empty() || source.is_empty() {
Vec::new()
} else {
package
.comment_authors
.iter()
.map(|author| CommentAuthorWrite {
id: author.id.clone(),
name: author.name.clone(),
initials: author.initials.clone(),
last_index: author.last_index.unwrap_or_default(),
color_index: author.color_index.unwrap_or_default(),
user_id: author.user_id.clone(),
provider_id: author.provider_id.clone(),
})
.collect()
};
let mut per_slide: Vec<(CommentSlide, Vec<CommentWrite>)> = current
.slides
.iter()
.enumerate()
.map(|(index, slide)| {
let target = match slide
.source_part_path
.clone()
.filter(|_| baseline.slides.iter().any(|base| base.id == slide.id))
{
Some(part_path) => CommentSlide::Existing(part_path),
None => CommentSlide::Added(index),
};
(target, Vec::new())
})
.collect();
for comment in live.iter().filter(|comment| comment.parent_id.is_none()) {
let Some(index) = current
.slides
.iter()
.position(|slide| slide.id == comment.slide_id)
else {
continue;
};
let mut write = preserved_comment(&mut authors, flavor, comment, &source);
for reply in live
.iter()
.filter(|reply| reply.parent_id.as_deref() == Some(comment.id.as_str()))
{
write
.replies
.push(preserved_comment(&mut authors, flavor, reply, &source));
}
if let Some((_, slide_comments)) = per_slide.get_mut(index) {
slide_comments.push(write);
}
}
Some(CommentsWrite {
flavor,
authors,
per_slide: per_slide
.into_iter()
.enumerate()
.filter_map(|(index, entry)| {
let slide_id = ¤t.slides[index].id;
let unchanged = flavor == baseline.comment_flavor
&& current
.comments
.iter()
.filter(|comment| &comment.slide_id == slide_id)
.eq(baseline
.comments
.iter()
.filter(|comment| &comment.slide_id == slide_id));
(!unchanged).then_some(entry)
})
.collect(),
})
}
fn preserved_comment(
authors: &mut Vec<CommentAuthorWrite>,
flavor: CommentFlavor,
comment: &CommentSnapshot,
source: &HashMap<String, &pptx_parse::Comment>,
) -> CommentWrite {
let Some(original) = source.get(&comment.id) else {
for author in authors.iter_mut() {
author.last_index = source
.values()
.filter(|item| item.author_id == author.id)
.filter_map(|item| item.id.rsplit_once(':')?.1.parse::<u32>().ok())
.fold(author.last_index, u32::max);
}
return charge_author(authors, flavor, comment);
};
let index = original
.id
.rsplit_once(':')
.and_then(|(_, index)| index.parse().ok())
.unwrap_or(0);
let mut write = comment_write(comment, original.author_id.clone(), index);
write.id = original.id.clone();
if comment.resolved == (original.status.as_deref() == Some("resolved")) {
write.status = original.status.clone();
} else if !comment.resolved {
write.status = Some("active".to_owned());
}
write
}
fn charge_author(
authors: &mut Vec<CommentAuthorWrite>,
flavor: CommentFlavor,
comment: &CommentSnapshot,
) -> CommentWrite {
let slot = match authors
.iter()
.position(|known| known.name == comment.author && known.initials == comment.initials)
{
Some(slot) => slot,
None => {
let slot = authors.len();
let id = match flavor {
CommentFlavor::Legacy => authors
.iter()
.filter_map(|author| author.id.parse::<u32>().ok())
.max()
.map_or(0, |id| id + 1)
.to_string(),
CommentFlavor::Modern => {
derived_guid(&format!("author:{}:{}", comment.author, comment.initials))
}
};
authors.push(CommentAuthorWrite {
id,
name: comment.author.clone(),
initials: comment.initials.clone(),
last_index: 0,
color_index: slot as u32,
user_id: None,
provider_id: None,
});
slot
}
};
authors[slot].last_index += 1;
comment_write(comment, authors[slot].id.clone(), authors[slot].last_index)
}
fn comment_write(comment: &CommentSnapshot, author_id: String, index: u32) -> CommentWrite {
CommentWrite {
id: derived_guid(&comment.id),
author_id,
index,
text: comment.text.clone(),
created: comment.created.clone(),
x_emu: comment.x_emu,
y_emu: comment.y_emu,
status: comment.resolved.then(|| "resolved".to_owned()),
replies: Vec::new(),
}
}
fn source_part_path(slide: &SlideSnapshot) -> EditResult<String> {
slide
.source_part_path
.clone()
.ok_or_else(|| EditError::Write(format!("slide {} has no source part", slide.id)))
}
fn source_shapes<'a>(package: &'a PptxPackage, slide: &SlideSnapshot) -> &'a [ShapeNode] {
slide
.source_part_path
.as_deref()
.and_then(|path| {
package
.slides
.iter()
.find(|source| source.part_path == path)
})
.map(|source| source.shapes.as_slice())
.unwrap_or_default()
}
fn shape_writes(
current: &[ShapeSnapshot],
baseline: &[ShapeSnapshot],
source: &[ShapeNode],
autofit: &HashMap<String, TextAutofit>,
context: &SlideParents<'_>,
) -> EditResult<Vec<ShapeWrite>> {
let baseline_shapes: HashMap<&str, &ShapeSnapshot> = baseline
.iter()
.map(|shape| (shape.id.as_str(), shape))
.collect();
let mut writes = Vec::with_capacity(current.len());
for shape in current {
let write = match baseline_shapes.get(shape.id.as_str()) {
Some(base) if *base == shape => ShapeWrite::Keep {
source_index: addressed_source_index(shape, source)?,
},
Some(base) => {
let index = addressed_source_index(shape, source)?;
ShapeWrite::Patch {
source_index: index,
patch: Box::new(shape_patch(
shape,
base,
&source[index],
group_children(source, index),
autofit,
context,
)?),
}
}
None => ShapeWrite::Add(Box::new(shape_add(shape)?)),
};
writes.push(write);
}
Ok(writes)
}
fn addressed_source_index(shape: &ShapeSnapshot, source: &[ShapeNode]) -> EditResult<usize> {
let index = source_index(&shape.id)?;
match source.get(index) {
Some(node) if node.id() == shape.source_id => Ok(index),
_ => Err(EditError::Write(format!(
"shape {} no longer addresses source shape {}: this update was seeded by a \
different parse of the file and has to be re-seeded from it",
shape.id, shape.source_id
))),
}
}
fn group_children(source: &[ShapeNode], index: usize) -> &[ShapeNode] {
match source.get(index) {
Some(ShapeNode::Group(group)) => &group.children,
_ => &[],
}
}
fn source_index(shape_id: &str) -> EditResult<usize> {
shape_id
.rfind(":shape:")
.map(|position| &shape_id[position + ":shape:".len()..])
.and_then(|path| path.rsplit('.').next())
.and_then(|segment| segment.parse().ok())
.ok_or_else(|| EditError::Write(format!("shape {shape_id} has no source index")))
}
fn shape_patch(
shape: &ShapeSnapshot,
base: &ShapeSnapshot,
source: &ShapeNode,
source_children: &[ShapeNode],
autofit: &HashMap<String, TextAutofit>,
context: &SlideParents<'_>,
) -> EditResult<ShapePatch> {
let mut patch = ShapePatch::default();
if shape.placeholder != base.placeholder {
patch.placeholder = shape.placeholder.clone();
}
let source_inherited = base.width <= 0 || base.height <= 0;
let materialized = source_inherited && shape.width > 0 && shape.height > 0;
let moved = materialized || (shape.x, shape.y) != (base.x, base.y);
let resized = (shape.width, shape.height) != (base.width, base.height);
if moved || resized {
patch.offset = moved.then_some((shape.x, shape.y));
patch.extent = resized.then_some((shape.width, shape.height));
patch.materializes = base.inherited.is_some();
patch.inherited = source_inherited.then(|| {
base.inherited
.filter(|_| !materialized)
.map(|transform| InheritedTransform {
x: transform.x,
y: transform.y,
width: transform.width,
height: transform.height,
rotation_deg: transform.rotation_deg,
flip_horizontal: transform.flip_h,
flip_vertical: transform.flip_v,
})
.unwrap_or(InheritedTransform {
x: shape.x,
y: shape.y,
width: shape.width,
height: shape.height,
rotation_deg: shape.rotation_deg,
flip_horizontal: shape.flip_h,
flip_vertical: shape.flip_v,
})
});
}
if shape.fill != base.fill {
patch.fill = Some(
shape
.fill
.clone()
.unwrap_or_else(|| ShapeFill::named("none")),
);
}
if shape.outline != base.outline {
patch.outline = Some(shape.outline.clone().unwrap_or_default());
}
if shape.adjust_values != base.adjust_values {
patch.adjust_values = Some(shape.adjust_values.clone());
}
patch.table_structure = shape.table_structure.clone();
let baseline_stories: HashMap<&str, &StorySnapshot> = base
.text_stories
.iter()
.map(|story| (story.id.as_str(), story))
.collect();
for (index, story) in shape.text_stories.iter().enumerate() {
let mapped_base = shape.table_structure.as_ref().and_then(|structure| {
let columns = structure.grid.len();
let &[row, cell] = structure
.rows
.get(index / columns)?
.cells
.get(index % columns)?;
let old = baseline_stories
.get(format!("story:{}:table:{row}:{cell}", base.id).as_str())
.copied()?;
let mut mapped = old.clone();
mapped.id.clone_from(&story.id);
for paragraph in &mut mapped.paragraphs {
paragraph.id = paragraph.id.replacen(
&format!("para:{}:", old.id),
&format!("para:{}:", story.id),
1,
);
}
Some(mapped)
});
let base_story = mapped_base
.as_ref()
.or_else(|| baseline_stories.get(story.id.as_str()).copied());
if base_story == Some(story) {
continue;
}
let target = text_target(&story.id, &shape.id)?;
let owned_autofit = node_autofit(source);
let inherited_autofit = inherited_autofit(shape, context);
let autofit = autofit
.get(&story.id)
.filter(|value| {
matches!(target, TextTarget::TableCell { .. })
|| matches!(owned_autofit, Some(TextAutofit::Normal { .. }))
|| autofit_values(Some(*value)) != autofit_values(inherited_autofit)
})
.cloned();
patch.texts.push(TextWrite {
target,
autofit,
paragraphs: paragraph_writes(story, base_story)?,
});
}
if shape.children != base.children {
patch.children = shape_writes(
&shape.children,
&base.children,
source_children,
autofit,
context,
)?;
}
Ok(patch)
}
fn node_autofit(node: &ShapeNode) -> Option<&TextAutofit> {
match node {
ShapeNode::Shape(shape) => shape.text.as_ref()?.autofit.as_ref(),
_ => None,
}
}
fn inherited_autofit<'a>(
shape: &ShapeSnapshot,
context: &SlideParents<'a>,
) -> Option<&'a TextAutofit> {
let placeholder = shape.placeholder.as_ref()?;
[
context.layout.map(|layout| layout.shapes.as_slice()),
context.master.map(|master| master.shapes.as_slice()),
]
.into_iter()
.flatten()
.find_map(|nodes| find_placeholder(nodes, placeholder).and_then(node_autofit))
}
fn autofit_values(autofit: Option<&TextAutofit>) -> (f64, f64) {
match autofit {
Some(TextAutofit::Normal {
font_scale,
line_space_reduction,
}) => (
font_scale.unwrap_or(1.0),
line_space_reduction.unwrap_or(0.0),
),
_ => (1.0, 0.0),
}
}
pub(crate) fn text_target(story_id: &str, shape_id: &str) -> EditResult<TextTarget> {
let suffix = story_id
.strip_prefix("story:")
.and_then(|rest| rest.strip_prefix(shape_id))
.and_then(|rest| rest.strip_prefix(':'))
.ok_or_else(|| EditError::Write(format!("story {story_id} does not match its shape")))?;
if let Some(cell) = suffix.strip_prefix("table:") {
let (row, cell) = cell
.split_once(':')
.and_then(|(row, cell)| {
Some((row.parse().ok()?, cell.split(':').next()?.parse().ok()?))
})
.ok_or_else(|| EditError::Write(format!("story {story_id} has no table address")))?;
return Ok(TextTarget::TableCell { row, cell });
}
Ok(TextTarget::Body)
}
fn paragraph_writes(
story: &StorySnapshot,
baseline: Option<&StorySnapshot>,
) -> EditResult<Vec<ParagraphWrite>> {
let baseline_paragraphs: HashMap<&str, &ParagraphSnapshot> = baseline
.into_iter()
.flat_map(|story| &story.paragraphs)
.map(|paragraph| (paragraph.id.as_str(), paragraph))
.collect();
let prefix = format!("para:{}:", story.id);
let mut writes = Vec::with_capacity(story.paragraphs.len());
for paragraph in &story.paragraphs {
let source_index = paragraph
.id
.strip_prefix(&prefix)
.and_then(|index| index.parse::<usize>().ok())
.filter(|_| paragraph.preserved_xml.is_none());
let base = baseline_paragraphs.get(paragraph.id.as_str()).copied();
if base == Some(paragraph) && source_index.is_some() {
writes.push(ParagraphWrite {
source_index,
source_paragraphs: Vec::new(),
preserved_xml: None,
rebuild: false,
properties_changed: false,
alignment: None,
level: 0,
bullet: None,
runs: Vec::new(),
});
continue;
}
let properties_changed = match base {
Some(base) => {
base.alignment != paragraph.alignment
|| base.level != paragraph.level
|| base.bullet_json != paragraph.bullet_json
}
None => true,
};
let bullet = paragraph
.bullet_json
.as_deref()
.map(serde_json::from_str::<Bullet>)
.transpose()
.map_err(|error| EditError::Json(error.to_string()))?;
writes.push(ParagraphWrite {
source_index,
source_paragraphs: paragraph
.source_paragraphs
.iter()
.map(|source| source.xml.clone())
.collect(),
preserved_xml: paragraph.preserved_xml.clone(),
rebuild: true,
properties_changed,
alignment: paragraph.alignment.clone(),
level: paragraph.level,
bullet,
runs: paragraph.runs.iter().map(run_write).collect(),
});
}
Ok(writes)
}
fn run_write(run: &TextRunSnapshot) -> RunWrite {
RunWrite {
text: run.text.clone(),
properties: RunProperties {
font_size_pt: run.style.font_size_pt,
spacing_pt: run.style.spacing_pt,
baseline_pct: run.style.baseline_pct,
kern_pt: run.style.kern_pt,
bold: run.style.bold,
italic: run.style.italic,
underline: run.style.underline.clone(),
caps: run.style.caps,
font_family: run.style.font_family.clone(),
color: run.style.color.as_deref().map(color_from_hex),
language: None,
hyperlink_relationship_id: None,
effects: None,
},
}
}
fn color_from_hex(color: &str) -> ColorValue {
ColorValue {
rgb: Some(
color
.strip_prefix('#')
.unwrap_or(color)
.to_ascii_uppercase(),
),
..ColorValue::default()
}
}
fn shape_add(shape: &ShapeSnapshot) -> EditResult<ShapeAdd> {
if shape.kind == ShapeKind::Picture {
return picture_shape_add(shape);
}
if shape.kind != ShapeKind::Shape {
return Err(EditError::Write(format!(
"shape {} cannot be written as a new shape",
shape.id
)));
}
let paragraphs = shape
.text_stories
.first()
.map(|story| paragraph_writes(story, None))
.transpose()?;
Ok(ShapeAdd {
name: shape.name.clone(),
geometry: shape.geometry.clone(),
text_box: shape.text_box,
x: shape.x,
y: shape.y,
width: shape.width,
height: shape.height,
adjust_values: shape.adjust_values.clone(),
fill: shape.fill.clone(),
outline: shape.outline.clone(),
paragraphs,
picture: None,
})
}
fn picture_shape_add(shape: &ShapeSnapshot) -> EditResult<ShapeAdd> {
let pending = shape.pending_media.as_ref().ok_or_else(|| {
EditError::Write(format!(
"shape {} is a picture but carries no pending image data",
shape.id
))
})?;
let media_bytes = base64::engine::general_purpose::STANDARD
.decode(&pending.base64)
.map_err(|error| EditError::Write(format!("invalid pending image data: {error}")))?;
Ok(ShapeAdd {
name: shape.name.clone(),
geometry: "rect".to_owned(),
text_box: false,
x: shape.x,
y: shape.y,
width: shape.width,
height: shape.height,
adjust_values: BTreeMap::new(),
fill: None,
outline: None,
paragraphs: None,
picture: Some(PictureAdd {
media_bytes,
content_type: pending.content_type.clone(),
}),
})
}