1use std::collections::HashMap;
5
6use ooxml_drawingml::{ColorValue, ShapeFill};
7use pptx_parse::{
8 Bullet, DeckWrite, InheritedTransform, ParagraphWrite, Placeholder, PptxPackage, RunProperties,
9 RunWrite, ShapeAdd, ShapeNode, ShapePatch, ShapeTransform, ShapeWrite, SlideLayout,
10 SlideMaster, SlideWrite, TextTarget, TextWrite,
11};
12
13use crate::deck::{seed_doc, snapshot_doc};
14use crate::{
15 BOOTSTRAP_CLIENT_ID, DeckSession, DeckSnapshot, EditError, EditResult, ParagraphSnapshot,
16 ShapeKind, ShapeSnapshot, SlideSnapshot, StorySnapshot, TextRunSnapshot, doc_with_client_id,
17};
18
19struct SlideContext<'a> {
21 layout: Option<&'a SlideLayout>,
22 master: Option<&'a SlideMaster>,
23}
24
25impl<'a> SlideContext<'a> {
26 fn new(package: &'a PptxPackage, snapshot: &SlideSnapshot) -> Self {
27 let layout = snapshot
28 .layout_part_path
29 .as_deref()
30 .and_then(|path| {
31 package
32 .layouts
33 .iter()
34 .find(|layout| layout.part_path == path)
35 })
36 .or_else(|| package.layouts.first());
37 let master = layout
38 .and_then(|layout| layout.master_part_path.as_deref())
39 .and_then(|path| {
40 package
41 .masters
42 .iter()
43 .find(|master| master.part_path == path)
44 })
45 .or_else(|| {
46 layout.and_then(|layout| {
47 package.masters.iter().find(|master| {
48 master
49 .layout_part_paths
50 .iter()
51 .any(|path| path == &layout.part_path)
52 })
53 })
54 })
55 .or_else(|| package.masters.first());
56 Self { layout, master }
57 }
58}
59
60impl DeckSession {
61 pub fn save(&self) -> EditResult<Vec<u8>> {
64 if !self.package.has_parts() {
65 return Err(EditError::Write(
66 "this session carries no source file bytes; open it from the \
67 file, or from the update plus the source file, to save"
68 .to_owned(),
69 ));
70 }
71 let current = self.snapshot()?;
72 let baseline = baseline_snapshot(&self.package)?;
73 if current == baseline {
74 return pptx_parse::write_pptx(&self.package)
75 .map_err(|error| EditError::Write(error.to_string()));
76 }
77 let deck = deck_write(¤t, &baseline, &self.package)?;
78 pptx_parse::write_pptx_with_edits(&self.package, &deck)
79 .map_err(|error| EditError::Write(error.to_string()))
80 }
81}
82
83fn baseline_snapshot(package: &PptxPackage) -> EditResult<DeckSnapshot> {
84 let doc = doc_with_client_id(BOOTSTRAP_CLIENT_ID);
85 seed_doc(&doc, package, "")?;
86 snapshot_doc(&doc, package)
87}
88
89fn deck_write(
90 current: &DeckSnapshot,
91 baseline: &DeckSnapshot,
92 package: &PptxPackage,
93) -> EditResult<DeckWrite> {
94 let baseline_slides: HashMap<&str, &SlideSnapshot> = baseline
95 .slides
96 .iter()
97 .map(|slide| (slide.id.as_str(), slide))
98 .collect();
99 let mut slides = Vec::with_capacity(current.slides.len());
100 for slide in ¤t.slides {
101 let write = match baseline_slides.get(slide.id.as_str()) {
102 Some(base) if *base == slide => SlideWrite::Keep {
103 part_path: source_part_path(slide)?,
104 },
105 Some(base) => SlideWrite::Patch {
106 part_path: source_part_path(slide)?,
107 shapes: shape_writes(
108 &slide.shapes,
109 &base.shapes,
110 &SlideContext::new(package, slide),
111 )?,
112 },
113 None => SlideWrite::Add {
114 name: slide.name.clone(),
115 layout_part_path: slide.layout_part_path.clone(),
116 shapes: slide
117 .shapes
118 .iter()
119 .map(shape_add)
120 .collect::<EditResult<Vec<_>>>()?,
121 },
122 };
123 slides.push(write);
124 }
125 Ok(DeckWrite { slides })
126}
127
128fn source_part_path(slide: &SlideSnapshot) -> EditResult<String> {
129 slide
130 .source_part_path
131 .clone()
132 .ok_or_else(|| EditError::Write(format!("slide {} has no source part", slide.id)))
133}
134
135fn shape_writes(
136 current: &[ShapeSnapshot],
137 baseline: &[ShapeSnapshot],
138 context: &SlideContext<'_>,
139) -> EditResult<Vec<ShapeWrite>> {
140 let baseline_shapes: HashMap<&str, &ShapeSnapshot> = baseline
141 .iter()
142 .map(|shape| (shape.id.as_str(), shape))
143 .collect();
144 let mut writes = Vec::with_capacity(current.len());
145 for shape in current {
146 let write = match baseline_shapes.get(shape.id.as_str()) {
147 Some(base) if *base == shape => ShapeWrite::Keep {
148 source_index: source_index(&shape.id)?,
149 },
150 Some(base) => ShapeWrite::Patch {
151 source_index: source_index(&shape.id)?,
152 patch: Box::new(shape_patch(shape, base, context)?),
153 },
154 None => ShapeWrite::Add(Box::new(shape_add(shape)?)),
155 };
156 writes.push(write);
157 }
158 Ok(writes)
159}
160
161fn source_index(shape_id: &str) -> EditResult<usize> {
162 shape_id
163 .rfind(":shape:")
164 .map(|position| &shape_id[position + ":shape:".len()..])
165 .and_then(|path| path.rsplit('.').next())
166 .and_then(|segment| segment.parse().ok())
167 .ok_or_else(|| EditError::Write(format!("shape {shape_id} has no source index")))
168}
169
170fn shape_patch(
171 shape: &ShapeSnapshot,
172 base: &ShapeSnapshot,
173 context: &SlideContext<'_>,
174) -> EditResult<ShapePatch> {
175 let mut patch = ShapePatch::default();
176 let moved = (shape.x, shape.y) != (base.x, base.y);
177 let resized = (shape.width, shape.height) != (base.width, base.height);
178 if moved || resized {
179 patch.offset = moved.then_some((shape.x, shape.y));
180 patch.extent = resized.then_some((shape.width, shape.height));
181 let source_inherited = base.width <= 0 || base.height <= 0;
182 patch.inherited = source_inherited.then(|| {
183 inherited_transform(shape, context)
184 .map(|transform| InheritedTransform {
185 x: transform.x,
186 y: transform.y,
187 width: transform.width,
188 height: transform.height,
189 rotation_deg: transform.rotation_deg,
190 flip_horizontal: transform.flip_h,
191 flip_vertical: transform.flip_v,
192 })
193 .unwrap_or(InheritedTransform {
194 x: shape.x,
195 y: shape.y,
196 width: shape.width,
197 height: shape.height,
198 rotation_deg: shape.rotation_deg,
199 flip_horizontal: shape.flip_h,
200 flip_vertical: shape.flip_v,
201 })
202 });
203 }
204 if shape.fill != base.fill {
205 patch.fill = Some(
206 shape
207 .fill
208 .clone()
209 .unwrap_or_else(|| ShapeFill::named("none")),
210 );
211 }
212 if shape.outline != base.outline {
213 patch.outline = Some(shape.outline.clone().unwrap_or_default());
214 }
215 if shape.adjust_values != base.adjust_values {
216 patch.adjust_values = Some(shape.adjust_values.clone());
217 }
218 let baseline_stories: HashMap<&str, &StorySnapshot> = base
219 .text_stories
220 .iter()
221 .map(|story| (story.id.as_str(), story))
222 .collect();
223 for story in &shape.text_stories {
224 let base_story = baseline_stories.get(story.id.as_str()).copied();
225 if base_story == Some(story) {
226 continue;
227 }
228 patch.texts.push(TextWrite {
229 target: text_target(&story.id, &shape.id)?,
230 paragraphs: paragraph_writes(story, base_story)?,
231 });
232 }
233 if shape.children != base.children {
234 patch.children = shape_writes(&shape.children, &base.children, context)?;
235 }
236 Ok(patch)
237}
238
239fn inherited_transform<'a>(
243 shape: &ShapeSnapshot,
244 context: &SlideContext<'a>,
245) -> Option<&'a ShapeTransform> {
246 let layout = shape.placeholder.as_ref().and_then(|placeholder| {
247 context
248 .layout
249 .and_then(|layout| find_placeholder(&layout.shapes, placeholder))
250 });
251 let master = shape.placeholder.as_ref().and_then(|placeholder| {
252 context
253 .master
254 .and_then(|master| find_placeholder(&master.shapes, placeholder))
255 });
256 [layout, master]
257 .into_iter()
258 .flatten()
259 .map(node_transform)
260 .find(|transform| transform.width > 0 && transform.height > 0)
261}
262
263fn find_placeholder<'a>(nodes: &'a [ShapeNode], target: &Placeholder) -> Option<&'a ShapeNode> {
264 for node in nodes {
265 if node_placeholder(node).is_some_and(|value| placeholders_match(value, target)) {
266 return Some(node);
267 }
268 if let ShapeNode::Group(group) = node
269 && let Some(found) = find_placeholder(&group.children, target)
270 {
271 return Some(found);
272 }
273 }
274 None
275}
276
277fn placeholders_match(left: &Placeholder, right: &Placeholder) -> bool {
278 match (left.index, right.index) {
279 (Some(left), Some(right)) => left == right,
280 _ => {
281 normalize_placeholder_type(left.placeholder_type.as_deref())
282 == normalize_placeholder_type(right.placeholder_type.as_deref())
283 }
284 }
285}
286
287fn normalize_placeholder_type(value: Option<&str>) -> &str {
288 match value.unwrap_or("body") {
289 "ctrTitle" => "title",
290 "obj" => "body",
291 value => value,
292 }
293}
294
295fn node_placeholder(node: &ShapeNode) -> Option<&Placeholder> {
296 node_base(node).placeholder.as_ref()
297}
298
299fn node_transform(node: &ShapeNode) -> &ShapeTransform {
300 &node_base(node).transform
301}
302
303fn node_base(node: &ShapeNode) -> &pptx_parse::ShapeBase {
304 match node {
305 ShapeNode::Shape(shape) => &shape.base,
306 ShapeNode::Picture(shape) => &shape.base,
307 ShapeNode::GraphicFrame(shape) => &shape.base,
308 ShapeNode::Group(shape) => &shape.base,
309 }
310}
311
312fn text_target(story_id: &str, shape_id: &str) -> EditResult<TextTarget> {
313 let suffix = story_id
314 .strip_prefix("story:")
315 .and_then(|rest| rest.strip_prefix(shape_id))
316 .and_then(|rest| rest.strip_prefix(':'))
317 .ok_or_else(|| EditError::Write(format!("story {story_id} does not match its shape")))?;
318 if let Some(cell) = suffix.strip_prefix("table:") {
319 let (row, cell) = cell
320 .split_once(':')
321 .and_then(|(row, cell)| Some((row.parse().ok()?, cell.parse().ok()?)))
322 .ok_or_else(|| EditError::Write(format!("story {story_id} has no table address")))?;
323 return Ok(TextTarget::TableCell { row, cell });
324 }
325 Ok(TextTarget::Body)
326}
327
328fn paragraph_writes(
329 story: &StorySnapshot,
330 baseline: Option<&StorySnapshot>,
331) -> EditResult<Vec<ParagraphWrite>> {
332 let baseline_paragraphs: HashMap<&str, &ParagraphSnapshot> = baseline
333 .into_iter()
334 .flat_map(|story| &story.paragraphs)
335 .map(|paragraph| (paragraph.id.as_str(), paragraph))
336 .collect();
337 let prefix = format!("para:{}:", story.id);
338 let mut writes = Vec::with_capacity(story.paragraphs.len());
339 for paragraph in &story.paragraphs {
340 let source_index = paragraph
341 .id
342 .strip_prefix(&prefix)
343 .and_then(|index| index.parse::<usize>().ok());
344 let base = baseline_paragraphs.get(paragraph.id.as_str()).copied();
345 if base == Some(paragraph) && source_index.is_some() {
346 writes.push(ParagraphWrite {
347 source_index,
348 rebuild: false,
349 properties_changed: false,
350 alignment: None,
351 level: 0,
352 bullet: None,
353 runs: Vec::new(),
354 });
355 continue;
356 }
357 let properties_changed = match base {
358 Some(base) => {
359 base.alignment != paragraph.alignment
360 || base.level != paragraph.level
361 || base.bullet_json != paragraph.bullet_json
362 }
363 None => true,
364 };
365 let bullet = paragraph
366 .bullet_json
367 .as_deref()
368 .map(serde_json::from_str::<Bullet>)
369 .transpose()
370 .map_err(|error| EditError::Json(error.to_string()))?;
371 writes.push(ParagraphWrite {
372 source_index,
373 rebuild: true,
374 properties_changed,
375 alignment: paragraph.alignment.clone(),
376 level: paragraph.level,
377 bullet,
378 runs: paragraph.runs.iter().map(run_write).collect(),
379 });
380 }
381 Ok(writes)
382}
383
384fn run_write(run: &TextRunSnapshot) -> RunWrite {
385 RunWrite {
386 text: run.text.clone(),
387 properties: RunProperties {
388 font_size_pt: run.style.font_size_pt,
389 bold: run.style.bold,
390 italic: run.style.italic,
391 underline: run.style.underline.clone(),
392 font_family: run.style.font_family.clone(),
393 color: run.style.color.as_deref().map(color_from_hex),
394 language: None,
395 hyperlink_relationship_id: None,
396 },
397 }
398}
399
400fn color_from_hex(color: &str) -> ColorValue {
401 ColorValue {
402 rgb: Some(
403 color
404 .strip_prefix('#')
405 .unwrap_or(color)
406 .to_ascii_uppercase(),
407 ),
408 ..ColorValue::default()
409 }
410}
411
412fn shape_add(shape: &ShapeSnapshot) -> EditResult<ShapeAdd> {
413 if shape.kind != ShapeKind::Shape {
414 return Err(EditError::Write(format!(
415 "shape {} cannot be written as a new shape",
416 shape.id
417 )));
418 }
419 let paragraphs = shape
420 .text_stories
421 .first()
422 .map(|story| paragraph_writes(story, None))
423 .transpose()?;
424 Ok(ShapeAdd {
425 name: shape.name.clone(),
426 geometry: shape.geometry.clone(),
427 x: shape.x,
428 y: shape.y,
429 width: shape.width,
430 height: shape.height,
431 adjust_values: shape.adjust_values.clone(),
432 fill: shape.fill.clone(),
433 outline: shape.outline.clone(),
434 paragraphs,
435 })
436}