1use std::collections::{BTreeMap, HashMap, HashSet};
6use std::ops::Range;
7
8use ooxml_drawingml::{
9 ColorValue, GradientFill, ShapeFill, ShapeOutline, Theme, resolve_color_value_to_hex_with_theme,
10};
11
12use crate::PptxError;
13use crate::comments::{CommentFlavor, CommentSlide, CommentsWrite, authors_xml, comments_xml};
14use crate::drawing::parse_run_properties;
15use crate::model::{
16 Bullet, PptxPackage, RunProperties, ShapeElements, SlideReference, TextAutofit,
17};
18use crate::xml::{
19 DRAWINGML_NS, PRESENTATIONML_NS, ParseBudget, ParseLimits, XmlElement, XmlNode,
20 alternate_content_branch_index, parse_xml, serialize_xml, serialize_xml_fragment,
21};
22
23const OFFICE_RELATIONSHIPS_NS: &str =
24 "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
25const SLIDE_RELATIONSHIP_TYPE: &str =
26 "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide";
27const SLIDE_LAYOUT_RELATIONSHIP_TYPE: &str =
28 "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout";
29const STRICT_SLIDE_LAYOUT_RELATIONSHIP_TYPE: &str =
30 "http://purl.oclc.org/ooxml/officeDocument/relationships/slideLayout";
31const SLIDE_CONTENT_TYPE: &str =
32 "application/vnd.openxmlformats-officedocument.presentationml.slide+xml";
33const IMAGE_RELATIONSHIP_TYPE: &str =
34 "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
35const MIN_SLIDE_ID: u32 = 256;
36const MAX_SLIDE_ID: u32 = 2_147_483_647;
37
38pub struct DeckWrite {
40 pub slides: Vec<SlideWrite>,
41 pub comments: Option<CommentsWrite>,
42 pub notes: Option<NotesWrite>,
43}
44
45pub struct NotesWrite {
47 pub per_slide: Vec<(CommentSlide, String)>,
48}
49
50const NOTES_SLIDE_RELATIONSHIP_TYPE: &str =
51 "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide";
52
53pub enum SlideWrite {
54 Keep { part_path: String },
56 Patch {
58 part_path: String,
59 shapes: Vec<ShapeWrite>,
60 background: Option<BackgroundWrite>,
61 layout_part_path: Option<String>,
62 },
63 Clone {
64 part_path: String,
65 shapes: Vec<ShapeWrite>,
66 background: Option<BackgroundWrite>,
67 layout_part_path: Option<String>,
68 },
69 Add {
71 name: Option<String>,
72 layout_part_path: Option<String>,
73 shapes: Vec<ShapeAdd>,
74 background: Option<Box<ShapeFill>>,
75 },
76}
77
78#[derive(Clone, Debug, PartialEq)]
81pub enum BackgroundWrite {
82 Set(Box<ShapeFill>),
83 Clear,
84}
85
86pub enum ShapeWrite {
87 Keep {
90 source_index: usize,
91 },
92 Patch {
93 source_index: usize,
94 patch: Box<ShapePatch>,
95 },
96 Add(Box<ShapeAdd>),
97}
98
99#[derive(Default)]
100pub struct ShapePatch {
101 pub placeholder: Option<crate::Placeholder>,
102 pub offset: Option<(i64, i64)>,
105 pub extent: Option<(i64, i64)>,
106 pub inherited: Option<InheritedTransform>,
108 pub materializes: bool,
111 pub fill: Option<ShapeFill>,
112 pub outline: Option<ShapeOutline>,
114 pub adjust_values: Option<BTreeMap<String, f64>>,
115 pub table_structure: Option<TableStructure>,
116 pub texts: Vec<TextWrite>,
117 pub children: Vec<ShapeWrite>,
119}
120
121#[derive(Clone, Copy)]
123pub struct InheritedTransform {
124 pub x: i64,
125 pub y: i64,
126 pub width: i64,
127 pub height: i64,
128 pub rotation_deg: f64,
129 pub flip_horizontal: bool,
130 pub flip_vertical: bool,
131}
132
133#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
134#[serde(rename_all = "camelCase", deny_unknown_fields)]
135pub struct TableStructure {
136 pub generation: String,
137 pub story_keys: Vec<String>,
138 pub grid: Vec<i64>,
139 pub columns: Vec<usize>,
140 pub copied_columns: Vec<bool>,
141 pub rows: Vec<TableRowSource>,
142}
143
144#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
145#[serde(rename_all = "camelCase", deny_unknown_fields)]
146pub struct TableRowSource {
147 pub source_row: usize,
148 pub copied: bool,
149 pub copied_cells: Vec<bool>,
150 pub height: i64,
151 pub cells: Vec<[usize; 2]>,
152}
153
154pub struct TextWrite {
155 pub target: TextTarget,
156 pub autofit: Option<TextAutofit>,
157 pub paragraphs: Vec<ParagraphWrite>,
158}
159
160pub enum TextTarget {
161 Body,
162 TableCell { row: usize, cell: usize },
163}
164
165pub struct ParagraphWrite {
166 pub source_index: Option<usize>,
168 pub source_paragraphs: Vec<Option<String>>,
169 pub preserved_xml: Option<String>,
170 pub rebuild: bool,
172 pub properties_changed: bool,
173 pub alignment: Option<String>,
174 pub level: u32,
175 pub bullet: Option<Bullet>,
176 pub runs: Vec<RunWrite>,
177}
178
179pub struct RunWrite {
180 pub text: String,
181 pub properties: RunProperties,
182}
183
184pub struct ShapeAdd {
185 pub name: String,
186 pub geometry: String,
187 pub text_box: bool,
188 pub x: i64,
189 pub y: i64,
190 pub width: i64,
191 pub height: i64,
192 pub adjust_values: BTreeMap<String, f64>,
193 pub fill: Option<ShapeFill>,
194 pub outline: Option<ShapeOutline>,
195 pub paragraphs: Option<Vec<ParagraphWrite>>,
196 pub picture: Option<PictureAdd>,
198}
199
200pub struct PictureAdd {
201 pub media_bytes: Vec<u8>,
202 pub content_type: String,
203}
204
205pub fn write_pptx_with_edits(
208 package: &PptxPackage,
209 deck: &DeckWrite,
210) -> Result<Vec<u8>, PptxError> {
211 let limits = ParseLimits::default();
212 let mut budget = ParseBudget::new(&limits);
213
214 let final_paths: HashSet<&str> = deck
215 .slides
216 .iter()
217 .filter_map(|slide| match slide {
218 SlideWrite::Keep { part_path } | SlideWrite::Patch { part_path, .. } => {
219 Some(part_path.as_str())
220 }
221 SlideWrite::Add { .. } | SlideWrite::Clone { .. } => None,
222 })
223 .collect();
224 let removed: Vec<&SlideReference> = package
225 .presentation
226 .slides
227 .iter()
228 .filter(|reference| !final_paths.contains(reference.part_path.as_str()))
229 .collect();
230
231 let mut replacements: HashMap<String, Vec<u8>> = HashMap::new();
232 let mut new_parts: Vec<(String, Vec<u8>)> = Vec::new();
233
234 let mut minted = Vec::new();
235 let mut next_slide_number = next_slide_number(package);
236 let mut minted_slide_id: Option<u32> = None;
237 let mut next_relationship = next_relationship_number(package);
238 let mut structural = false;
239 let mut final_order: Vec<FinalSlide<'_>> = Vec::new();
240 let mut minted_by_slide: HashMap<usize, usize> = HashMap::new();
241 let mut cloned_dependencies = Vec::new();
242 {
243 let mut sink = PartSink {
244 package,
245 replacements: &mut replacements,
246 new_parts: &mut new_parts,
247 };
248
249 for slide in &deck.slides {
250 if let SlideWrite::Patch {
251 part_path,
252 shapes,
253 background,
254 layout_part_path,
255 } = slide
256 {
257 let bytes = package
258 .part_bytes(part_path)
259 .ok_or_else(|| PptxError::MissingPart(part_path.clone()))?;
260 let mut root = parse_xml(bytes, part_path, &mut budget)?;
261 let original_root = root.clone();
262 let theme =
263 crate::slide_theme(package, Some(part_path), layout_part_path.as_deref());
264 patch_slide(
265 &mut root,
266 shapes,
267 background.as_ref(),
268 Some(&theme),
269 part_path,
270 package.shape_elements,
271 &mut sink,
272 &mut budget,
273 )?;
274 if (layout_part_path.is_some() || has_placeholder_patch(shapes))
275 && background.is_none()
276 && only_placeholder_edits(shapes)
277 {
278 root.attributes.clone_from(&original_root.attributes);
279 }
280 if root != original_root {
281 let xml = if layout_part_path.is_some() || has_placeholder_patch(shapes) {
282 crate::attribute_patch::patch_attributes(bytes, &original_root, &root)
283 .unwrap_or_else(|| serialize_xml(&root))
284 } else {
285 serialize_xml(&root)
286 };
287 sink.store(part_path, xml);
288 }
289 if let Some(layout) = layout_part_path {
290 patch_layout_relationship(&mut sink, part_path, layout, &mut budget)?;
291 }
292 }
293 }
294
295 for (slide_index, slide) in deck.slides.iter().enumerate() {
296 match slide {
297 SlideWrite::Keep { part_path } | SlideWrite::Patch { part_path, .. } => {
298 let reference = package
299 .presentation
300 .slides
301 .iter()
302 .find(|reference| &reference.part_path == part_path)
303 .ok_or_else(|| write_error(part_path, "not a slide of this deck"))?;
304 final_order.push(FinalSlide::Existing(reference));
305 }
306 SlideWrite::Add { .. } | SlideWrite::Clone { .. } => {
307 structural = true;
308 let part_path = format!("ppt/slides/slide{next_slide_number}.xml");
309 next_slide_number += 1;
310 let relationship_id = format!("rId{next_relationship}");
311 next_relationship += 1;
312 let slide_id = match minted_slide_id {
313 None => next_slide_id(package)?,
314 Some(previous) => previous
315 .checked_add(1)
316 .filter(|id| *id <= MAX_SLIDE_ID)
317 .ok_or_else(|| {
318 write_error(
319 &package.presentation.part_path,
320 "the slide id space is exhausted",
321 )
322 })?,
323 };
324 minted_slide_id = Some(slide_id);
325 match slide {
326 SlideWrite::Clone {
327 part_path: source,
328 shapes,
329 background,
330 layout_part_path,
331 } => {
332 let bytes = package
333 .part_bytes(source)
334 .ok_or_else(|| PptxError::MissingPart(source.clone()))?;
335 let mut root = parse_xml(bytes, source, &mut budget)?;
336 let source_rels = slide_relationships_path(source);
337 if let Some(bytes) = package.part_bytes(&source_rels) {
338 let mut rels = parse_xml(bytes, &source_rels, &mut budget)?;
339 rels.children.retain(|child| match child {
340 XmlNode::Element(element) => {
341 element.attribute("Type").is_none_or(|kind| {
342 !kind.ends_with("/comments")
343 && !kind.ends_with("/comment")
344 })
345 }
346 _ => true,
347 });
348 sink.store(
349 &slide_relationships_path(&part_path),
350 serialize_xml(&rels),
351 );
352 }
353 cloned_dependencies.extend(
354 package
355 .relationships
356 .get(source)
357 .into_iter()
358 .flatten()
359 .filter(|relationship| {
360 !relationship.has_type("/notesSlide")
361 && !relationship.has_type("/comments")
362 && !relationship.has_type("/comment")
363 })
364 .filter_map(|relationship| {
365 relationship.resolved_target.clone()
366 }),
367 );
368 cloned_dependencies.extend(clone_notes_part(
369 &mut sink,
370 source,
371 &part_path,
372 &mut budget,
373 )?);
374 if let Some(layout) = layout_part_path {
375 patch_layout_relationship(
376 &mut sink,
377 &part_path,
378 layout,
379 &mut budget,
380 )?;
381 }
382 let theme = crate::slide_theme(
383 package,
384 Some(source),
385 layout_part_path.as_deref(),
386 );
387 patch_slide(
388 &mut root,
389 shapes,
390 background.as_ref(),
391 Some(&theme),
392 &part_path,
393 package.shape_elements,
394 &mut sink,
395 &mut budget,
396 )?;
397 sink.store(&part_path, serialize_xml(&root));
398 }
399 SlideWrite::Add {
400 name,
401 layout_part_path,
402 shapes,
403 background,
404 } => {
405 let layout = layout_part_path
406 .clone()
407 .filter(|path| {
408 package
409 .layouts
410 .iter()
411 .any(|layout| &layout.part_path == path)
412 })
413 .or_else(|| {
414 package
415 .layouts
416 .first()
417 .map(|layout| layout.part_path.clone())
418 });
419 if let Some(layout) = &layout {
420 sink.store(
421 &slide_relationships_path(&part_path),
422 slide_relationships_xml(&part_path, layout),
423 );
424 }
425 let xml = slide_xml(
426 name.as_deref(),
427 shapes,
428 background.as_deref(),
429 &part_path,
430 &mut sink,
431 &mut budget,
432 )?;
433 sink.store(&part_path, xml);
434 }
435 _ => unreachable!(),
436 }
437 minted.push(MintedSlide {
438 part_path,
439 relationship_id,
440 slide_id,
441 });
442 minted_by_slide.insert(slide_index, minted.len() - 1);
443 final_order.push(FinalSlide::Added(minted.len() - 1));
444 }
445 }
446 }
447 }
448 let original_paths: Vec<&str> = package
449 .presentation
450 .slides
451 .iter()
452 .map(|reference| reference.part_path.as_str())
453 .collect();
454 let kept_paths: Vec<&str> = final_order
455 .iter()
456 .filter_map(|slide| match slide {
457 FinalSlide::Existing(reference) => Some(reference.part_path.as_str()),
458 FinalSlide::Added(_) => None,
459 })
460 .collect();
461 structural = structural || kept_paths != original_paths;
462
463 let mut removed_paths = HashSet::new();
464 for reference in &removed {
465 removed_paths.insert(reference.part_path.clone());
466 removed_paths.insert(slide_relationships_path(&reference.part_path));
467 }
468 let orphans = orphaned_parts(package, &removed, &cloned_dependencies);
469 for orphan in &orphans {
470 removed_paths.insert(orphan.clone());
471 removed_paths.insert(slide_relationships_path(orphan));
472 }
473
474 if structural {
475 patch_structure(
476 package,
477 &final_order,
478 &minted,
479 &removed,
480 &orphans,
481 &mut replacements,
482 &mut budget,
483 )?;
484 }
485 if let Some(comments) = &deck.comments {
486 patch_comment_parts(
487 package,
488 comments,
489 &MintedSlides {
490 slides: &minted,
491 by_slide_index: &minted_by_slide,
492 },
493 &mut replacements,
494 &mut new_parts,
495 &mut removed_paths,
496 &mut budget,
497 )?;
498 }
499 if let Some(notes) = &deck.notes {
500 patch_notes_parts(
501 package,
502 notes,
503 &MintedSlides {
504 slides: &minted,
505 by_slide_index: &minted_by_slide,
506 },
507 &mut replacements,
508 &mut new_parts,
509 &mut removed_paths,
510 &mut budget,
511 )?;
512 }
513 {
514 let mut sink = PartSink {
515 package,
516 replacements: &mut replacements,
517 new_parts: &mut new_parts,
518 };
519 for slide in &deck.slides {
520 if let SlideWrite::Patch {
521 part_path,
522 layout_part_path: Some(_),
523 ..
524 } = slide
525 {
526 let path = slide_relationships_path(part_path);
527 if let (Some(source), Some(current)) =
528 (package.part_bytes(&path), sink.current(&path))
529 {
530 let before = parse_xml(source, &path, &mut budget)?;
531 let after = parse_xml(¤t, &path, &mut budget)?;
532 if after.children.len() >= before.children.len() {
533 let mut retained = after.clone();
534 retained.children.truncate(before.children.len());
535 if let Some(bytes) =
536 crate::attribute_patch::patch_attributes(source, &before, &retained)
537 .and_then(|bytes| {
538 append_relationships(
539 bytes,
540 &after.children[before.children.len()..],
541 )
542 })
543 {
544 sink.store(&path, bytes);
545 }
546 }
547 }
548 }
549 }
550 }
551 let mut parts = Vec::with_capacity(package.parts.len() + new_parts.len());
552 for part in &package.parts {
553 if removed_paths.contains(&part.path) {
554 continue;
555 }
556 match replacements.remove(&part.path) {
557 Some(bytes) => parts.push((part.path.clone(), bytes)),
558 None => parts.push((part.path.clone(), part.bytes.clone())),
559 }
560 }
561 parts.extend(new_parts);
562 ooxml_opc::rezip_parts_preserving(&parts, package.source_container.as_bytes())
563 .map_err(PptxError::Container)
564}
565
566struct MintedSlide {
567 part_path: String,
568 relationship_id: String,
569 slide_id: u32,
570}
571
572enum FinalSlide<'a> {
573 Existing(&'a SlideReference),
574 Added(usize),
575}
576
577fn write_error(part: &str, message: impl Into<String>) -> PptxError {
578 PptxError::Write {
579 part: part.to_owned(),
580 message: message.into(),
581 }
582}
583
584fn next_slide_number(package: &PptxPackage) -> usize {
585 let paths: HashSet<&str> = package
586 .parts
587 .iter()
588 .map(|part| part.path.as_str())
589 .collect();
590 let mut number = package
591 .parts
592 .iter()
593 .filter_map(|part| {
594 part.path
595 .strip_prefix("ppt/slides/slide")?
596 .strip_suffix(".xml")?
597 .parse::<usize>()
598 .ok()
599 })
600 .max()
601 .unwrap_or(0)
602 + 1;
603 while paths.contains(format!("ppt/slides/slide{number}.xml").as_str())
604 || paths.contains(format!("ppt/slides/_rels/slide{number}.xml.rels").as_str())
605 {
606 number += 1;
607 }
608 number
609}
610
611fn next_slide_id(package: &PptxPackage) -> Result<u32, PptxError> {
612 let next = package
613 .presentation
614 .slides
615 .iter()
616 .map(|reference| reference.id)
617 .max()
618 .unwrap_or(0)
619 .max(MIN_SLIDE_ID - 1)
620 .checked_add(1)
621 .filter(|id| *id <= MAX_SLIDE_ID);
622 next.ok_or_else(|| {
623 write_error(
624 &package.presentation.part_path,
625 "the slide id space is exhausted",
626 )
627 })
628}
629
630fn next_relationship_number(package: &PptxPackage) -> usize {
631 package
632 .relationships
633 .get(&package.presentation.part_path)
634 .into_iter()
635 .flatten()
636 .filter_map(|relationship| relationship.id.strip_prefix("rId")?.parse::<usize>().ok())
637 .max()
638 .unwrap_or(0)
639 + 1
640}
641
642fn slide_relationships_path(part_path: &str) -> String {
643 match part_path.rsplit_once('/') {
644 Some((directory, name)) => format!("{directory}/_rels/{name}.rels"),
645 None => format!("_rels/{part_path}.rels"),
646 }
647}
648
649fn orphaned_parts(
652 package: &PptxPackage,
653 removed: &[&SlideReference],
654 cloned_dependencies: &[String],
655) -> Vec<String> {
656 if removed.is_empty() {
657 return Vec::new();
658 }
659 let excluded: HashSet<&str> = removed
660 .iter()
661 .map(|reference| reference.part_path.as_str())
662 .collect();
663 let before = reachable_parts(package, &HashSet::new(), &[]);
664 let after = reachable_parts(package, &excluded, cloned_dependencies);
665 before
666 .into_iter()
667 .filter(|part| !after.contains(part) && !excluded.contains(part.as_str()))
668 .collect()
669}
670
671fn reachable_parts(
672 package: &PptxPackage,
673 excluded: &HashSet<&str>,
674 roots: &[String],
675) -> HashSet<String> {
676 let mut reachable: HashSet<String> = roots
677 .iter()
678 .filter(|part| !excluded.contains(part.as_str()))
679 .cloned()
680 .collect();
681 let mut queue: Vec<String> = reachable.iter().cloned().collect();
682 queue.push(String::new());
683 while let Some(source) = queue.pop() {
684 for relationship in package.relationships.get(&source).into_iter().flatten() {
685 let Some(target) = &relationship.resolved_target else {
686 continue;
687 };
688 if excluded.contains(target.as_str()) {
689 continue;
690 }
691 if reachable.insert(target.clone()) {
692 queue.push(target.clone());
693 }
694 }
695 }
696 reachable
697}
698
699fn relative_target(from_part: &str, to_part: &str) -> String {
700 let from: Vec<&str> = from_part.split('/').collect();
701 let to: Vec<&str> = to_part.split('/').collect();
702 let from_directory = &from[..from.len().saturating_sub(1)];
703 let to_directory = &to[..to.len().saturating_sub(1)];
704 let shared = from_directory
705 .iter()
706 .zip(to_directory)
707 .take_while(|(a, b)| a == b)
708 .count();
709 let mut segments: Vec<String> = vec!["..".to_owned(); from_directory.len() - shared];
710 segments.extend(to[shared..].iter().map(|segment| (*segment).to_owned()));
711 segments.join("/")
712}
713
714fn patch_structure(
717 package: &PptxPackage,
718 final_order: &[FinalSlide<'_>],
719 minted: &[MintedSlide],
720 removed: &[&SlideReference],
721 orphans: &[String],
722 replacements: &mut HashMap<String, Vec<u8>>,
723 budget: &mut ParseBudget<'_>,
724) -> Result<(), PptxError> {
725 let presentation_path = package.presentation.part_path.as_str();
726 let bytes = package
727 .part_bytes(presentation_path)
728 .ok_or_else(|| PptxError::MissingPart(presentation_path.to_owned()))?;
729 let mut root = parse_xml(bytes, presentation_path, budget)?;
730 patch_slide_id_list(&mut root, final_order, minted, presentation_path)?;
731 prune_slide_references(&mut root, removed);
732 replacements.insert(presentation_path.to_owned(), serialize_xml(&root));
733
734 let relationships_path = slide_relationships_path(presentation_path);
735 let bytes = package
736 .part_bytes(&relationships_path)
737 .ok_or_else(|| PptxError::MissingPart(relationships_path.clone()))?;
738 let mut root = parse_xml(bytes, &relationships_path, budget)?;
739 let removed_ids: HashSet<&str> = removed
740 .iter()
741 .map(|reference| reference.relationship_id.as_str())
742 .collect();
743 root.children.retain(|child| match child {
744 XmlNode::Element(element) if element.local_name() == "Relationship" => element
745 .attribute("Id")
746 .is_none_or(|id| !removed_ids.contains(id)),
747 _ => true,
748 });
749 for slide in minted {
750 root.children.push(XmlNode::Element(
751 XmlElement::new("Relationship")
752 .with_attribute("Id", slide.relationship_id.clone())
753 .with_attribute("Type", SLIDE_RELATIONSHIP_TYPE)
754 .with_attribute(
755 "Target",
756 relative_target(presentation_path, &slide.part_path),
757 ),
758 ));
759 }
760 replacements.insert(relationships_path, serialize_xml(&root));
761
762 let content_types_path = "[Content_Types].xml";
763 let bytes = match replacements.get(content_types_path) {
764 Some(bytes) => bytes.clone(),
765 None => package
766 .part_bytes(content_types_path)
767 .ok_or_else(|| PptxError::MissingPart(content_types_path.to_owned()))?
768 .to_vec(),
769 };
770 let mut root = parse_xml(&bytes, content_types_path, budget)?;
771 let removed_names: HashSet<String> = removed
772 .iter()
773 .map(|reference| format!("/{}", reference.part_path))
774 .chain(orphans.iter().map(|orphan| format!("/{orphan}")))
775 .collect();
776 root.children.retain(|child| match child {
777 XmlNode::Element(element) if element.local_name() == "Override" => element
778 .attribute("PartName")
779 .is_none_or(|name| !removed_names.contains(name)),
780 _ => true,
781 });
782 for slide in minted {
783 root.children.push(XmlNode::Element(
784 XmlElement::new("Override")
785 .with_attribute("PartName", format!("/{}", slide.part_path))
786 .with_attribute("ContentType", SLIDE_CONTENT_TYPE),
787 ));
788 }
789 replacements.insert(content_types_path.to_owned(), serialize_xml(&root));
790 Ok(())
791}
792
793const PACKAGE_RELATIONSHIPS_NS: &str =
794 "http://schemas.openxmlformats.org/package/2006/relationships";
795
796struct MintedSlides<'a> {
797 slides: &'a [MintedSlide],
798 by_slide_index: &'a HashMap<usize, usize>,
799}
800
801impl MintedSlides<'_> {
802 fn get(&self, slide_index: usize) -> Option<&MintedSlide> {
803 self.by_slide_index
804 .get(&slide_index)
805 .and_then(|at| self.slides.get(*at))
806 }
807}
808
809fn patch_comment_parts(
810 package: &PptxPackage,
811 write: &CommentsWrite,
812 minted: &MintedSlides<'_>,
813 replacements: &mut HashMap<String, Vec<u8>>,
814 new_parts: &mut Vec<(String, Vec<u8>)>,
815 removed_paths: &mut HashSet<String>,
816 budget: &mut ParseBudget<'_>,
817) -> Result<(), PptxError> {
818 let mut taken: HashSet<String> = package
819 .parts
820 .iter()
821 .map(|part| part.path.clone())
822 .chain(
823 package
824 .relationships
825 .values()
826 .flatten()
827 .filter(|relationship| relationship.is_type(write.comments_relationship_type()))
828 .filter_map(|relationship| relationship.resolved_target.clone()),
829 )
830 .collect();
831
832 let mut sink = PartSink {
833 package,
834 replacements,
835 new_parts,
836 };
837 for (slide, comments) in &write.per_slide {
838 let (slide_part_path, slide_id) = match slide {
839 CommentSlide::Existing(part_path) => {
840 let reference = package
841 .presentation
842 .slides
843 .iter()
844 .find(|reference| &reference.part_path == part_path)
845 .ok_or_else(|| write_error(part_path, "not a slide of this deck"))?;
846 (part_path.clone(), reference.id)
847 }
848 CommentSlide::Added(index) => {
849 let Some(slide) = minted.get(*index) else {
850 continue;
851 };
852 (slide.part_path.clone(), slide.slide_id)
853 }
854 };
855 let relationships_path = slide_relationships_path(&slide_part_path);
856 let existing = existing_comment_part(package, &slide_part_path, write);
857
858 if comments.is_empty() {
859 if let Some(part_path) = existing {
860 removed_paths.insert(part_path.clone());
861 sink.forget(&part_path);
862 remove_content_type_override(&mut sink, &part_path, budget)?;
863 remove_relationship(
864 &mut sink,
865 &relationships_path,
866 write.comments_relationship_type(),
867 budget,
868 )?;
869 }
870 continue;
871 }
872
873 let is_existing = existing.is_some();
874 let part_path = match existing {
875 Some(part_path) => part_path,
876 None => mint_comment_part_path(write, &slide_part_path, &mut taken),
877 };
878 removed_paths.remove(&part_path);
879 let template = comments
880 .iter()
881 .filter_map(|comment| comment.source.as_ref())
882 .find_map(|(slide, _)| {
883 let path = existing_comment_part(package, slide, write)?;
884 package.part_bytes(&path)
885 });
886 let bytes = match (sink.current(&part_path), template) {
887 (Some(bytes), _) => crate::comment_patch::patch_comments_xml(
888 &bytes, &part_path, write, comments, slide_id, false, budget,
889 )?,
890 (None, Some(bytes)) => crate::comment_patch::patch_comments_xml(
891 bytes, &part_path, write, comments, slide_id, true, budget,
892 )?,
893 (None, None) => comments_xml(write, comments, slide_id),
894 };
895 sink.store(&part_path, bytes);
896 if !is_existing {
897 set_content_type_override(
898 &mut sink,
899 &part_path,
900 write.comments_content_type(),
901 budget,
902 )?;
903 set_relationship(
904 &mut sink,
905 &relationships_path,
906 write.comments_relationship_type(),
907 &relative_target(&slide_part_path, &part_path),
908 budget,
909 )?;
910 }
911 }
912
913 let presentation_relationships = slide_relationships_path(&package.presentation.part_path);
914 let authors_path = existing_authors_part(package, write)
915 .unwrap_or_else(|| write.authors_part_path().to_owned());
916 if write.authors.is_empty() {
917 removed_paths.insert(authors_path.clone());
918 sink.forget(&authors_path);
919 remove_content_type_override(&mut sink, &authors_path, budget)?;
920 remove_relationship(
921 &mut sink,
922 &presentation_relationships,
923 write.authors_relationship_type(),
924 budget,
925 )?;
926 } else {
927 removed_paths.remove(&authors_path);
928 let existing = sink.current(&authors_path);
929 let bytes = match &existing {
930 Some(bytes) => {
931 crate::comment_patch::patch_authors_xml(bytes, &authors_path, write, budget)?
932 }
933 None => authors_xml(write),
934 };
935 sink.store(&authors_path, bytes);
936 if existing.is_none() {
937 set_content_type_override(
938 &mut sink,
939 &authors_path,
940 write.authors_content_type(),
941 budget,
942 )?;
943 set_relationship(
944 &mut sink,
945 &presentation_relationships,
946 write.authors_relationship_type(),
947 &relative_target(&package.presentation.part_path, &authors_path),
948 budget,
949 )?;
950 }
951 }
952
953 if package.comment_flavor != Some(write.flavor) {
954 drop_other_flavor(package, write, &mut sink, removed_paths, budget)?;
955 }
956 Ok(())
957}
958
959fn clone_notes_part(
960 sink: &mut PartSink<'_>,
961 source_slide: &str,
962 copy_slide: &str,
963 budget: &mut ParseBudget<'_>,
964) -> Result<Vec<String>, PptxError> {
965 let Some(source_notes) = existing_notes_part(sink.package, source_slide) else {
966 return Ok(Vec::new());
967 };
968 let mut taken = sink
969 .package
970 .parts
971 .iter()
972 .map(|part| part.path.clone())
973 .chain(sink.new_parts.iter().map(|(path, _)| path.clone()))
974 .chain(
975 sink.package
976 .relationships
977 .values()
978 .flatten()
979 .filter_map(|relationship| relationship.resolved_target.clone()),
980 )
981 .collect();
982 let copy_notes = mint_notes_part_path(copy_slide, &mut taken);
983 let bytes = sink
984 .package
985 .part_bytes(&source_notes)
986 .ok_or_else(|| PptxError::MissingPart(source_notes.clone()))?
987 .to_vec();
988 sink.store(©_notes, bytes);
989 set_content_type_override(sink, ©_notes, crate::notes::CT_NOTES_SLIDE, budget)?;
990 set_relationship(
991 sink,
992 &slide_relationships_path(copy_slide),
993 NOTES_SLIDE_RELATIONSHIP_TYPE,
994 &relative_target(copy_slide, ©_notes),
995 budget,
996 )?;
997 let source_rels = slide_relationships_path(&source_notes);
998 let copy_rels = slide_relationships_path(©_notes);
999 let mut dependencies = Vec::new();
1000 if let Some(bytes) = sink.package.part_bytes(&source_rels) {
1001 let mut root = parse_xml(bytes, &source_rels, budget)?;
1002 root.children.retain(|child| match child {
1003 XmlNode::Element(element) => element
1004 .attribute("Type")
1005 .is_none_or(|kind| !kind.ends_with("/comments") && !kind.ends_with("/comment")),
1006 _ => true,
1007 });
1008 for child in &mut root.children {
1009 let XmlNode::Element(element) = child else {
1010 continue;
1011 };
1012 if element.local_name() != "Relationship"
1013 || element.attribute("TargetMode") == Some("External")
1014 {
1015 continue;
1016 }
1017 let target = if element
1018 .attribute("Type")
1019 .is_some_and(|kind| kind.ends_with("/slide"))
1020 {
1021 copy_slide.to_owned()
1022 } else if let Some(target) = element.attribute("Target") {
1023 let target = crate::relationships::resolve_target(&source_notes, target)?;
1024 dependencies.push(target.clone());
1025 target
1026 } else {
1027 continue;
1028 };
1029 element.set_attribute("Target", relative_target(©_notes, &target));
1030 }
1031 sink.store(©_rels, serialize_xml(&root));
1032 }
1033 set_relationship(
1034 sink,
1035 ©_rels,
1036 SLIDE_RELATIONSHIP_TYPE,
1037 &relative_target(©_notes, copy_slide),
1038 budget,
1039 )?;
1040 Ok(dependencies)
1041}
1042
1043fn patch_notes_parts(
1044 package: &PptxPackage,
1045 write: &NotesWrite,
1046 minted: &MintedSlides<'_>,
1047 replacements: &mut HashMap<String, Vec<u8>>,
1048 new_parts: &mut Vec<(String, Vec<u8>)>,
1049 removed_paths: &mut HashSet<String>,
1050 budget: &mut ParseBudget<'_>,
1051) -> Result<(), PptxError> {
1052 let mut taken: HashSet<String> = package
1053 .parts
1054 .iter()
1055 .map(|part| part.path.clone())
1056 .chain(new_parts.iter().map(|(path, _)| path.clone()))
1057 .chain(
1058 package
1059 .relationships
1060 .values()
1061 .flatten()
1062 .filter(|relationship| relationship.is_type(NOTES_SLIDE_RELATIONSHIP_TYPE))
1063 .filter_map(|relationship| relationship.resolved_target.clone()),
1064 )
1065 .collect();
1066
1067 let mut sink = PartSink {
1068 package,
1069 replacements,
1070 new_parts,
1071 };
1072 for (slide, text) in &write.per_slide {
1073 let slide_part_path = match slide {
1074 CommentSlide::Existing(part_path) => part_path.clone(),
1075 CommentSlide::Added(index) => {
1076 let Some(slide) = minted.get(*index) else {
1077 continue;
1078 };
1079 slide.part_path.clone()
1080 }
1081 };
1082 let relationships_path = slide_relationships_path(&slide_part_path);
1083 let existing = sink
1084 .current(&relationships_path)
1085 .map(|bytes| {
1086 crate::relationships::parse_relationships(
1087 &bytes,
1088 &relationships_path,
1089 &slide_part_path,
1090 budget,
1091 )
1092 })
1093 .transpose()?
1094 .and_then(|relationships| crate::notes::slide_notes_part(&relationships));
1095
1096 if text.is_empty() && existing.is_none() {
1097 continue;
1098 }
1099
1100 let is_existing = existing.is_some();
1101 let part_path = match existing {
1102 Some(part_path) => part_path,
1103 None => mint_notes_part_path(&slide_part_path, &mut taken),
1104 };
1105 removed_paths.remove(&part_path);
1106 let bytes = match sink.current(&part_path) {
1107 Some(bytes) => crate::notes::patch_notes_xml(&bytes, &part_path, text, budget)?,
1108 None => crate::notes::notes_slide_xml(text),
1109 };
1110 sink.store(&part_path, bytes);
1111 if !is_existing {
1112 set_content_type_override(&mut sink, &part_path, crate::notes::CT_NOTES_SLIDE, budget)?;
1113 set_relationship(
1114 &mut sink,
1115 &relationships_path,
1116 NOTES_SLIDE_RELATIONSHIP_TYPE,
1117 &relative_target(&slide_part_path, &part_path),
1118 budget,
1119 )?;
1120 let notes_relationships = slide_relationships_path(&part_path);
1121 set_relationship(
1122 &mut sink,
1123 ¬es_relationships,
1124 SLIDE_RELATIONSHIP_TYPE,
1125 &relative_target(&part_path, &slide_part_path),
1126 budget,
1127 )?;
1128 if let Some(master) = package
1129 .relationships
1130 .get(&package.presentation.part_path)
1131 .into_iter()
1132 .flatten()
1133 .find(|relationship| relationship.has_type("/notesMaster"))
1134 .and_then(|relationship| relationship.resolved_target.as_deref())
1135 {
1136 set_relationship(
1137 &mut sink,
1138 ¬es_relationships,
1139 &format!("{OFFICE_RELATIONSHIPS_NS}/notesMaster"),
1140 &relative_target(&part_path, master),
1141 budget,
1142 )?;
1143 }
1144 }
1145 }
1146 Ok(())
1147}
1148
1149fn existing_notes_part(package: &PptxPackage, slide_part_path: &str) -> Option<String> {
1150 package
1151 .relationships
1152 .get(slide_part_path)?
1153 .iter()
1154 .find(|relationship| relationship.is_type(NOTES_SLIDE_RELATIONSHIP_TYPE))
1155 .and_then(|relationship| relationship.resolved_target.clone())
1156}
1157
1158fn mint_notes_part_path(slide_part_path: &str, taken: &mut HashSet<String>) -> String {
1159 let preferred = slide_number(slide_part_path);
1160 let mut number = preferred;
1161 loop {
1162 let candidate = format!("ppt/notesSlides/notesSlide{number}.xml");
1163 if taken.insert(candidate.clone()) {
1164 return candidate;
1165 }
1166 number += 1;
1167 }
1168}
1169
1170fn mint_comment_part_path(
1171 write: &CommentsWrite,
1172 slide_part_path: &str,
1173 taken: &mut HashSet<String>,
1174) -> String {
1175 let preferred = slide_number(slide_part_path);
1176 let mut number = preferred;
1177 loop {
1178 let candidate = write.comments_part_path(number);
1179 if taken.insert(candidate.clone()) {
1180 return candidate;
1181 }
1182 number += 1;
1183 }
1184}
1185
1186fn drop_other_flavor(
1187 package: &PptxPackage,
1188 write: &CommentsWrite,
1189 sink: &mut PartSink<'_>,
1190 removed_paths: &mut HashSet<String>,
1191 budget: &mut ParseBudget<'_>,
1192) -> Result<(), PptxError> {
1193 let other = CommentsWrite {
1194 flavor: match write.flavor {
1195 CommentFlavor::Legacy => CommentFlavor::Modern,
1196 CommentFlavor::Modern => CommentFlavor::Legacy,
1197 },
1198 authors: Vec::new(),
1199 per_slide: Vec::new(),
1200 };
1201 for reference in &package.presentation.slides {
1202 let Some(part_path) = existing_comment_part(package, &reference.part_path, &other) else {
1203 continue;
1204 };
1205 removed_paths.insert(part_path.clone());
1206 sink.forget(&part_path);
1207 remove_content_type_override(sink, &part_path, budget)?;
1208 remove_relationship(
1209 sink,
1210 &slide_relationships_path(&reference.part_path),
1211 other.comments_relationship_type(),
1212 budget,
1213 )?;
1214 }
1215 let authors_path = existing_authors_part(package, &other)
1216 .unwrap_or_else(|| other.authors_part_path().to_owned());
1217 if package.part_bytes(&authors_path).is_some() {
1218 removed_paths.insert(authors_path.clone());
1219 sink.forget(&authors_path);
1220 remove_content_type_override(sink, &authors_path, budget)?;
1221 remove_relationship(
1222 sink,
1223 &slide_relationships_path(&package.presentation.part_path),
1224 other.authors_relationship_type(),
1225 budget,
1226 )?;
1227 }
1228 Ok(())
1229}
1230
1231fn existing_authors_part(package: &PptxPackage, write: &CommentsWrite) -> Option<String> {
1232 package
1233 .relationships
1234 .get(&package.presentation.part_path)?
1235 .iter()
1236 .find(|relationship| relationship.is_type(write.authors_relationship_type()))
1237 .and_then(|relationship| relationship.resolved_target.clone())
1238}
1239
1240fn existing_comment_part(
1241 package: &PptxPackage,
1242 slide_part_path: &str,
1243 write: &CommentsWrite,
1244) -> Option<String> {
1245 package
1246 .relationships
1247 .get(slide_part_path)?
1248 .iter()
1249 .find(|relationship| relationship.is_type(write.comments_relationship_type()))
1250 .and_then(|relationship| relationship.resolved_target.clone())
1251}
1252
1253fn slide_number(slide_part_path: &str) -> usize {
1254 slide_part_path
1255 .rsplit_once("/slide")
1256 .and_then(|(_, tail)| tail.strip_suffix(".xml"))
1257 .and_then(|digits| digits.parse().ok())
1258 .unwrap_or(1)
1259}
1260
1261struct PartSink<'a> {
1262 package: &'a PptxPackage,
1263 replacements: &'a mut HashMap<String, Vec<u8>>,
1264 new_parts: &'a mut Vec<(String, Vec<u8>)>,
1265}
1266
1267impl PartSink<'_> {
1268 fn current(&self, path: &str) -> Option<Vec<u8>> {
1269 if let Some(bytes) = self.replacements.get(path) {
1270 return Some(bytes.clone());
1271 }
1272 if let Some((_, bytes)) = self.new_parts.iter().find(|(name, _)| name == path) {
1273 return Some(bytes.clone());
1274 }
1275 self.package.part_bytes(path).map(<[u8]>::to_vec)
1276 }
1277
1278 fn store(&mut self, path: &str, bytes: Vec<u8>) {
1279 if self.package.part_bytes(path).is_some() {
1280 self.replacements.insert(path.to_owned(), bytes);
1281 return;
1282 }
1283 match self.new_parts.iter_mut().find(|(name, _)| name == path) {
1284 Some((_, existing)) => *existing = bytes,
1285 None => self.new_parts.push((path.to_owned(), bytes)),
1286 }
1287 }
1288
1289 fn forget(&mut self, path: &str) {
1290 self.replacements.remove(path);
1291 self.new_parts.retain(|(name, _)| name != path);
1292 }
1293}
1294
1295fn append_relationships(bytes: Vec<u8>, children: &[XmlNode]) -> Option<Vec<u8>> {
1296 if children.is_empty() {
1297 return Some(bytes);
1298 }
1299 let mut additions = Vec::new();
1300 for child in children {
1301 let XmlNode::Element(child) = child else {
1302 return None;
1303 };
1304 additions.extend(serialize_xml_fragment(child));
1305 }
1306 let mut reader = quick_xml::Reader::from_reader(bytes.as_slice());
1307 let mut depth = 0;
1308 loop {
1309 let start = reader.buffer_position() as usize;
1310 match reader.read_event().ok()? {
1311 quick_xml::events::Event::Start(_) => depth += 1,
1312 quick_xml::events::Event::End(_) => {
1313 depth -= 1;
1314 if depth == 0 {
1315 let mut output = bytes[..start].to_vec();
1316 output.extend(additions);
1317 output.extend_from_slice(&bytes[start..]);
1318 return Some(output);
1319 }
1320 }
1321 quick_xml::events::Event::Eof => return None,
1322 _ => {}
1323 }
1324 }
1325}
1326
1327fn only_placeholder_edits(shapes: &[ShapeWrite]) -> bool {
1328 shapes.iter().all(|shape| match shape {
1329 ShapeWrite::Keep { .. } => true,
1330 ShapeWrite::Patch { patch, .. } => {
1331 patch.offset.is_none()
1332 && patch.extent.is_none()
1333 && patch.inherited.is_none()
1334 && !patch.materializes
1335 && patch.fill.is_none()
1336 && patch.outline.is_none()
1337 && patch.adjust_values.is_none()
1338 && patch.table_structure.is_none()
1339 && patch.texts.is_empty()
1340 && only_placeholder_edits(&patch.children)
1341 }
1342 ShapeWrite::Add(_) => false,
1343 })
1344}
1345
1346fn has_placeholder_patch(shapes: &[ShapeWrite]) -> bool {
1347 shapes.iter().any(|shape| {
1348 matches!(shape, ShapeWrite::Patch { patch, .. }
1349 if patch.placeholder.is_some() || has_placeholder_patch(&patch.children))
1350 })
1351}
1352
1353fn is_slide_layout_relationship(element: &XmlElement) -> bool {
1354 element.local_name() == "Relationship"
1355 && matches!(
1356 element.attribute("Type"),
1357 Some(SLIDE_LAYOUT_RELATIONSHIP_TYPE | STRICT_SLIDE_LAYOUT_RELATIONSHIP_TYPE)
1358 )
1359}
1360
1361fn patch_layout_relationship(
1362 sink: &mut PartSink<'_>,
1363 slide: &str,
1364 layout: &str,
1365 budget: &mut ParseBudget<'_>,
1366) -> Result<(), PptxError> {
1367 let path = slide_relationships_path(slide);
1368 let bytes = sink
1369 .current(&path)
1370 .ok_or_else(|| PptxError::InvalidSlideLayoutRelationships {
1371 part: slide.to_owned(),
1372 count: 0,
1373 })?;
1374 let mut root = parse_xml(&bytes, &path, budget)?;
1375 let before = root.clone();
1376 let count = root
1377 .child_elements()
1378 .filter(|element| is_slide_layout_relationship(element))
1379 .count();
1380 if count != 1 {
1381 return Err(PptxError::InvalidSlideLayoutRelationships {
1382 part: slide.to_owned(),
1383 count,
1384 });
1385 }
1386 for child in &mut root.children {
1387 if let XmlNode::Element(element) = child
1388 && is_slide_layout_relationship(element)
1389 {
1390 element.set_attribute("Target", relative_target(slide, layout));
1391 }
1392 }
1393 let output = crate::attribute_patch::patch_attributes(&bytes, &before, &root)
1394 .ok_or_else(|| write_error(&path, "could not patch slide layout relationship"))?;
1395 sink.store(&path, output);
1396 Ok(())
1397}
1398
1399fn set_relationship(
1400 sink: &mut PartSink<'_>,
1401 relationships_path: &str,
1402 relationship_type: &str,
1403 target: &str,
1404 budget: &mut ParseBudget<'_>,
1405) -> Result<(), PptxError> {
1406 let mut root = match sink.current(relationships_path) {
1407 Some(bytes) => parse_xml(&bytes, relationships_path, budget)?,
1408 None => XmlElement::new("Relationships").with_attribute("xmlns", PACKAGE_RELATIONSHIPS_NS),
1409 };
1410 let existing = root.children.iter_mut().find_map(|child| match child {
1411 XmlNode::Element(element)
1412 if element.local_name() == "Relationship"
1413 && element.attribute("Type") == Some(relationship_type) =>
1414 {
1415 Some(element)
1416 }
1417 _ => None,
1418 });
1419 match existing {
1420 Some(element) => element.set_attribute("Target", target),
1421 None => {
1422 let id = format!("rId{}", max_relationship_id(&root) + 1);
1423 root.children.push(XmlNode::Element(
1424 XmlElement::new("Relationship")
1425 .with_attribute("Id", id)
1426 .with_attribute("Type", relationship_type)
1427 .with_attribute("Target", target),
1428 ));
1429 }
1430 }
1431 sink.store(relationships_path, serialize_xml(&root));
1432 Ok(())
1433}
1434
1435fn remove_relationship(
1436 sink: &mut PartSink<'_>,
1437 relationships_path: &str,
1438 relationship_type: &str,
1439 budget: &mut ParseBudget<'_>,
1440) -> Result<(), PptxError> {
1441 let Some(bytes) = sink.current(relationships_path) else {
1442 return Ok(());
1443 };
1444 let mut root = parse_xml(&bytes, relationships_path, budget)?;
1445 root.children.retain(|child| match child {
1446 XmlNode::Element(element) if element.local_name() == "Relationship" => {
1447 element.attribute("Type") != Some(relationship_type)
1448 }
1449 _ => true,
1450 });
1451 sink.store(relationships_path, serialize_xml(&root));
1452 Ok(())
1453}
1454
1455fn max_relationship_id(root: &XmlElement) -> usize {
1456 root.children_named("Relationship")
1457 .filter_map(|element| element.attribute("Id"))
1458 .filter_map(|id| id.strip_prefix("rId"))
1459 .filter_map(|number| number.parse::<usize>().ok())
1460 .max()
1461 .unwrap_or(0)
1462}
1463
1464fn set_content_type_override(
1465 sink: &mut PartSink<'_>,
1466 part_path: &str,
1467 content_type: &str,
1468 budget: &mut ParseBudget<'_>,
1469) -> Result<(), PptxError> {
1470 let path = "[Content_Types].xml";
1471 let bytes = sink
1472 .current(path)
1473 .ok_or_else(|| PptxError::MissingPart(path.to_owned()))?;
1474 let mut root = parse_xml(&bytes, path, budget)?;
1475 let prefix = root.name.rsplit_once(':').map_or("", |(prefix, _)| prefix);
1476 let override_name = qualified(prefix, "Override");
1477 let name = format!("/{part_path}");
1478 let existing = root.children.iter_mut().find_map(|child| match child {
1479 XmlNode::Element(element)
1480 if element.local_name() == "Override"
1481 && element.attribute("PartName") == Some(name.as_str()) =>
1482 {
1483 Some(element)
1484 }
1485 _ => None,
1486 });
1487 match existing {
1488 Some(element) => element.set_attribute("ContentType", content_type),
1489 None => root.children.push(XmlNode::Element(
1490 XmlElement::new(override_name)
1491 .with_attribute("PartName", name)
1492 .with_attribute("ContentType", content_type),
1493 )),
1494 }
1495 sink.store(path, serialize_xml(&root));
1496 Ok(())
1497}
1498
1499fn remove_content_type_override(
1500 sink: &mut PartSink<'_>,
1501 part_path: &str,
1502 budget: &mut ParseBudget<'_>,
1503) -> Result<(), PptxError> {
1504 let path = "[Content_Types].xml";
1505 let Some(bytes) = sink.current(path) else {
1506 return Ok(());
1507 };
1508 let mut root = parse_xml(&bytes, path, budget)?;
1509 let name = format!("/{part_path}");
1510 root.children.retain(|child| match child {
1511 XmlNode::Element(element) if element.local_name() == "Override" => {
1512 element.attribute("PartName") != Some(name.as_str())
1513 }
1514 _ => true,
1515 });
1516 sink.store(path, serialize_xml(&root));
1517 Ok(())
1518}
1519
1520fn prune_slide_references(root: &mut XmlElement, removed: &[&SlideReference]) {
1523 if removed.is_empty() {
1524 return;
1525 }
1526 let removed_relationship_ids: HashSet<&str> = removed
1527 .iter()
1528 .map(|reference| reference.relationship_id.as_str())
1529 .collect();
1530 let removed_slide_ids: HashSet<String> = removed
1531 .iter()
1532 .map(|reference| reference.id.to_string())
1533 .collect();
1534 for child in root.children.iter_mut() {
1535 let XmlNode::Element(element) = child else {
1536 continue;
1537 };
1538 match element.local_name() {
1539 "custShowLst" => remove_matching_descendants(element, &|candidate| {
1540 candidate.local_name() == "sld"
1541 && candidate.attributes.iter().any(|(key, value)| {
1542 key.ends_with(":id") && removed_relationship_ids.contains(value.as_str())
1543 })
1544 }),
1545 "extLst" => remove_matching_descendants(element, &|candidate| {
1546 candidate.local_name() == "sldId"
1547 && candidate
1548 .attribute("id")
1549 .is_some_and(|id| removed_slide_ids.contains(id))
1550 }),
1551 _ => {}
1552 }
1553 }
1554}
1555
1556fn remove_matching_descendants(element: &mut XmlElement, matches: &dyn Fn(&XmlElement) -> bool) {
1557 element.children.retain(|child| match child {
1558 XmlNode::Element(candidate) => !matches(candidate),
1559 _ => true,
1560 });
1561 for child in element.children.iter_mut() {
1562 if let XmlNode::Element(child) = child {
1563 remove_matching_descendants(child, matches);
1564 }
1565 }
1566}
1567
1568fn patch_slide_id_list(
1569 root: &mut XmlElement,
1570 final_order: &[FinalSlide<'_>],
1571 minted: &[MintedSlide],
1572 part: &str,
1573) -> Result<(), PptxError> {
1574 let presentation_prefix = resolve_prefix(root, PRESENTATIONML_NS, "p");
1575 let relationship_prefix = resolve_prefix(root, OFFICE_RELATIONSHIPS_NS, "r");
1576 let list_name = qualified(&presentation_prefix, "sldIdLst");
1577 if root.child_mut("sldIdLst").is_none() {
1578 let position = root
1579 .children
1580 .iter()
1581 .position(|child| {
1582 matches!(child, XmlNode::Element(element) if element.local_name() == "sldMasterIdLst")
1583 })
1584 .map(|index| index + 1)
1585 .unwrap_or(0);
1586 root.children.insert(
1587 position,
1588 XmlNode::Element(XmlElement::new(list_name.clone())),
1589 );
1590 }
1591 let relationship_attribute = qualified(&relationship_prefix, "id");
1592 let list = root
1593 .child_mut("sldIdLst")
1594 .ok_or_else(|| write_error(part, "missing slide id list"))?;
1595 let mut existing: HashMap<String, XmlElement> = HashMap::new();
1596 for child in std::mem::take(&mut list.children) {
1597 if let XmlNode::Element(element) = child
1598 && element.local_name() == "sldId"
1599 {
1600 let relationship_id = element.attribute(&relationship_attribute).or_else(|| {
1601 element
1602 .attributes
1603 .iter()
1604 .find(|(key, _)| key.ends_with(":id"))
1605 .map(|(_, value)| value.as_str())
1606 });
1607 if let Some(id) = relationship_id {
1608 existing.insert(id.to_owned(), element);
1609 }
1610 }
1611 }
1612 let entry_name = qualified(&presentation_prefix, "sldId");
1613 for slide in final_order {
1614 let element = match slide {
1615 FinalSlide::Existing(reference) => existing
1616 .remove(&reference.relationship_id)
1617 .unwrap_or_else(|| {
1618 XmlElement::new(entry_name.clone())
1619 .with_attribute("id", reference.id.to_string())
1620 .with_attribute(
1621 relationship_attribute.clone(),
1622 reference.relationship_id.clone(),
1623 )
1624 }),
1625 FinalSlide::Added(index) => {
1626 let slide = &minted[*index];
1627 XmlElement::new(entry_name.clone())
1628 .with_attribute("id", slide.slide_id.to_string())
1629 .with_attribute(
1630 relationship_attribute.clone(),
1631 slide.relationship_id.clone(),
1632 )
1633 }
1634 };
1635 list.children.push(XmlNode::Element(element));
1636 }
1637 Ok(())
1638}
1639
1640fn find_prefix(root: &XmlElement, namespace: &str) -> Option<String> {
1643 root.attributes.iter().find_map(|(key, value)| {
1644 if value != namespace {
1645 return None;
1646 }
1647 if key == "xmlns" {
1648 Some(String::new())
1649 } else {
1650 key.strip_prefix("xmlns:").map(str::to_owned)
1651 }
1652 })
1653}
1654
1655fn resolve_prefix(root: &mut XmlElement, namespace: &str, fallback: &str) -> String {
1656 if let Some(prefix) = find_prefix(root, namespace) {
1657 return prefix;
1658 }
1659 root.set_attribute(format!("xmlns:{fallback}"), namespace);
1660 fallback.to_owned()
1661}
1662
1663fn qualified(prefix: &str, local: &str) -> String {
1664 if prefix.is_empty() {
1665 local.to_owned()
1666 } else {
1667 format!("{prefix}:{local}")
1668 }
1669}
1670
1671struct Prefixes {
1672 drawing: String,
1673 presentation: String,
1674 relationship: String,
1675}
1676
1677impl Prefixes {
1678 fn from_root(root: &mut XmlElement) -> Self {
1679 Self {
1680 drawing: resolve_prefix(root, DRAWINGML_NS, "a"),
1681 presentation: resolve_prefix(root, PRESENTATIONML_NS, "p"),
1682 relationship: resolve_prefix(root, OFFICE_RELATIONSHIPS_NS, "r"),
1683 }
1684 }
1685
1686 fn drawing(&self, local: &str) -> String {
1687 qualified(&self.drawing, local)
1688 }
1689
1690 fn presentation(&self, local: &str) -> String {
1691 qualified(&self.presentation, local)
1692 }
1693
1694 fn relationship(&self, local: &str) -> String {
1695 qualified(&self.relationship, local)
1696 }
1697}
1698
1699#[allow(clippy::too_many_arguments)]
1703fn patch_slide(
1704 root: &mut XmlElement,
1705 shapes: &[ShapeWrite],
1706 background: Option<&BackgroundWrite>,
1707 theme: Option<&Theme>,
1708 part: &str,
1709 elements: ShapeElements,
1710 sink: &mut PartSink<'_>,
1711 budget: &mut ParseBudget<'_>,
1712) -> Result<(), PptxError> {
1713 let prefixes = Prefixes::from_root(root);
1714 if let Some(background) = background {
1715 let common = root
1716 .child_mut("cSld")
1717 .ok_or_else(|| write_error(part, "slide has no common slide data"))?;
1718 patch_background(common, background, &prefixes, part)?;
1719 }
1720 let mut next_shape_id = max_shape_id(root).checked_add(1);
1721 let tree = root
1722 .child_mut("cSld")
1723 .and_then(|common| common.child_mut("spTree"))
1724 .ok_or_else(|| write_error(part, "slide has no shape tree"))?;
1725 patch_shape_children(
1726 tree,
1727 shapes,
1728 &mut next_shape_id,
1729 theme,
1730 &prefixes,
1731 part,
1732 elements,
1733 sink,
1734 budget,
1735 )
1736}
1737
1738fn patch_background(
1740 common: &mut XmlElement,
1741 background: &BackgroundWrite,
1742 prefixes: &Prefixes,
1743 part: &str,
1744) -> Result<(), PptxError> {
1745 common.children.retain(|child| match child {
1746 XmlNode::Element(element) => element.local_name() != "bg",
1747 _ => true,
1748 });
1749 if let BackgroundWrite::Set(fill) = background {
1750 common.children.insert(
1751 0,
1752 XmlNode::Element(background_element(fill, prefixes, part)?),
1753 );
1754 }
1755 Ok(())
1756}
1757
1758fn background_element(
1760 fill: &ShapeFill,
1761 prefixes: &Prefixes,
1762 part: &str,
1763) -> Result<XmlElement, PptxError> {
1764 Ok(XmlElement::new(prefixes.presentation("bg")).with_child(
1765 XmlElement::new(prefixes.presentation("bgPr"))
1766 .with_child(fill_element(fill, prefixes, part)?)
1767 .with_child(XmlElement::new(prefixes.drawing("effectLst"))),
1768 ))
1769}
1770
1771fn max_shape_id(root: &XmlElement) -> u32 {
1772 root.descendants_named("cNvPr")
1773 .iter()
1774 .filter_map(|element| element.attribute("id")?.parse::<u32>().ok())
1775 .max()
1776 .unwrap_or(1)
1777}
1778
1779fn alloc_shape_id(next_shape_id: &mut Option<u32>, part: &str) -> Result<u32, PptxError> {
1780 let shape_id =
1781 next_shape_id.ok_or_else(|| write_error(part, "the shape id space is exhausted"))?;
1782 if shape_id == 0 {
1783 return Err(write_error(part, "the shape id space is exhausted"));
1784 }
1785 *next_shape_id = shape_id.checked_add(1);
1786 Ok(shape_id)
1787}
1788
1789struct ShapeSlot {
1792 position: usize,
1793 path: Vec<usize>,
1794}
1795
1796#[allow(clippy::too_many_arguments)]
1800fn patch_shape_children(
1801 parent: &mut XmlElement,
1802 writes: &[ShapeWrite],
1803 next_shape_id: &mut Option<u32>,
1804 theme: Option<&Theme>,
1805 prefixes: &Prefixes,
1806 part: &str,
1807 elements: ShapeElements,
1808 sink: &mut PartSink<'_>,
1809 budget: &mut ParseBudget<'_>,
1810) -> Result<(), PptxError> {
1811 let mut slots: Vec<Option<XmlNode>> = std::mem::take(&mut parent.children)
1812 .into_iter()
1813 .map(Some)
1814 .collect();
1815 let mut shape_slots = shape_slots(&slots, elements);
1816 let mut kept = vec![false; shape_slots.len()];
1817 for write in writes {
1818 if let ShapeWrite::Keep { source_index } | ShapeWrite::Patch { source_index, .. } = write
1819 && let Some(flag) = kept.get_mut(*source_index)
1820 {
1821 *flag = true;
1822 }
1823 }
1824 let mut nested: BTreeMap<usize, (usize, Vec<Vec<usize>>)> = BTreeMap::new();
1827 for (slot, kept) in shape_slots.iter().zip(&kept) {
1828 if slot.path.is_empty() {
1829 continue;
1830 }
1831 let entry = nested.entry(slot.position).or_default();
1832 entry.0 += 1;
1833 if !kept {
1834 entry.1.push(slot.path.clone());
1835 }
1836 }
1837 for (position, (total, mut paths)) in nested {
1838 if paths.len() == total && branch_holds_only(&slots[position], &paths) {
1839 slots[position] = None;
1840 continue;
1841 }
1842 paths.sort();
1843 for path in paths.iter().rev() {
1844 let Some(node) = slots[position].as_mut() else {
1845 continue;
1846 };
1847 remove_at_path(node, path);
1848 for slot in shape_slots
1849 .iter_mut()
1850 .filter(|slot| slot.position == position)
1851 {
1852 shift_path(&mut slot.path, path);
1853 }
1854 }
1855 }
1856 let mut children = Vec::with_capacity(slots.len());
1857 let mut hosts: BTreeMap<usize, usize> = BTreeMap::new();
1858 let first_ext_list = |slots: &[Option<XmlNode>]| {
1859 slots
1860 .iter()
1861 .position(|slot| {
1862 matches!(slot, Some(XmlNode::Element(element)) if element.local_name() == "extLst")
1863 })
1864 .unwrap_or(slots.len())
1865 };
1866 let prologue_end = shape_slots
1867 .first()
1868 .map(|slot| slot.position)
1869 .unwrap_or_else(|| first_ext_list(&slots));
1870 emit_sibling_slots(&mut slots, &mut children, prologue_end, elements);
1871 let last_kept = writes
1872 .iter()
1873 .rposition(|write| !matches!(write, ShapeWrite::Add(_)));
1874 for (index, write) in writes.iter().enumerate() {
1875 match write {
1876 ShapeWrite::Keep { source_index } | ShapeWrite::Patch { source_index, .. } => {
1877 let slot = shape_slots.get(*source_index).ok_or_else(|| {
1878 write_error(part, format!("shape index {source_index} is not available"))
1879 })?;
1880 emit_sibling_slots(&mut slots, &mut children, slot.position, elements);
1881 let target = emit_source_element(&mut slots, &mut children, &mut hosts, slot)
1882 .ok_or_else(|| {
1883 write_error(
1884 part,
1885 format!("shape index {source_index} was already written"),
1886 )
1887 })?;
1888 if let ShapeWrite::Patch { patch, .. } = write {
1889 let element =
1890 element_at_path(&mut children[target], &slot.path).ok_or_else(|| {
1891 write_error(
1892 part,
1893 format!("shape index {source_index} is not available"),
1894 )
1895 })?;
1896 patch_shape(
1897 element,
1898 patch,
1899 next_shape_id,
1900 theme,
1901 prefixes,
1902 part,
1903 elements,
1904 sink,
1905 budget,
1906 )?;
1907 }
1908 }
1909 ShapeWrite::Add(add) => {
1910 if last_kept.is_none_or(|kept| index > kept) {
1913 let flush_end = first_ext_list(&slots);
1914 emit_sibling_slots(&mut slots, &mut children, flush_end, elements);
1915 }
1916 children.push(XmlNode::Element(add_shape_element(
1917 add,
1918 next_shape_id,
1919 prefixes,
1920 part,
1921 sink,
1922 budget,
1923 )?));
1924 }
1925 }
1926 }
1927 for slot in slots.into_iter().flatten() {
1928 if !matches!(&slot, XmlNode::Element(element) if elements.contains(element.local_name())) {
1929 children.push(slot);
1930 }
1931 }
1932 parent.children = children;
1933 Ok(())
1934}
1935
1936fn shape_slots(slots: &[Option<XmlNode>], elements: ShapeElements) -> Vec<ShapeSlot> {
1938 let mut found = Vec::new();
1939 for (position, slot) in slots.iter().enumerate() {
1940 let Some(XmlNode::Element(element)) = slot else {
1941 continue;
1942 };
1943 if elements.contains(element.local_name()) {
1944 found.push(ShapeSlot {
1945 position,
1946 path: Vec::new(),
1947 });
1948 } else if element.local_name() == "AlternateContent" {
1949 collect_branch_slots(element, position, &mut Vec::new(), elements, &mut found);
1950 }
1951 }
1952 found
1953}
1954
1955fn collect_branch_slots(
1956 alternate: &XmlElement,
1957 position: usize,
1958 path: &mut Vec<usize>,
1959 elements: ShapeElements,
1960 found: &mut Vec<ShapeSlot>,
1961) {
1962 let Some(index) = alternate_content_branch_index(alternate) else {
1963 return;
1964 };
1965 let XmlNode::Element(branch) = &alternate.children[index] else {
1966 return;
1967 };
1968 path.push(index);
1969 for (index, child) in branch.children.iter().enumerate() {
1970 let XmlNode::Element(child) = child else {
1971 continue;
1972 };
1973 path.push(index);
1974 if elements.contains(child.local_name()) {
1975 found.push(ShapeSlot {
1976 position,
1977 path: path.clone(),
1978 });
1979 } else if child.local_name() == "AlternateContent" {
1980 collect_branch_slots(child, position, path, elements, found);
1981 }
1982 path.pop();
1983 }
1984 path.pop();
1985}
1986
1987fn emit_source_element(
1990 slots: &mut [Option<XmlNode>],
1991 children: &mut Vec<XmlNode>,
1992 hosts: &mut BTreeMap<usize, usize>,
1993 slot: &ShapeSlot,
1994) -> Option<usize> {
1995 if let Some(host) = hosts.get(&slot.position) {
1996 return Some(*host);
1997 }
1998 let XmlNode::Element(element) = slots[slot.position].take()? else {
1999 return None;
2000 };
2001 children.push(XmlNode::Element(element));
2002 if !slot.path.is_empty() {
2003 hosts.insert(slot.position, children.len() - 1);
2004 }
2005 Some(children.len() - 1)
2006}
2007
2008fn branch_holds_only(slot: &Option<XmlNode>, paths: &[Vec<usize>]) -> bool {
2010 let Some(XmlNode::Element(alternate)) = slot else {
2011 return true;
2012 };
2013 let Some(index) = alternate_content_branch_index(alternate) else {
2014 return true;
2015 };
2016 let XmlNode::Element(branch) = &alternate.children[index] else {
2017 return true;
2018 };
2019 branch.children.iter().enumerate().all(|(child, node)| {
2020 !matches!(node, XmlNode::Element(_)) || paths.iter().any(|path| path == &[index, child])
2021 })
2022}
2023
2024fn remove_at_path(node: &mut XmlNode, path: &[usize]) {
2025 let Some((last, parents)) = path.split_last() else {
2026 return;
2027 };
2028 if let Some(parent) = element_at_path(node, parents)
2029 && *last < parent.children.len()
2030 {
2031 parent.children.remove(*last);
2032 }
2033}
2034
2035fn shift_path(path: &mut [usize], removed: &[usize]) {
2037 let Some((last, parents)) = removed.split_last() else {
2038 return;
2039 };
2040 if path.len() > parents.len() && path.starts_with(parents) && path[parents.len()] > *last {
2041 path[parents.len()] -= 1;
2042 }
2043}
2044
2045fn element_at_path<'a>(node: &'a mut XmlNode, path: &[usize]) -> Option<&'a mut XmlElement> {
2046 let XmlNode::Element(element) = node else {
2047 return None;
2048 };
2049 let mut current = element;
2050 for index in path {
2051 match current.children.get_mut(*index)? {
2052 XmlNode::Element(child) => current = child,
2053 XmlNode::Text(_) => return None,
2054 }
2055 }
2056 Some(current)
2057}
2058
2059fn emit_sibling_slots(
2061 slots: &mut [Option<XmlNode>],
2062 children: &mut Vec<XmlNode>,
2063 position: usize,
2064 elements: ShapeElements,
2065) {
2066 for slot in slots.iter_mut().take(position) {
2067 let is_other = !matches!(
2068 slot,
2069 Some(XmlNode::Element(element)) if elements.contains(element.local_name())
2070 );
2071 if is_other && let Some(node) = slot.take() {
2072 children.push(node);
2073 }
2074 }
2075}
2076
2077#[allow(clippy::too_many_arguments)]
2078fn patch_shape(
2079 element: &mut XmlElement,
2080 patch: &ShapePatch,
2081 next_shape_id: &mut Option<u32>,
2082 theme: Option<&Theme>,
2083 prefixes: &Prefixes,
2084 part: &str,
2085 elements: ShapeElements,
2086 sink: &mut PartSink<'_>,
2087 budget: &mut ParseBudget<'_>,
2088) -> Result<(), PptxError> {
2089 if let Some(placeholder) = &patch.placeholder {
2090 let non_visual = element
2091 .children
2092 .iter_mut()
2093 .find_map(|child| match child {
2094 XmlNode::Element(child)
2095 if matches!(
2096 child.local_name(),
2097 "nvSpPr" | "nvPicPr" | "nvGraphicFramePr" | "nvGrpSpPr" | "nvCxnSpPr"
2098 ) =>
2099 {
2100 Some(child)
2101 }
2102 _ => None,
2103 })
2104 .and_then(|child| child.child_mut("nvPr"))
2105 .and_then(|child| child.child_mut("ph"))
2106 .ok_or_else(|| write_error(part, "mapped shape has no placeholder element"))?;
2107 non_visual
2108 .attributes
2109 .retain(|name, _| name == "xmlns" || name.starts_with("xmlns:"));
2110 for (name, value) in [
2111 ("type", placeholder.placeholder_type.clone()),
2112 ("idx", placeholder.index.map(|index| index.to_string())),
2113 ("orient", placeholder.orientation.clone()),
2114 ("sz", placeholder.size.clone()),
2115 ] {
2116 if let Some(value) = value {
2117 non_visual.set_attribute(name, value);
2118 }
2119 }
2120 }
2121 if patch.offset.is_some() || patch.extent.is_some() {
2122 patch_transform(element, patch, prefixes, part)?;
2123 }
2124 if let Some(fill) = &patch.fill {
2125 let properties = shape_properties_mut(element, part)?;
2126 set_fill(properties, fill, prefixes, part)?;
2127 }
2128 if let Some(outline) = &patch.outline {
2129 let properties = shape_properties_mut(element, part)?;
2130 set_outline(properties, outline, prefixes);
2131 }
2132 if let Some(adjust_values) = &patch.adjust_values {
2133 let properties = shape_properties_mut(element, part)?;
2134 set_adjust_values(properties, adjust_values, prefixes, part)?;
2135 }
2136 if let Some(structure) = &patch.table_structure {
2137 patch_table(element, structure, part)?;
2138 }
2139 for text in &patch.texts {
2140 let empty_runs = if patch.table_structure.is_some() {
2141 empty_table_runs(element, text, theme, prefixes)
2142 } else {
2143 Vec::new()
2144 };
2145 patch_text(element, text, theme, prefixes, part)?;
2146 if let TextTarget::TableCell { row, cell } = text.target {
2147 for (paragraph, run) in empty_runs {
2148 let body = table_cell_mut(element, row, cell)
2149 .and_then(|cell| cell.child_mut("txBody"))
2150 .ok_or_else(|| write_error(part, "table cell has no text body"))?;
2151 let paragraph = nth_child_mut(body, "p", paragraph)
2152 .ok_or_else(|| write_error(part, "table cell paragraph is missing"))?;
2153 let at = paragraph.children.iter().position(|node| matches!(node, XmlNode::Element(element) if element.local_name() == "endParaRPr")).unwrap_or(paragraph.children.len());
2154 paragraph.children.insert(at, XmlNode::Element(run));
2155 }
2156 }
2157 }
2158 if !patch.children.is_empty() {
2159 patch_shape_children(
2160 element,
2161 &patch.children,
2162 next_shape_id,
2163 theme,
2164 prefixes,
2165 part,
2166 elements,
2167 sink,
2168 budget,
2169 )?;
2170 }
2171 Ok(())
2172}
2173
2174fn shape_properties_mut<'a>(
2175 element: &'a mut XmlElement,
2176 part: &str,
2177) -> Result<&'a mut XmlElement, PptxError> {
2178 let container = match element.local_name() {
2179 "grpSp" => "grpSpPr",
2180 _ => "spPr",
2181 };
2182 element
2183 .child_mut(container)
2184 .ok_or_else(|| write_error(part, "shape has no properties element"))
2185}
2186
2187fn patch_transform(
2192 element: &mut XmlElement,
2193 patch: &ShapePatch,
2194 prefixes: &Prefixes,
2195 part: &str,
2196) -> Result<(), PptxError> {
2197 let offset = patch
2198 .offset
2199 .or(patch.inherited.map(|inherited| (inherited.x, inherited.y)));
2200 let extent = patch.extent.or(patch
2201 .inherited
2202 .map(|inherited| (inherited.width, inherited.height)));
2203 let (container, transform_name) = match element.local_name() {
2204 "graphicFrame" => (None, prefixes.presentation("xfrm")),
2205 "grpSp" => (Some("grpSpPr"), prefixes.drawing("xfrm")),
2206 _ => (Some("spPr"), prefixes.drawing("xfrm")),
2207 };
2208 let parent = match container {
2209 Some(name) => element
2210 .child_mut(name)
2211 .ok_or_else(|| write_error(part, "shape has no properties element"))?,
2212 None => element,
2213 };
2214 let created = parent.child_mut("xfrm").is_none();
2215 if created {
2216 if offset.is_none() || extent.is_none() {
2217 return Err(write_error(
2218 part,
2219 "cannot write half a transform onto a shape without one",
2220 ));
2221 }
2222 let position = parent
2223 .children
2224 .iter()
2225 .position(|child| {
2226 matches!(child, XmlNode::Element(element) if element.local_name() != "nvGraphicFramePr")
2227 })
2228 .unwrap_or(parent.children.len());
2229 parent
2230 .children
2231 .insert(position, XmlNode::Element(XmlElement::new(transform_name)));
2232 }
2233 let transform = parent.child_mut("xfrm").expect("transform ensured above");
2234 if (created || patch.materializes)
2235 && let Some(inherited) = patch.inherited
2236 {
2237 if inherited.rotation_deg != 0.0 {
2238 transform.set_attribute("rot", format_fixed(inherited.rotation_deg * 60_000.0));
2239 }
2240 if inherited.flip_horizontal {
2241 transform.set_attribute("flipH", "1");
2242 }
2243 if inherited.flip_vertical {
2244 transform.set_attribute("flipV", "1");
2245 }
2246 }
2247 if let Some((x, y)) = offset
2248 && (patch.offset.is_some() || transform.child_mut("off").is_none())
2249 {
2250 if transform.child_mut("off").is_none() {
2251 transform.children.insert(
2252 0,
2253 XmlNode::Element(XmlElement::new(prefixes.drawing("off"))),
2254 );
2255 }
2256 let element = transform.child_mut("off").expect("offset ensured above");
2257 element.set_attribute("x", x.to_string());
2258 element.set_attribute("y", y.to_string());
2259 }
2260 if let Some((width, height)) = extent
2261 && (patch.extent.is_some() || transform.child_mut("ext").is_none())
2262 {
2263 if transform.child_mut("ext").is_none() {
2264 let position = transform
2265 .children
2266 .iter()
2267 .position(
2268 |child| matches!(child, XmlNode::Element(element) if element.local_name() == "off"),
2269 )
2270 .map(|index| index + 1)
2271 .unwrap_or(transform.children.len());
2272 transform.children.insert(
2273 position,
2274 XmlNode::Element(XmlElement::new(prefixes.drawing("ext"))),
2275 );
2276 }
2277 let element = transform.child_mut("ext").expect("extent ensured above");
2278 element.set_attribute("cx", width.to_string());
2279 element.set_attribute("cy", height.to_string());
2280 }
2281 Ok(())
2282}
2283
2284const FILL_ELEMENTS: [&str; 6] = [
2285 "noFill",
2286 "solidFill",
2287 "gradFill",
2288 "blipFill",
2289 "pattFill",
2290 "grpFill",
2291];
2292const POST_FILL_ELEMENTS: [&str; 6] = ["ln", "effectLst", "effectDag", "scene3d", "sp3d", "extLst"];
2293
2294fn set_fill(
2295 properties: &mut XmlElement,
2296 fill: &ShapeFill,
2297 prefixes: &Prefixes,
2298 part: &str,
2299) -> Result<(), PptxError> {
2300 let element = fill_element(fill, prefixes, part)?;
2301 let existing = properties.children.iter().position(|child| {
2302 matches!(child, XmlNode::Element(element) if FILL_ELEMENTS.contains(&element.local_name()))
2303 });
2304 match existing {
2305 Some(index) => properties.children[index] = XmlNode::Element(element),
2306 None => {
2307 let position = properties
2308 .children
2309 .iter()
2310 .position(|child| {
2311 matches!(
2312 child,
2313 XmlNode::Element(element)
2314 if POST_FILL_ELEMENTS.contains(&element.local_name())
2315 )
2316 })
2317 .unwrap_or(properties.children.len());
2318 properties
2319 .children
2320 .insert(position, XmlNode::Element(element));
2321 }
2322 }
2323 Ok(())
2324}
2325
2326fn fill_element(
2327 fill: &ShapeFill,
2328 prefixes: &Prefixes,
2329 part: &str,
2330) -> Result<XmlElement, PptxError> {
2331 match fill.fill_type.as_str() {
2332 "none" => Ok(XmlElement::new(prefixes.drawing("noFill"))),
2333 "solid" => {
2334 let color = fill
2335 .color
2336 .as_ref()
2337 .and_then(|color| color_element(color, prefixes))
2338 .ok_or_else(|| write_error(part, "solid fill without a serializable color"))?;
2339 Ok(XmlElement::new(prefixes.drawing("solidFill")).with_child(color))
2340 }
2341 "gradient" => {
2342 let gradient = fill
2343 .gradient
2344 .as_ref()
2345 .ok_or_else(|| write_error(part, "gradient fill without stops"))?;
2346 gradient_element(gradient, prefixes)
2347 .ok_or_else(|| write_error(part, "gradient stop without a color"))
2348 }
2349 other => Err(write_error(
2350 part,
2351 format!("unsupported fill type {other:?}"),
2352 )),
2353 }
2354}
2355
2356fn gradient_element(gradient: &GradientFill, prefixes: &Prefixes) -> Option<XmlElement> {
2357 let mut stops = XmlElement::new(prefixes.drawing("gsLst"));
2358 for stop in &gradient.stops {
2359 stops = stops.with_child(
2360 XmlElement::new(prefixes.drawing("gs"))
2361 .with_attribute("pos", format_fixed(stop.position))
2362 .with_child(color_element(&stop.color, prefixes)?),
2363 );
2364 }
2365 let mut element = XmlElement::new(prefixes.drawing("gradFill")).with_child(stops);
2366 match gradient.gradient_type.as_str() {
2367 "linear" => {
2368 element = element.with_child(
2369 XmlElement::new(prefixes.drawing("lin"))
2370 .with_attribute(
2371 "ang",
2372 format_fixed(gradient.angle.unwrap_or_default() * 60_000.0),
2373 )
2374 .with_attribute("scaled", "1"),
2375 );
2376 }
2377 kind => {
2378 let path = match kind {
2379 "radial" => "circle",
2380 "rectangular" => "rect",
2381 _ => "shape",
2382 };
2383 element = element
2384 .with_child(XmlElement::new(prefixes.drawing("path")).with_attribute("path", path));
2385 }
2386 }
2387 Some(element)
2388}
2389
2390fn color_element(color: &ColorValue, prefixes: &Prefixes) -> Option<XmlElement> {
2391 let mut element = if let Some(rgb) = &color.rgb {
2392 XmlElement::new(prefixes.drawing("srgbClr")).with_attribute("val", rgb.clone())
2393 } else if let Some(theme) = &color.theme_color {
2394 XmlElement::new(prefixes.drawing("schemeClr"))
2395 .with_attribute("val", denormalize_scheme_color(theme))
2396 } else {
2397 return None;
2398 };
2399 if !color.transforms.is_empty() {
2400 for transform in color.drawingml_transforms() {
2401 let mut child = XmlElement::new(prefixes.drawing(transform.xml_name()));
2402 if let Some(value) = transform.xml_value() {
2403 child = child.with_attribute("val", value);
2404 }
2405 element = element.with_child(child);
2406 }
2407 return Some(element);
2408 }
2409 let fractions = [
2410 ("alpha", color.alpha),
2411 ("lumMod", color.luminance_modulation),
2412 ("lumOff", color.luminance_offset),
2413 ("satMod", color.saturation_modulation),
2414 ];
2415 for (name, value) in fractions {
2416 if let Some(value) = value {
2417 element = element.with_child(
2418 XmlElement::new(prefixes.drawing(name))
2419 .with_attribute("val", format_fixed(value * 100_000.0)),
2420 );
2421 }
2422 }
2423 let modifiers = [("tint", &color.theme_tint), ("shade", &color.theme_shade)];
2424 for (name, value) in modifiers {
2425 if let Some(byte) = value
2426 .as_deref()
2427 .and_then(|value| u8::from_str_radix(value, 16).ok())
2428 {
2429 element = element.with_child(
2430 XmlElement::new(prefixes.drawing(name))
2431 .with_attribute("val", format_fixed(f64::from(byte) / 255.0 * 100_000.0)),
2432 );
2433 }
2434 }
2435 Some(element)
2436}
2437
2438fn denormalize_scheme_color(value: &str) -> String {
2439 match value {
2440 "text1" => "tx1",
2441 "text2" => "tx2",
2442 "background1" => "bg1",
2443 "background2" => "bg2",
2444 value => value,
2445 }
2446 .to_owned()
2447}
2448
2449fn format_fixed(value: f64) -> String {
2450 (value.round() as i64).to_string()
2451}
2452
2453fn set_outline(properties: &mut XmlElement, outline: &ShapeOutline, prefixes: &Prefixes) {
2454 if let Some(line) = properties.child_mut("ln")
2455 && let Some(mut original) = crate::drawing::parse_outline_element(line)
2456 {
2457 original.width = outline.width;
2458 if original == *outline {
2459 if let Some(width) = outline.width {
2460 line.set_attribute("w", format_fixed(width));
2461 } else {
2462 line.attributes.remove("w");
2463 }
2464 return;
2465 }
2466 }
2467 let element = outline_element(outline, prefixes);
2468 let existing = properties.children.iter().position(
2469 |child| matches!(child, XmlNode::Element(element) if element.local_name() == "ln"),
2470 );
2471 match existing {
2472 Some(index) => properties.children[index] = XmlNode::Element(element),
2473 None => {
2474 let position = properties
2475 .children
2476 .iter()
2477 .position(|child| {
2478 matches!(
2479 child,
2480 XmlNode::Element(element)
2481 if POST_FILL_ELEMENTS[1..].contains(&element.local_name())
2482 )
2483 })
2484 .unwrap_or(properties.children.len());
2485 properties
2486 .children
2487 .insert(position, XmlNode::Element(element));
2488 }
2489 }
2490}
2491
2492fn outline_element(outline: &ShapeOutline, prefixes: &Prefixes) -> XmlElement {
2493 let mut element = XmlElement::new(prefixes.drawing("ln"));
2494 if *outline == ShapeOutline::default() {
2495 return element.with_child(XmlElement::new(prefixes.drawing("noFill")));
2496 }
2497 if let Some(width) = outline.width {
2498 element.set_attribute("w", format_fixed(width));
2499 }
2500 if let Some(cap) = &outline.cap {
2501 element.set_attribute("cap", cap.clone());
2502 }
2503 if let Some(color) = outline
2504 .color
2505 .as_ref()
2506 .and_then(|color| color_element(color, prefixes))
2507 {
2508 element =
2509 element.with_child(XmlElement::new(prefixes.drawing("solidFill")).with_child(color));
2510 } else if let Some(gradient) = outline
2511 .gradient
2512 .as_ref()
2513 .and_then(|gradient| gradient_element(gradient, prefixes))
2514 {
2515 element = element.with_child(gradient);
2516 }
2517 if let Some(style) = &outline.style {
2518 element = element.with_child(
2519 XmlElement::new(prefixes.drawing("prstDash")).with_attribute("val", style.clone()),
2520 );
2521 }
2522 if let Some(join) = &outline.join {
2523 element = element.with_child(XmlElement::new(prefixes.drawing(join)));
2524 }
2525 let ends = [
2526 ("headEnd", &outline.head_end),
2527 ("tailEnd", &outline.tail_end),
2528 ];
2529 for (name, end) in ends {
2530 if let Some(end) = end {
2531 let mut end_element = XmlElement::new(prefixes.drawing(name))
2532 .with_attribute("type", end.end_type.clone());
2533 if let Some(width) = &end.width {
2534 end_element.set_attribute("w", width.clone());
2535 }
2536 if let Some(length) = &end.length {
2537 end_element.set_attribute("len", length.clone());
2538 }
2539 element = element.with_child(end_element);
2540 }
2541 }
2542 element
2543}
2544
2545fn set_adjust_values(
2546 properties: &mut XmlElement,
2547 adjust_values: &BTreeMap<String, f64>,
2548 prefixes: &Prefixes,
2549 part: &str,
2550) -> Result<(), PptxError> {
2551 let Some(geometry) = properties.child_mut("prstGeom") else {
2552 return Err(write_error(
2553 part,
2554 "cannot write adjustments onto a shape without preset geometry",
2555 ));
2556 };
2557 let list_name = prefixes.drawing("avLst");
2558 if geometry.child_mut("avLst").is_none() {
2559 geometry
2560 .children
2561 .insert(0, XmlNode::Element(XmlElement::new(list_name)));
2562 }
2563 let list = geometry.child_mut("avLst").expect("list ensured above");
2564 for (name, value) in adjust_values {
2566 let formula = format!("val {}", format_fixed(value * 100_000.0));
2567 let existing = list.children.iter_mut().find_map(|child| match child {
2568 XmlNode::Element(element)
2569 if element.local_name() == "gd" && element.attribute("name") == Some(name) =>
2570 {
2571 Some(element)
2572 }
2573 _ => None,
2574 });
2575 match existing {
2576 Some(guide) => guide.set_attribute("fmla", formula),
2577 None => list.children.push(XmlNode::Element(
2578 XmlElement::new(prefixes.drawing("gd"))
2579 .with_attribute("name", name.clone())
2580 .with_attribute("fmla", formula),
2581 )),
2582 }
2583 }
2584 Ok(())
2585}
2586
2587pub fn text_paragraph_xml(
2591 package: &PptxPackage,
2592 part: &str,
2593 shape_path: &[usize],
2594 target: &TextTarget,
2595) -> Result<Vec<String>, PptxError> {
2596 let Some(bytes) = package.part_bytes(part) else {
2597 return Ok(Vec::new());
2598 };
2599 let limits = ParseLimits::default();
2600 let mut budget = ParseBudget::new(&limits);
2601 let mut root = parse_xml(bytes, part, &mut budget)?;
2602 let mut element = root
2603 .child_mut("cSld")
2604 .and_then(|data| data.child_mut("spTree"))
2605 .ok_or_else(|| write_error(part, "missing shape tree"))?
2606 .clone();
2607 for &index in shape_path {
2608 let mut slots: Vec<_> = element.children.into_iter().map(Some).collect();
2609 let paths = shape_slots(&slots, package.shape_elements);
2610 let slot = paths
2611 .get(index)
2612 .ok_or_else(|| write_error(part, "missing source shape"))?;
2613 element = element_at_path(slots[slot.position].as_mut().unwrap(), &slot.path)
2614 .ok_or_else(|| write_error(part, "missing source shape"))?
2615 .clone();
2616 }
2617 let body = match target {
2618 TextTarget::Body => element.child_mut("txBody"),
2619 TextTarget::TableCell { row, cell } => element
2620 .child_mut("graphic")
2621 .and_then(|graphic| graphic.child_mut("graphicData"))
2622 .and_then(|data| data.child_mut("tbl"))
2623 .and_then(|table| nth_child_mut(table, "tr", *row))
2624 .and_then(|row| nth_child_mut(row, "tc", *cell))
2625 .and_then(|cell| cell.child_mut("txBody")),
2626 };
2627 Ok(body
2628 .into_iter()
2629 .flat_map(|body| body.children_named("p"))
2630 .map(|paragraph| String::from_utf8(serialize_xml_fragment(paragraph)).unwrap())
2631 .collect())
2632}
2633
2634fn table_cell_mut(element: &mut XmlElement, row: usize, cell: usize) -> Option<&mut XmlElement> {
2635 element
2636 .child_mut("graphic")
2637 .and_then(|graphic| graphic.child_mut("graphicData"))
2638 .and_then(|data| data.child_mut("tbl"))
2639 .and_then(|table| nth_child_mut(table, "tr", row))
2640 .and_then(|row| nth_child_mut(row, "tc", cell))
2641}
2642
2643fn empty_table_runs(
2644 element: &mut XmlElement,
2645 text: &TextWrite,
2646 theme: Option<&Theme>,
2647 prefixes: &Prefixes,
2648) -> Vec<(usize, XmlElement)> {
2649 let TextTarget::TableCell { row, cell } = text.target else {
2650 return Vec::new();
2651 };
2652 let Some(body) = table_cell_mut(element, row, cell).and_then(|cell| cell.child_mut("txBody"))
2653 else {
2654 return Vec::new();
2655 };
2656 text.paragraphs
2657 .iter()
2658 .enumerate()
2659 .filter_map(|(index, paragraph)| {
2660 if !paragraph.rebuild || paragraph.runs.iter().any(|run| !run.text.is_empty()) {
2661 return None;
2662 }
2663 let source = body.children_named("p").nth(paragraph.source_index?)?;
2664 let mut run = source.children_named("r").next()?.clone();
2665 run.children.retain(
2666 |child| matches!(child, XmlNode::Element(element) if element.local_name() == "rPr"),
2667 );
2668 if let Some(style) = paragraph.runs.first() {
2669 if run.child("rPr").is_none() {
2670 run.children.insert(
2671 0,
2672 XmlNode::Element(XmlElement::new(prefixes.drawing("rPr"))),
2673 );
2674 }
2675 apply_run_properties(
2676 run.child_mut("rPr").unwrap(),
2677 &style.properties,
2678 theme,
2679 prefixes,
2680 );
2681 }
2682 let name = source.children_named("r").next()?.child("t")?.name.clone();
2683 run.children.push(XmlNode::Element(XmlElement::new(name)));
2684 Some((index, run))
2685 })
2686 .collect()
2687}
2688
2689fn patch_table(
2690 element: &mut XmlElement,
2691 structure: &TableStructure,
2692 part: &str,
2693) -> Result<(), PptxError> {
2694 if structure.grid.is_empty()
2695 || structure.rows.is_empty()
2696 || structure.grid.len() != structure.columns.len()
2697 || structure.grid.len() != structure.copied_columns.len()
2698 || structure.rows.iter().any(|row| {
2699 row.cells.len() != structure.grid.len()
2700 || row.copied_cells.len() != structure.grid.len()
2701 })
2702 {
2703 return Err(write_error(
2704 part,
2705 "table structure grid and cell counts must agree",
2706 ));
2707 }
2708 let table = element
2709 .child_mut("graphic")
2710 .and_then(|graphic| graphic.child_mut("graphicData"))
2711 .and_then(|data| data.child_mut("tbl"))
2712 .ok_or_else(|| write_error(part, "table structure target is not a table"))?;
2713 let mut used: HashSet<String> = table
2714 .descendants_named("rowId")
2715 .into_iter()
2716 .chain(table.descendants_named("colId"))
2717 .chain(table.descendants_named("creationId"))
2718 .flat_map(|element| element.attributes.values())
2719 .cloned()
2720 .collect();
2721 let mut next_id = used
2722 .iter()
2723 .filter_map(|value| value.parse::<u64>().ok())
2724 .max()
2725 .unwrap_or(0);
2726 let original_rows: Vec<_> = table.children_named("tr").cloned().collect();
2727 let grid = table
2728 .child_mut("tblGrid")
2729 .ok_or_else(|| write_error(part, "table has no grid"))?;
2730 let originals: Vec<_> = grid.children_named("gridCol").cloned().collect();
2731 let columns = structure
2732 .grid
2733 .iter()
2734 .zip(&structure.columns)
2735 .zip(&structure.copied_columns)
2736 .map(|((width, source), copied)| {
2737 let mut column = originals
2738 .get(*source)
2739 .cloned()
2740 .ok_or_else(|| write_error(part, "table source grid column is out of range"))?;
2741 column.set_attribute("w", width.to_string());
2742 if *copied {
2743 fresh_table_ids(&mut column, &mut next_id, &mut used, part)?;
2744 }
2745 Ok(XmlNode::Element(column))
2746 })
2747 .collect::<Result<Vec<_>, PptxError>>()?;
2748 replace_children(grid, "gridCol", columns);
2749 let rows = structure
2750 .rows
2751 .iter()
2752 .map(|source| {
2753 let mut row = original_rows
2754 .get(source.source_row)
2755 .cloned()
2756 .ok_or_else(|| write_error(part, "table source row is out of range"))?;
2757 row.set_attribute("h", source.height.to_string());
2758 let cells = source
2759 .cells
2760 .iter()
2761 .zip(&source.copied_cells)
2762 .map(|(&[r, c], copied)| {
2763 let mut cell = original_rows
2764 .get(r)
2765 .and_then(|row| row.children_named("tc").nth(c))
2766 .cloned()
2767 .ok_or_else(|| write_error(part, "table source cell is out of range"))?;
2768 if *copied && !source.copied {
2769 fresh_table_ids(&mut cell, &mut next_id, &mut used, part)?;
2770 }
2771 Ok(XmlNode::Element(cell))
2772 })
2773 .collect::<Result<Vec<_>, PptxError>>()?;
2774 replace_children(&mut row, "tc", cells);
2775 if source.copied {
2776 fresh_table_ids(&mut row, &mut next_id, &mut used, part)?;
2777 }
2778 Ok(XmlNode::Element(row))
2779 })
2780 .collect::<Result<Vec<_>, PptxError>>()?;
2781 replace_children(table, "tr", rows);
2782 Ok(())
2783}
2784
2785fn fresh_table_ids(
2786 element: &mut XmlElement,
2787 next: &mut u64,
2788 used: &mut HashSet<String>,
2789 part: &str,
2790) -> Result<(), PptxError> {
2791 if matches!(element.local_name(), "rowId" | "colId" | "creationId") {
2792 for (key, value) in &mut element.attributes {
2793 if matches!(key.rsplit(':').next(), Some("val" | "id")) {
2794 loop {
2795 *next = next
2796 .checked_add(1)
2797 .filter(|id| *id <= u64::from(u32::MAX))
2798 .ok_or_else(|| write_error(part, "table identifier range exhausted"))?;
2799 let candidate = if value.parse::<u64>().is_ok() {
2800 next.to_string()
2801 } else {
2802 format!("{{FFFFFFFF-FFFF-4FFF-8FFF-{:012X}}}", *next)
2803 };
2804 if used.insert(candidate.clone()) {
2805 *value = candidate;
2806 break;
2807 }
2808 }
2809 }
2810 }
2811 }
2812 for child in &mut element.children {
2813 if let XmlNode::Element(child) = child {
2814 fresh_table_ids(child, next, used, part)?;
2815 }
2816 }
2817 Ok(())
2818}
2819
2820fn replace_children(parent: &mut XmlElement, local: &str, replacements: Vec<XmlNode>) {
2821 let at = parent
2822 .children
2823 .iter()
2824 .position(
2825 |child| matches!(child, XmlNode::Element(element) if element.local_name() == local),
2826 )
2827 .unwrap_or(parent.children.len());
2828 parent.children.retain(
2829 |child| !matches!(child, XmlNode::Element(element) if element.local_name() == local),
2830 );
2831 parent.children.splice(at..at, replacements);
2832}
2833
2834fn patch_text(
2835 element: &mut XmlElement,
2836 text: &TextWrite,
2837 theme: Option<&Theme>,
2838 prefixes: &Prefixes,
2839 part: &str,
2840) -> Result<(), PptxError> {
2841 let body = match &text.target {
2842 TextTarget::Body => element.child_mut("txBody"),
2843 TextTarget::TableCell { row, cell } => element
2844 .child_mut("graphic")
2845 .and_then(|graphic| graphic.child_mut("graphicData"))
2846 .and_then(|data| data.child_mut("tbl"))
2847 .and_then(|table| nth_child_mut(table, "tr", *row))
2848 .and_then(|table_row| nth_child_mut(table_row, "tc", *cell))
2849 .and_then(|table_cell| table_cell.child_mut("txBody")),
2850 };
2851 let body = body.ok_or_else(|| write_error(part, "text target has no body"))?;
2852 if let Some(TextAutofit::Normal {
2853 font_scale,
2854 line_space_reduction,
2855 }) = &text.autofit
2856 {
2857 if body.child("bodyPr").is_none() {
2858 body.children.insert(
2859 0,
2860 XmlNode::Element(XmlElement::new(prefixes.drawing("bodyPr"))),
2861 );
2862 }
2863 let properties = body.child_mut("bodyPr").unwrap();
2864 if properties.child("normAutofit").is_none() {
2865 let position = properties.children.iter().position(|child| {
2866 matches!(child, XmlNode::Element(element) if matches!(element.local_name(), "scene3d" | "sp3d" | "flatTx" | "extLst"))
2867 }).unwrap_or(properties.children.len());
2868 properties.children.insert(
2869 position,
2870 XmlNode::Element(XmlElement::new(prefixes.drawing("normAutofit"))),
2871 );
2872 }
2873 let autofit = properties.child_mut("normAutofit").unwrap();
2874 if let Some(scale) = font_scale {
2875 autofit.set_attribute("fontScale", format_fixed(scale * 100_000.0));
2876 }
2877 if let Some(reduction) = line_space_reduction {
2878 autofit.set_attribute("lnSpcReduction", format_fixed(reduction * 100_000.0));
2879 }
2880 }
2881 rebuild_paragraphs(body, &text.paragraphs, theme, prefixes)?;
2882 Ok(())
2883}
2884
2885fn nth_child_mut<'a>(
2886 parent: &'a mut XmlElement,
2887 local: &str,
2888 index: usize,
2889) -> Option<&'a mut XmlElement> {
2890 parent
2891 .children
2892 .iter_mut()
2893 .filter_map(|child| match child {
2894 XmlNode::Element(element) if element.local_name() == local => Some(element),
2895 _ => None,
2896 })
2897 .nth(index)
2898}
2899
2900fn rebuild_paragraphs(
2901 body: &mut XmlElement,
2902 paragraphs: &[ParagraphWrite],
2903 theme: Option<&Theme>,
2904 prefixes: &Prefixes,
2905) -> Result<(), PptxError> {
2906 let mut preamble = Vec::new();
2907 let mut originals: Vec<XmlElement> = Vec::new();
2908 for child in std::mem::take(&mut body.children) {
2909 match child {
2910 XmlNode::Element(element) if element.local_name() == "p" => {
2911 originals.push(element);
2912 }
2913 XmlNode::Text(text) if text.trim().is_empty() => {}
2914 other => preamble.push(other),
2915 }
2916 }
2917 let mut children = preamble;
2918 let limits = ParseLimits::default();
2919 let mut budget = ParseBudget::new(&limits);
2920 for paragraph in paragraphs {
2921 let source = if let Some(first) = paragraph.source_paragraphs.first() {
2922 first
2923 .as_ref()
2924 .map(|xml| parse_xml(xml.as_bytes(), "paragraph", &mut budget))
2925 .transpose()?
2926 } else if let Some(xml) = ¶graph.preserved_xml {
2927 Some(parse_xml(xml.as_bytes(), "paragraph", &mut budget)?)
2928 } else {
2929 paragraph
2930 .source_index
2931 .and_then(|index| originals.get(index).cloned())
2932 };
2933 let mut source = source;
2934 for xml in paragraph.source_paragraphs.iter().skip(1) {
2935 let source = source.get_or_insert_with(|| XmlElement::new(prefixes.drawing("p")));
2936 if let Some(xml) = xml {
2937 let next = parse_xml(xml.as_bytes(), "paragraph", &mut budget)?;
2938 if next.child("endParaRPr").is_some() {
2939 source.children.retain(|child| {
2940 !matches!(child, XmlNode::Element(element) if element.local_name() == "endParaRPr")
2941 });
2942 }
2943 source.children.extend(
2944 next.child_elements()
2945 .filter(|child| {
2946 is_run_element(child.local_name()) || child.local_name() == "endParaRPr"
2947 })
2948 .cloned()
2949 .map(XmlNode::Element),
2950 );
2951 }
2952 }
2953 let element = match source {
2954 Some(element) if !paragraph.rebuild => element,
2955 source => build_paragraph(paragraph, source, theme, prefixes),
2956 };
2957 children.push(XmlNode::Element(element));
2958 }
2959 if children
2960 .iter()
2961 .all(|child| !matches!(child, XmlNode::Element(element) if element.local_name() == "p"))
2962 {
2963 children.push(XmlNode::Element(XmlElement::new(prefixes.drawing("p"))));
2964 }
2965 body.children = children;
2966 Ok(())
2967}
2968
2969fn is_run_element(local: &str) -> bool {
2970 matches!(local, "r" | "br" | "fld")
2971}
2972
2973struct RunSegment<'a> {
2975 line_break: bool,
2976 text: &'a str,
2977 properties: &'a RunProperties,
2978}
2979
2980fn run_segments(runs: &[RunWrite]) -> Vec<RunSegment<'_>> {
2981 let mut segments = Vec::new();
2982 for run in runs {
2983 for (index, piece) in run.text.split('\n').enumerate() {
2984 if index > 0 {
2985 segments.push(RunSegment {
2986 line_break: true,
2987 text: "",
2988 properties: &run.properties,
2989 });
2990 }
2991 if !piece.is_empty() {
2992 segments.push(RunSegment {
2993 line_break: false,
2994 text: piece,
2995 properties: &run.properties,
2996 });
2997 }
2998 }
2999 }
3000 segments
3001}
3002
3003fn segment_matches(element: &XmlElement, segment: &RunSegment<'_>, theme: Option<&Theme>) -> bool {
3005 if element.local_name() == "br" {
3006 return segment.line_break;
3007 }
3008 if segment.line_break {
3009 return false;
3010 }
3011 let text = element
3012 .child("t")
3013 .map(XmlElement::text_content)
3014 .unwrap_or_default();
3015 if text != segment.text {
3016 return false;
3017 }
3018 let source = parse_run_properties(element.child("rPr"));
3019 let target = segment.properties;
3020 source.baseline_pct == target.baseline_pct
3021 && source.bold == target.bold
3022 && source.italic == target.italic
3023 && source.font_size_pt == target.font_size_pt
3024 && source.spacing_pt == target.spacing_pt
3025 && source.kern_pt == target.kern_pt
3026 && source.underline == target.underline
3027 && source.caps == target.caps
3028 && source.font_family == target.font_family
3029 && resolve_color_value_to_hex_with_theme(source.color.as_ref(), theme)
3030 == resolve_color_value_to_hex_with_theme(target.color.as_ref(), theme)
3031}
3032
3033fn build_paragraph(
3034 write: &ParagraphWrite,
3035 source: Option<XmlElement>,
3036 theme: Option<&Theme>,
3037 prefixes: &Prefixes,
3038) -> XmlElement {
3039 let (mut paragraph, source_runs) = match source {
3040 Some(mut element) => {
3041 let mut runs = Vec::new();
3042 let mut kept = Vec::new();
3043 for child in std::mem::take(&mut element.children) {
3044 match child {
3045 XmlNode::Element(child) if is_run_element(child.local_name()) => {
3046 runs.push(child);
3047 }
3048 XmlNode::Text(text) if text.trim().is_empty() => {}
3049 other => kept.push(other),
3050 }
3051 }
3052 element.children = kept;
3053 (element, runs)
3054 }
3055 None => (XmlElement::new(prefixes.drawing("p")), Vec::new()),
3056 };
3057 let fresh = write.source_index.is_none();
3058 if write.properties_changed || fresh {
3059 apply_paragraph_properties(&mut paragraph, write, prefixes);
3060 }
3061 let segments = run_segments(&write.runs);
3062 let mut front = 0;
3063 let mut back = 0;
3064 while front < source_runs.len()
3065 && front < segments.len()
3066 && segment_matches(&source_runs[front], &segments[front], theme)
3067 {
3068 let steals = front + 1 < source_runs.len() - back
3069 && segment_matches(&source_runs[front + 1], &segments[front], theme)
3070 && source_span_len(&source_runs, front + 1..source_runs.len() - back)
3071 > target_span_len(&segments, front + 1..segments.len() - back);
3072 if steals {
3073 break;
3074 }
3075 front += 1;
3076 }
3077 while back < source_runs.len() - front
3078 && back < segments.len() - front
3079 && segment_matches(
3080 &source_runs[source_runs.len() - 1 - back],
3081 &segments[segments.len() - 1 - back],
3082 theme,
3083 )
3084 {
3085 let src = source_runs.len() - 1 - back;
3086 let seg = segments.len() - 1 - back;
3087 let steals = src > front
3088 && segment_matches(&source_runs[src - 1], &segments[seg], theme)
3089 && source_span_len(&source_runs, front..src) > target_span_len(&segments, front..seg);
3090 if steals {
3091 break;
3092 }
3093 back += 1;
3094 }
3095 let tail_start = source_runs.len() - back;
3096 let rebuilt = span_elements(
3097 &segments[front..segments.len() - back],
3098 &source_runs,
3099 front..tail_start,
3100 theme,
3101 prefixes,
3102 );
3103 let mut runs: Vec<XmlNode> = Vec::with_capacity(segments.len());
3104 let mut source_runs = source_runs.into_iter();
3105 for element in source_runs.by_ref().take(front) {
3106 runs.push(XmlNode::Element(element));
3107 }
3108 runs.extend(rebuilt);
3109 for element in source_runs.skip(tail_start - front) {
3110 runs.push(XmlNode::Element(element));
3111 }
3112 let position = paragraph
3113 .children
3114 .iter()
3115 .position(|child| {
3116 matches!(child, XmlNode::Element(element) if element.local_name() == "endParaRPr")
3117 })
3118 .unwrap_or(paragraph.children.len());
3119 paragraph.children.splice(position..position, runs);
3120 paragraph
3121}
3122
3123struct TargetRange {
3125 end: usize,
3126 source: usize,
3127 verbatim: bool,
3129 field: bool,
3131}
3132
3133fn span_elements(
3135 segments: &[RunSegment<'_>],
3136 source_runs: &[XmlElement],
3137 span: Range<usize>,
3138 theme: Option<&Theme>,
3139 prefixes: &Prefixes,
3140) -> Vec<XmlNode> {
3141 if source_runs.is_empty() {
3142 return segments
3143 .iter()
3144 .map(|segment| XmlNode::Element(segment_element(segment, None, theme, prefixes)))
3145 .collect();
3146 }
3147 let ranges = align_span(segments, source_runs, span);
3148 let mut output = Vec::new();
3149 let mut offset = 0;
3150 let mut range_index = 0;
3151 let mut range_start = 0;
3152 for segment in segments {
3153 let mut remaining = segment.text;
3154 loop {
3155 while ranges[range_index].end <= offset {
3156 range_start = ranges[range_index].end;
3157 range_index += 1;
3158 }
3159 let range = &ranges[range_index];
3160 let source = &source_runs[range.source];
3161 if segment.line_break {
3162 let template = if source.local_name() == "br"
3163 || source
3164 .child("rPr")
3165 .is_some_and(|properties| properties.child("gradFill").is_some())
3166 {
3167 source.child("rPr")
3168 } else {
3169 None
3170 };
3171 output.push(XmlNode::Element(segment_element(
3172 segment, template, theme, prefixes,
3173 )));
3174 offset += 1;
3175 break;
3176 }
3177 let length = remaining.len().min(range.end - offset);
3178 let piece = RunSegment {
3179 text: &remaining[..length],
3180 ..*segment
3181 };
3182 let element = if range.field && offset == range_start && offset + length == range.end {
3183 field_element(source, &piece, theme, prefixes)
3184 } else {
3185 segment_element(&piece, source.child("rPr"), theme, prefixes)
3186 };
3187 output.push(XmlNode::Element(element));
3188 offset += length;
3189 remaining = &remaining[length..];
3190 if remaining.is_empty() {
3191 break;
3192 }
3193 }
3194 }
3195 output
3196}
3197
3198fn run_text(run: &XmlElement) -> String {
3199 if run.local_name() == "br" {
3200 return "\n".to_owned();
3201 }
3202 run.child("t")
3203 .map(XmlElement::text_content)
3204 .unwrap_or_default()
3205}
3206
3207fn segment_text<'a>(segment: &RunSegment<'a>) -> &'a str {
3208 if segment.line_break {
3209 "\n"
3210 } else {
3211 segment.text
3212 }
3213}
3214
3215fn source_span_len(runs: &[XmlElement], span: Range<usize>) -> usize {
3216 runs[span].iter().map(|run| run_text(run).len()).sum()
3217}
3218
3219fn target_span_len(segments: &[RunSegment<'_>], span: Range<usize>) -> usize {
3220 segments[span]
3221 .iter()
3222 .map(|segment| segment_text(segment).len())
3223 .sum()
3224}
3225
3226fn push_range(ranges: &mut Vec<TargetRange>, end: usize, source: usize, verbatim: bool) {
3227 if end <= ranges.last().map_or(0, |last| last.end) {
3228 return;
3229 }
3230 match ranges.last_mut() {
3231 Some(last) if last.source == source && last.verbatim == verbatim => last.end = end,
3232 _ => ranges.push(TargetRange {
3233 end,
3234 source,
3235 verbatim,
3236 field: false,
3237 }),
3238 }
3239}
3240
3241fn align_span(
3242 segments: &[RunSegment<'_>],
3243 source_runs: &[XmlElement],
3244 span: Range<usize>,
3245) -> Vec<TargetRange> {
3246 let runs = &source_runs[span.clone()];
3247 let mut source_text = String::new();
3248 let mut source_ends = Vec::with_capacity(runs.len());
3249 for run in runs {
3250 source_text.push_str(&run_text(run));
3251 source_ends.push(source_text.len());
3252 }
3253 let target_text: String = segments.iter().map(segment_text).collect();
3254 let run_at = |offset: usize| {
3255 span.start
3256 + source_ends
3257 .iter()
3258 .position(|&end| end > offset)
3259 .unwrap_or(source_ends.len().saturating_sub(1))
3260 };
3261 let prefix: usize = source_text
3262 .chars()
3263 .zip(target_text.chars())
3264 .take_while(|(left, right)| left == right)
3265 .map(|(value, _)| value.len_utf8())
3266 .sum();
3267 let suffix: usize = source_text[prefix..]
3268 .chars()
3269 .rev()
3270 .zip(target_text[prefix..].chars().rev())
3271 .take_while(|(left, right)| left == right)
3272 .map(|(value, _)| value.len_utf8())
3273 .sum();
3274 let source_end = source_text.len() - suffix;
3275 let target_end = target_text.len() - suffix;
3276
3277 let mut ranges: Vec<TargetRange> = Vec::new();
3278 for (index, &end) in source_ends.iter().enumerate() {
3279 push_range(&mut ranges, end.min(prefix), span.start + index, true);
3280 }
3281 let seed = span.start.saturating_sub(1);
3282 let (mut source_offset, mut target_offset) = (prefix, prefix);
3283 let mut replaced: Option<usize> = None;
3284 for op in char_diff(
3285 &source_text[prefix..source_end],
3286 &target_text[prefix..target_end],
3287 ) {
3288 match op {
3289 DiffOp::Match(length) => {
3290 let mut done = 0;
3291 while done < length {
3292 let run = run_at(source_offset + done);
3293 let piece =
3294 (source_ends[run - span.start] - (source_offset + done)).min(length - done);
3295 push_range(&mut ranges, target_offset + done + piece, run, true);
3296 done += piece;
3297 }
3298 source_offset += length;
3299 target_offset += length;
3300 replaced = None;
3301 }
3302 DiffOp::Delete(length) => {
3303 if replaced.is_none() {
3304 replaced = Some(run_at(source_offset));
3305 }
3306 source_offset += length;
3307 }
3308 DiffOp::Insert(length) => {
3309 let source = replaced
3310 .take()
3311 .or_else(|| ranges.last().map(|range| range.source))
3312 .unwrap_or(seed);
3313 push_range(&mut ranges, target_offset + length, source, false);
3314 target_offset += length;
3315 }
3316 }
3317 }
3318 for (index, &end) in source_ends.iter().enumerate() {
3319 if end > source_end {
3320 push_range(
3321 &mut ranges,
3322 target_end + end - source_end,
3323 span.start + index,
3324 true,
3325 );
3326 }
3327 }
3328 let mut kept = vec![false; runs.len()];
3329 for range in &ranges {
3330 if range.verbatim {
3331 kept[range.source - span.start] = true;
3332 }
3333 }
3334 for field_idx in 0..runs.len() {
3335 if runs[field_idx].local_name() != "fld" || kept[field_idx] {
3336 continue;
3337 }
3338 let field_text = run_text(&runs[field_idx]);
3339 if field_text.is_empty() {
3340 continue;
3341 }
3342 let mut donor: Option<usize> = None;
3343 for candidate in [
3344 field_idx.checked_sub(1),
3345 field_idx.checked_add(1).filter(|next| *next < runs.len()),
3346 ]
3347 .into_iter()
3348 .flatten()
3349 {
3350 if runs[candidate].local_name() == "r"
3351 && kept[candidate]
3352 && run_text(&runs[candidate]) == field_text
3353 {
3354 donor = Some(candidate);
3355 break;
3356 }
3357 }
3358 if let Some(donor) = donor {
3359 for range in &mut ranges {
3360 if range.verbatim && range.source == span.start + donor {
3361 range.source = span.start + field_idx;
3362 }
3363 }
3364 kept[donor] = false;
3365 kept[field_idx] = true;
3366 }
3367 }
3368
3369 let mut verbatim_ranges = vec![0usize; runs.len()];
3370 for range in ranges.iter().filter(|range| range.verbatim) {
3371 verbatim_ranges[range.source - span.start] += 1;
3372 }
3373 let mut merged: Vec<TargetRange> = Vec::with_capacity(ranges.len());
3374 let mut start = 0;
3375 for mut range in ranges {
3376 range.field = range.verbatim && {
3377 let relative = range.source - span.start;
3378 let run_start = relative
3379 .checked_sub(1)
3380 .map_or(0, |previous| source_ends[previous]);
3381 runs[relative].local_name() == "fld"
3382 && verbatim_ranges[relative] == 1
3383 && range.end - start == source_ends[relative] - run_start
3384 };
3385 start = range.end;
3386 match merged.last_mut() {
3387 Some(last) if last.source == range.source && !last.field && !range.field => {
3388 last.end = range.end
3389 }
3390 _ => merged.push(range),
3391 }
3392 }
3393 merged
3394}
3395
3396enum DiffOp {
3398 Match(usize),
3399 Delete(usize),
3400 Insert(usize),
3401}
3402
3403const DIFF_CELL_LIMIT: usize = 1 << 20;
3404
3405fn push_op(ops: &mut Vec<DiffOp>, op: DiffOp) {
3406 match (ops.last_mut(), op) {
3407 (Some(DiffOp::Match(last)), DiffOp::Match(length))
3408 | (Some(DiffOp::Delete(last)), DiffOp::Delete(length))
3409 | (Some(DiffOp::Insert(last)), DiffOp::Insert(length)) => *last += length,
3410 (_, op) => ops.push(op),
3411 }
3412}
3413
3414fn char_diff(source: &str, target: &str) -> Vec<DiffOp> {
3416 let source_chars: Vec<char> = source.chars().collect();
3417 let target_chars: Vec<char> = target.chars().collect();
3418 let (rows, columns) = (source_chars.len(), target_chars.len());
3419 let mut ops = Vec::new();
3420 if rows == 0 || columns == 0 || rows.saturating_mul(columns) > DIFF_CELL_LIMIT {
3421 if rows > 0 {
3422 push_op(&mut ops, DiffOp::Delete(source.len()));
3423 }
3424 if columns > 0 {
3425 push_op(&mut ops, DiffOp::Insert(target.len()));
3426 }
3427 return ops;
3428 }
3429 let width = columns + 1;
3430 let mut table = vec![0u16; (rows + 1) * width];
3431 for row in (0..rows).rev() {
3432 for column in (0..columns).rev() {
3433 table[row * width + column] = if source_chars[row] == target_chars[column] {
3434 table[(row + 1) * width + column + 1] + 1
3435 } else {
3436 table[(row + 1) * width + column].max(table[row * width + column + 1])
3437 };
3438 }
3439 }
3440 let (mut row, mut column) = (0, 0);
3441 while row < rows && column < columns {
3442 if source_chars[row] == target_chars[column] {
3443 push_op(&mut ops, DiffOp::Match(source_chars[row].len_utf8()));
3444 row += 1;
3445 column += 1;
3446 } else if table[(row + 1) * width + column] >= table[row * width + column + 1] {
3447 push_op(&mut ops, DiffOp::Delete(source_chars[row].len_utf8()));
3448 row += 1;
3449 } else {
3450 push_op(&mut ops, DiffOp::Insert(target_chars[column].len_utf8()));
3451 column += 1;
3452 }
3453 }
3454 for value in &source_chars[row..] {
3455 push_op(&mut ops, DiffOp::Delete(value.len_utf8()));
3456 }
3457 for value in &target_chars[column..] {
3458 push_op(&mut ops, DiffOp::Insert(value.len_utf8()));
3459 }
3460 ops
3461}
3462
3463fn segment_properties(
3464 segment: &RunSegment<'_>,
3465 template: Option<&XmlElement>,
3466 theme: Option<&Theme>,
3467 prefixes: &Prefixes,
3468) -> Option<XmlElement> {
3469 match template {
3470 Some(template) => {
3471 let mut base = template.clone();
3472 apply_run_properties(&mut base, segment.properties, theme, prefixes);
3473 Some(base)
3474 }
3475 None => run_properties_element(segment.properties, prefixes),
3476 }
3477}
3478
3479fn segment_element(
3480 segment: &RunSegment<'_>,
3481 template: Option<&XmlElement>,
3482 theme: Option<&Theme>,
3483 prefixes: &Prefixes,
3484) -> XmlElement {
3485 let mut element =
3486 XmlElement::new(prefixes.drawing(if segment.line_break { "br" } else { "r" }));
3487 if let Some(properties) = segment_properties(segment, template, theme, prefixes) {
3488 element = element.with_child(properties);
3489 }
3490 if segment.line_break {
3491 element
3492 } else {
3493 element.with_child(XmlElement::new(prefixes.drawing("t")).with_text(segment.text))
3494 }
3495}
3496
3497fn field_element(
3499 source: &XmlElement,
3500 segment: &RunSegment<'_>,
3501 theme: Option<&Theme>,
3502 prefixes: &Prefixes,
3503) -> XmlElement {
3504 let mut element = XmlElement::new(source.name.clone());
3505 element.attributes = source.attributes.clone();
3506 if let Some(properties) = segment_properties(segment, source.child("rPr"), theme, prefixes) {
3507 element.children.push(XmlNode::Element(properties));
3508 }
3509 element.children.extend(
3510 source
3511 .child_elements()
3512 .filter(|child| !matches!(child.local_name(), "rPr" | "t"))
3513 .cloned()
3514 .map(XmlNode::Element),
3515 );
3516 element.children.push(XmlNode::Element(
3517 XmlElement::new(prefixes.drawing("t")).with_text(segment.text),
3518 ));
3519 element
3520}
3521
3522const POST_LATIN_ELEMENTS: [&str; 7] = [
3523 "ea",
3524 "cs",
3525 "sym",
3526 "hlinkClick",
3527 "hlinkMouseOver",
3528 "rtl",
3529 "extLst",
3530];
3531
3532fn is_flattened_colour_snapshot(
3533 authored: Option<&ColorValue>,
3534 requested: Option<&ColorValue>,
3535 theme: Option<&Theme>,
3536) -> bool {
3537 let (Some(authored), Some(requested)) = (authored, requested) else {
3538 return false;
3539 };
3540 if !requested.transforms.is_empty()
3541 || requested.theme_color.is_some()
3542 || requested.theme_tint.is_some()
3543 || requested.theme_shade.is_some()
3544 || requested.auto.is_some()
3545 || requested.luminance_modulation.is_some()
3546 || requested.luminance_offset.is_some()
3547 || requested.saturation_modulation.is_some()
3548 {
3549 return false;
3550 }
3551 if requested.alpha.is_some()
3552 && ooxml_drawingml::resolve_color_value_alpha(requested)
3553 != ooxml_drawingml::resolve_color_value_alpha(authored)
3554 {
3555 return false;
3556 }
3557 resolve_color_value_to_hex_with_theme(Some(authored), theme)
3558 .zip(requested.rgb.as_deref())
3559 .is_some_and(|(hex, rgb)| rgb.eq_ignore_ascii_case(&hex[1..]))
3560}
3561
3562fn apply_run_properties(
3564 base: &mut XmlElement,
3565 properties: &RunProperties,
3566 theme: Option<&Theme>,
3567 prefixes: &Prefixes,
3568) {
3569 match properties.font_size_pt {
3570 Some(size) => base.set_attribute("sz", format_fixed(size * 100.0)),
3571 None => {
3572 base.attributes.remove("sz");
3573 }
3574 }
3575 match properties.spacing_pt {
3576 Some(spacing) => base.set_attribute("spc", format_fixed(spacing * 100.0)),
3577 None => {
3578 base.attributes.remove("spc");
3579 }
3580 }
3581 match properties.kern_pt {
3582 Some(kern) => base.set_attribute("kern", format_fixed(kern * 100.0)),
3583 None => {
3584 base.attributes.remove("kern");
3585 }
3586 }
3587 match properties.baseline_pct {
3588 Some(baseline) => base.set_attribute("baseline", format_fixed(baseline * 1000.0)),
3589 None => {
3590 base.attributes.remove("baseline");
3591 }
3592 }
3593 match properties.caps {
3594 Some(caps) => base.set_attribute("cap", caps.as_attribute()),
3595 None => {
3596 base.attributes.remove("cap");
3597 }
3598 }
3599 let toggles = [("b", properties.bold), ("i", properties.italic)];
3600 for (name, value) in toggles {
3601 match value {
3602 Some(value) => base.set_attribute(name, if value { "1" } else { "0" }),
3603 None => {
3604 base.attributes.remove(name);
3605 }
3606 }
3607 }
3608 match &properties.underline {
3609 Some(underline) => base.set_attribute("u", underline.clone()),
3610 None => {
3611 base.attributes.remove("u");
3612 }
3613 }
3614 let authored = parse_run_properties(Some(base)).color;
3615 let keeps_fill = authored == properties.color
3616 || is_flattened_colour_snapshot(authored.as_ref(), properties.color.as_ref(), theme);
3617 let removed_fills: &[&str] = if keeps_fill {
3620 &[]
3621 } else if properties.color.is_some() {
3622 &FILL_ELEMENTS
3623 } else {
3624 &["solidFill"]
3625 };
3626 base.children.retain(|child| {
3627 !matches!(
3628 child,
3629 XmlNode::Element(element) if removed_fills.contains(&element.local_name())
3630 )
3631 });
3632 if let Some(color) = properties
3633 .color
3634 .as_ref()
3635 .filter(|_| !keeps_fill)
3636 .and_then(|color| color_element(color, prefixes))
3637 {
3638 let position = base
3639 .children
3640 .iter()
3641 .position(
3642 |child| !matches!(child, XmlNode::Element(element) if element.local_name() == "ln"),
3643 )
3644 .unwrap_or(base.children.len());
3645 base.children.insert(
3646 position,
3647 XmlNode::Element(XmlElement::new(prefixes.drawing("solidFill")).with_child(color)),
3648 );
3649 }
3650 let Some(family) = &properties.font_family else {
3651 base.children.retain(|child| {
3652 !matches!(
3653 child,
3654 XmlNode::Element(element) if element.local_name() == "latin"
3655 )
3656 });
3657 return;
3658 };
3659 if let Some(latin) = base.child_mut("latin") {
3660 if latin.attribute("typeface") != Some(family.as_str()) {
3661 latin
3663 .attributes
3664 .retain(|name, _| name == "xmlns" || name.starts_with("xmlns:"));
3665 latin.set_attribute("typeface", family.clone());
3666 }
3667 } else {
3668 let position = base
3669 .children
3670 .iter()
3671 .position(|child| {
3672 matches!(
3673 child,
3674 XmlNode::Element(element)
3675 if POST_LATIN_ELEMENTS.contains(&element.local_name())
3676 )
3677 })
3678 .unwrap_or(base.children.len());
3679 base.children.insert(
3680 position,
3681 XmlNode::Element(
3682 XmlElement::new(prefixes.drawing("latin"))
3683 .with_attribute("typeface", family.clone()),
3684 ),
3685 );
3686 }
3687}
3688
3689fn apply_paragraph_properties(
3690 paragraph: &mut XmlElement,
3691 write: &ParagraphWrite,
3692 prefixes: &Prefixes,
3693) {
3694 let needs_properties = write.alignment.is_some() || write.level > 0 || write.bullet.is_some();
3695 if paragraph.child_mut("pPr").is_none() {
3696 if !needs_properties {
3697 return;
3698 }
3699 paragraph.children.insert(
3700 0,
3701 XmlNode::Element(XmlElement::new(prefixes.drawing("pPr"))),
3702 );
3703 }
3704 let properties = paragraph.child_mut("pPr").expect("ensured above");
3705 match &write.alignment {
3706 Some(alignment) => properties.set_attribute("algn", alignment.clone()),
3707 None => {
3708 properties.attributes.remove("algn");
3709 }
3710 }
3711 if write.level > 0 {
3712 properties.set_attribute("lvl", write.level.to_string());
3713 } else {
3714 properties.attributes.remove("lvl");
3715 }
3716 properties.children.retain(|child| {
3717 !matches!(
3718 child,
3719 XmlNode::Element(element)
3720 if matches!(element.local_name(), "buNone" | "buChar" | "buAutoNum")
3721 )
3722 });
3723 let bullet = match &write.bullet {
3724 Some(Bullet::Character { value }) => {
3725 Some(XmlElement::new(prefixes.drawing("buChar")).with_attribute("char", value.clone()))
3726 }
3727 Some(Bullet::AutoNumber {
3728 scheme,
3729 start_at,
3730 restart,
3731 }) => {
3732 let mut element = XmlElement::new(prefixes.drawing("buAutoNum"))
3733 .with_attribute("type", scheme.clone());
3734 if *restart || *start_at != 1 {
3735 element.set_attribute("startAt", start_at.to_string());
3736 }
3737 Some(element)
3738 }
3739 Some(Bullet::None) => Some(XmlElement::new(prefixes.drawing("buNone"))),
3740 None => None,
3741 };
3742 if let Some(bullet) = bullet {
3743 let position = properties
3744 .children
3745 .iter()
3746 .position(|child| {
3747 matches!(
3748 child,
3749 XmlNode::Element(element)
3750 if matches!(element.local_name(), "tabLst" | "defRPr" | "extLst")
3751 )
3752 })
3753 .unwrap_or(properties.children.len());
3754 properties
3755 .children
3756 .insert(position, XmlNode::Element(bullet));
3757 }
3758}
3759
3760fn run_properties_element(properties: &RunProperties, prefixes: &Prefixes) -> Option<XmlElement> {
3761 let mut element = XmlElement::new(prefixes.drawing("rPr"));
3762 let mut present = false;
3763 if let Some(language) = &properties.language {
3764 element.set_attribute("lang", language.clone());
3765 present = true;
3766 }
3767 if let Some(size) = properties.font_size_pt {
3768 element.set_attribute("sz", format_fixed(size * 100.0));
3769 present = true;
3770 }
3771 if let Some(spacing) = properties.spacing_pt {
3772 element.set_attribute("spc", format_fixed(spacing * 100.0));
3773 present = true;
3774 }
3775 if let Some(kern) = properties.kern_pt {
3776 element.set_attribute("kern", format_fixed(kern * 100.0));
3777 present = true;
3778 }
3779 if let Some(baseline) = properties.baseline_pct {
3780 element.set_attribute("baseline", format_fixed(baseline * 1000.0));
3781 present = true;
3782 }
3783 if let Some(bold) = properties.bold {
3784 element.set_attribute("b", if bold { "1" } else { "0" });
3785 present = true;
3786 }
3787 if let Some(italic) = properties.italic {
3788 element.set_attribute("i", if italic { "1" } else { "0" });
3789 present = true;
3790 }
3791 if let Some(underline) = &properties.underline {
3792 element.set_attribute("u", underline.clone());
3793 present = true;
3794 }
3795 if let Some(caps) = properties.caps {
3796 element.set_attribute("cap", caps.as_attribute());
3797 present = true;
3798 }
3799 if let Some(color) = properties
3800 .color
3801 .as_ref()
3802 .and_then(|color| color_element(color, prefixes))
3803 {
3804 element =
3805 element.with_child(XmlElement::new(prefixes.drawing("solidFill")).with_child(color));
3806 present = true;
3807 }
3808 if let Some(family) = &properties.font_family {
3809 element = element.with_child(
3810 XmlElement::new(prefixes.drawing("latin")).with_attribute("typeface", family.clone()),
3811 );
3812 present = true;
3813 }
3814 present.then_some(element)
3815}
3816
3817fn shape_element(
3820 add: &ShapeAdd,
3821 next_shape_id: &mut Option<u32>,
3822 prefixes: &Prefixes,
3823 part: &str,
3824) -> Result<XmlElement, PptxError> {
3825 if matches!(add.geometry.as_str(), "custom" | "group") {
3826 return Err(write_error(
3827 part,
3828 format!("unsupported geometry {:?} for a new shape", add.geometry),
3829 ));
3830 }
3831 let shape_id = alloc_shape_id(next_shape_id, part)?;
3832 let mut non_visual_properties = XmlElement::new(prefixes.presentation("cNvSpPr"));
3833 if add.text_box {
3834 non_visual_properties.set_attribute("txBox", "1");
3835 }
3836 let non_visual = XmlElement::new(prefixes.presentation("nvSpPr"))
3837 .with_child(
3838 XmlElement::new(prefixes.presentation("cNvPr"))
3839 .with_attribute("id", shape_id.to_string())
3840 .with_attribute("name", add.name.clone()),
3841 )
3842 .with_child(non_visual_properties)
3843 .with_child(XmlElement::new(prefixes.presentation("nvPr")));
3844 let transform = XmlElement::new(prefixes.drawing("xfrm"))
3845 .with_child(
3846 XmlElement::new(prefixes.drawing("off"))
3847 .with_attribute("x", add.x.to_string())
3848 .with_attribute("y", add.y.to_string()),
3849 )
3850 .with_child(
3851 XmlElement::new(prefixes.drawing("ext"))
3852 .with_attribute("cx", add.width.to_string())
3853 .with_attribute("cy", add.height.to_string()),
3854 );
3855 let mut adjust_list = XmlElement::new(prefixes.drawing("avLst"));
3856 for (name, value) in &add.adjust_values {
3857 adjust_list = adjust_list.with_child(
3858 XmlElement::new(prefixes.drawing("gd"))
3859 .with_attribute("name", name.clone())
3860 .with_attribute("fmla", format!("val {}", format_fixed(value * 100_000.0))),
3861 );
3862 }
3863 let geometry = XmlElement::new(prefixes.drawing("prstGeom"))
3864 .with_attribute("prst", add.geometry.clone())
3865 .with_child(adjust_list);
3866 let mut properties = XmlElement::new(prefixes.presentation("spPr"))
3867 .with_child(transform)
3868 .with_child(geometry);
3869 if let Some(fill) = &add.fill {
3870 properties = properties.with_child(fill_element(fill, prefixes, part)?);
3871 }
3872 if let Some(outline) = &add.outline {
3873 properties = properties.with_child(outline_element(outline, prefixes));
3874 }
3875 let mut shape = XmlElement::new(prefixes.presentation("sp"))
3876 .with_child(non_visual)
3877 .with_child(properties);
3878 if let Some(paragraphs) = &add.paragraphs {
3879 let mut body = XmlElement::new(prefixes.presentation("txBody"))
3880 .with_child(XmlElement::new(prefixes.drawing("bodyPr")))
3881 .with_child(XmlElement::new(prefixes.drawing("lstStyle")));
3882 for paragraph in paragraphs {
3883 body = body.with_child(build_paragraph(paragraph, None, None, prefixes));
3884 }
3885 if paragraphs.is_empty() {
3886 body = body.with_child(XmlElement::new(prefixes.drawing("p")));
3887 }
3888 shape = shape.with_child(body);
3889 }
3890 Ok(shape)
3891}
3892
3893fn add_shape_element(
3895 add: &ShapeAdd,
3896 next_shape_id: &mut Option<u32>,
3897 prefixes: &Prefixes,
3898 part: &str,
3899 sink: &mut PartSink<'_>,
3900 budget: &mut ParseBudget<'_>,
3901) -> Result<XmlElement, PptxError> {
3902 match &add.picture {
3903 Some(picture) => {
3904 picture_shape_element(add, picture, next_shape_id, prefixes, part, sink, budget)
3905 }
3906 None => shape_element(add, next_shape_id, prefixes, part),
3907 }
3908}
3909
3910fn image_content_type_extension(content_type: &str) -> Option<&'static str> {
3911 match content_type {
3912 "image/png" => Some("png"),
3913 "image/jpeg" | "image/jpg" => Some("jpeg"),
3914 "image/gif" => Some("gif"),
3915 "image/bmp" => Some("bmp"),
3916 "image/tiff" => Some("tiff"),
3917 "image/webp" => Some("webp"),
3918 "image/svg+xml" => Some("svg"),
3919 _ => None,
3920 }
3921}
3922
3923fn image_extension(content_type: &str) -> Result<&'static str, PptxError> {
3924 image_content_type_extension(content_type)
3925 .ok_or_else(|| write_error("media", format!("unsupported image type {content_type:?}")))
3926}
3927
3928pub fn is_supported_image_content_type(content_type: &str) -> bool {
3930 image_content_type_extension(content_type).is_some()
3931}
3932
3933fn next_media_part_path(
3934 package: &PptxPackage,
3935 new_parts: &[(String, Vec<u8>)],
3936 extension: &str,
3937) -> String {
3938 let existing: HashSet<&str> = package
3939 .parts
3940 .iter()
3941 .map(|part| part.path.as_str())
3942 .chain(new_parts.iter().map(|(path, _)| path.as_str()))
3943 .collect();
3944 let mut number = 1_u64;
3945 while existing.contains(format!("ppt/media/image{number}.{extension}").as_str()) {
3946 number += 1;
3947 }
3948 format!("ppt/media/image{number}.{extension}")
3949}
3950
3951fn ensure_media_content_type(
3953 sink: &mut PartSink<'_>,
3954 extension: &str,
3955 part_path: &str,
3956 content_type: &str,
3957 budget: &mut ParseBudget<'_>,
3958) -> Result<(), PptxError> {
3959 let path = "[Content_Types].xml";
3960 let bytes = sink
3961 .current(path)
3962 .ok_or_else(|| PptxError::MissingPart(path.to_owned()))?;
3963 let mut root = parse_xml(&bytes, path, budget)?;
3964 let default = root.children.iter().find_map(|child| match child {
3965 XmlNode::Element(element)
3966 if element.local_name() == "Default"
3967 && element
3968 .attribute("Extension")
3969 .is_some_and(|value| value.eq_ignore_ascii_case(extension)) =>
3970 {
3971 Some(element)
3972 }
3973 _ => None,
3974 });
3975 if let Some(default) = default {
3976 if default.attribute("ContentType") != Some(content_type) {
3977 set_content_type_override(sink, part_path, content_type, budget)?;
3978 }
3979 } else {
3980 let prefix = root.name.rsplit_once(':').map_or("", |(prefix, _)| prefix);
3981 root.children.push(XmlNode::Element(
3982 XmlElement::new(qualified(prefix, "Default"))
3983 .with_attribute("Extension", extension)
3984 .with_attribute("ContentType", content_type),
3985 ));
3986 sink.store(path, serialize_xml(&root));
3987 }
3988 Ok(())
3989}
3990
3991fn add_relationship(
3994 sink: &mut PartSink<'_>,
3995 relationships_path: &str,
3996 relationship_type: &str,
3997 target: &str,
3998 budget: &mut ParseBudget<'_>,
3999) -> Result<String, PptxError> {
4000 let mut root = match sink.current(relationships_path) {
4001 Some(bytes) => parse_xml(&bytes, relationships_path, budget)?,
4002 None => XmlElement::new("Relationships").with_attribute("xmlns", PACKAGE_RELATIONSHIPS_NS),
4003 };
4004 let used: HashSet<&str> = root
4005 .children_named("Relationship")
4006 .filter_map(|element| element.attribute("Id"))
4007 .collect();
4008 let mut number = 1_u64;
4009 while used.contains(format!("rId{number}").as_str()) {
4010 number += 1;
4011 }
4012 let id = format!("rId{number}");
4013 let prefix = root.name.rsplit_once(':').map_or("", |(prefix, _)| prefix);
4014 root.children.push(XmlNode::Element(
4015 XmlElement::new(qualified(prefix, "Relationship"))
4016 .with_attribute("Id", id.clone())
4017 .with_attribute("Type", relationship_type)
4018 .with_attribute("Target", target),
4019 ));
4020 sink.store(relationships_path, serialize_xml(&root));
4021 Ok(id)
4022}
4023
4024fn picture_shape_element(
4025 add: &ShapeAdd,
4026 picture: &PictureAdd,
4027 next_shape_id: &mut Option<u32>,
4028 prefixes: &Prefixes,
4029 slide_part_path: &str,
4030 sink: &mut PartSink<'_>,
4031 budget: &mut ParseBudget<'_>,
4032) -> Result<XmlElement, PptxError> {
4033 let extension = image_extension(&picture.content_type)?;
4034 let media_part_path = next_media_part_path(sink.package, sink.new_parts.as_slice(), extension);
4035 sink.store(&media_part_path, picture.media_bytes.clone());
4036 ensure_media_content_type(
4037 sink,
4038 extension,
4039 &media_part_path,
4040 &picture.content_type,
4041 budget,
4042 )?;
4043 let relationships_path = slide_relationships_path(slide_part_path);
4044 let target = relative_target(slide_part_path, &media_part_path);
4045 let relationship_id = add_relationship(
4046 sink,
4047 &relationships_path,
4048 IMAGE_RELATIONSHIP_TYPE,
4049 &target,
4050 budget,
4051 )?;
4052
4053 let shape_id = alloc_shape_id(next_shape_id, slide_part_path)?;
4054 let non_visual = XmlElement::new(prefixes.presentation("nvPicPr"))
4055 .with_child(
4056 XmlElement::new(prefixes.presentation("cNvPr"))
4057 .with_attribute("id", shape_id.to_string())
4058 .with_attribute("name", add.name.clone()),
4059 )
4060 .with_child(
4061 XmlElement::new(prefixes.presentation("cNvPicPr")).with_child(
4062 XmlElement::new(prefixes.drawing("picLocks")).with_attribute("noChangeAspect", "1"),
4063 ),
4064 )
4065 .with_child(XmlElement::new(prefixes.presentation("nvPr")));
4066 let blip_fill = XmlElement::new(prefixes.presentation("blipFill"))
4067 .with_child(
4068 XmlElement::new(prefixes.drawing("blip"))
4069 .with_attribute(prefixes.relationship("embed"), relationship_id),
4070 )
4071 .with_child(
4072 XmlElement::new(prefixes.drawing("stretch"))
4073 .with_child(XmlElement::new(prefixes.drawing("fillRect"))),
4074 );
4075 let transform = XmlElement::new(prefixes.drawing("xfrm"))
4076 .with_child(
4077 XmlElement::new(prefixes.drawing("off"))
4078 .with_attribute("x", add.x.to_string())
4079 .with_attribute("y", add.y.to_string()),
4080 )
4081 .with_child(
4082 XmlElement::new(prefixes.drawing("ext"))
4083 .with_attribute("cx", add.width.to_string())
4084 .with_attribute("cy", add.height.to_string()),
4085 );
4086 let geometry = XmlElement::new(prefixes.drawing("prstGeom"))
4087 .with_attribute("prst", "rect")
4088 .with_child(XmlElement::new(prefixes.drawing("avLst")));
4089 let properties = XmlElement::new(prefixes.presentation("spPr"))
4090 .with_child(transform)
4091 .with_child(geometry);
4092 Ok(XmlElement::new(prefixes.presentation("pic"))
4093 .with_child(non_visual)
4094 .with_child(blip_fill)
4095 .with_child(properties))
4096}
4097
4098fn slide_xml(
4099 name: Option<&str>,
4100 shapes: &[ShapeAdd],
4101 background: Option<&ShapeFill>,
4102 part: &str,
4103 sink: &mut PartSink<'_>,
4104 budget: &mut ParseBudget<'_>,
4105) -> Result<Vec<u8>, PptxError> {
4106 let mut root = XmlElement::new("p:sld")
4107 .with_attribute("xmlns:a", DRAWINGML_NS)
4108 .with_attribute("xmlns:r", OFFICE_RELATIONSHIPS_NS)
4109 .with_attribute("xmlns:p", PRESENTATIONML_NS);
4110 let prefixes = Prefixes {
4111 drawing: "a".to_owned(),
4112 presentation: "p".to_owned(),
4113 relationship: "r".to_owned(),
4114 };
4115 let group_transform = XmlElement::new(prefixes.drawing("xfrm"))
4116 .with_child(
4117 XmlElement::new(prefixes.drawing("off"))
4118 .with_attribute("x", "0")
4119 .with_attribute("y", "0"),
4120 )
4121 .with_child(
4122 XmlElement::new(prefixes.drawing("ext"))
4123 .with_attribute("cx", "0")
4124 .with_attribute("cy", "0"),
4125 )
4126 .with_child(
4127 XmlElement::new(prefixes.drawing("chOff"))
4128 .with_attribute("x", "0")
4129 .with_attribute("y", "0"),
4130 )
4131 .with_child(
4132 XmlElement::new(prefixes.drawing("chExt"))
4133 .with_attribute("cx", "0")
4134 .with_attribute("cy", "0"),
4135 );
4136 let mut tree = XmlElement::new(prefixes.presentation("spTree"))
4137 .with_child(
4138 XmlElement::new(prefixes.presentation("nvGrpSpPr"))
4139 .with_child(
4140 XmlElement::new(prefixes.presentation("cNvPr"))
4141 .with_attribute("id", "1")
4142 .with_attribute("name", ""),
4143 )
4144 .with_child(XmlElement::new(prefixes.presentation("cNvGrpSpPr")))
4145 .with_child(XmlElement::new(prefixes.presentation("nvPr"))),
4146 )
4147 .with_child(XmlElement::new(prefixes.presentation("grpSpPr")).with_child(group_transform));
4148 let mut next_shape_id = Some(2);
4149 for shape in shapes {
4150 tree = tree.with_child(add_shape_element(
4151 shape,
4152 &mut next_shape_id,
4153 &prefixes,
4154 part,
4155 sink,
4156 budget,
4157 )?);
4158 }
4159 let mut common = XmlElement::new(prefixes.presentation("cSld"));
4160 if let Some(name) = name {
4161 common.set_attribute("name", name);
4162 }
4163 if let Some(fill) = background {
4164 common = common.with_child(background_element(fill, &prefixes, part)?);
4165 }
4166 root = root.with_child(common.with_child(tree)).with_child(
4167 XmlElement::new(prefixes.presentation("clrMapOvr"))
4168 .with_child(XmlElement::new(prefixes.drawing("masterClrMapping"))),
4169 );
4170 Ok(serialize_xml(&root))
4171}
4172
4173fn slide_relationships_xml(part_path: &str, layout_part_path: &str) -> Vec<u8> {
4174 let root = XmlElement::new("Relationships")
4175 .with_attribute(
4176 "xmlns",
4177 "http://schemas.openxmlformats.org/package/2006/relationships",
4178 )
4179 .with_child(
4180 XmlElement::new("Relationship")
4181 .with_attribute("Id", "rId1")
4182 .with_attribute("Type", SLIDE_LAYOUT_RELATIONSHIP_TYPE)
4183 .with_attribute("Target", relative_target(part_path, layout_part_path)),
4184 );
4185 serialize_xml(&root)
4186}
4187
4188#[cfg(test)]
4189mod tests {
4190 use super::*;
4191 use crate::ParseLimits;
4192 use crate::drawing::parse_run_properties;
4193 use crate::model::ShapeElements;
4194 use crate::xml::{ParseBudget, parse_xml};
4195
4196 #[test]
4197 fn a_labelled_ellipse_is_not_written_as_a_text_box() {
4198 let add = ShapeAdd {
4199 name: "Labelled ellipse".to_owned(),
4200 geometry: "ellipse".to_owned(),
4201 text_box: false,
4202 x: 0,
4203 y: 0,
4204 width: 1_000_000,
4205 height: 500_000,
4206 adjust_values: BTreeMap::new(),
4207 fill: None,
4208 outline: None,
4209 paragraphs: Some(vec![ParagraphWrite {
4210 source_paragraphs: Vec::new(),
4211 source_index: None,
4212 preserved_xml: None,
4213 rebuild: true,
4214 properties_changed: true,
4215 alignment: None,
4216 level: 0,
4217 bullet: None,
4218 runs: vec![RunWrite {
4219 text: "Ellipse label".to_owned(),
4220 properties: RunProperties::default(),
4221 }],
4222 }]),
4223 picture: None,
4224 };
4225 let mut root = XmlElement::new("p:sld");
4226 let prefixes = Prefixes::from_root(&mut root);
4227 let shape = shape_element(&add, &mut Some(2), &prefixes, "ppt/slides/slide1.xml").unwrap();
4228 assert!(shape.child("txBody").unwrap().child("p").is_some());
4229 assert_eq!(
4230 shape
4231 .child("spPr")
4232 .unwrap()
4233 .child("prstGeom")
4234 .unwrap()
4235 .attribute("prst"),
4236 Some("ellipse")
4237 );
4238 assert_eq!(
4239 shape
4240 .child("nvSpPr")
4241 .unwrap()
4242 .child("cNvSpPr")
4243 .unwrap()
4244 .attribute("txBox"),
4245 None
4246 );
4247 }
4248
4249 #[test]
4250 fn changing_outline_width_preserves_unchanged_color_source() {
4251 let limits = ParseLimits::default();
4252 let mut properties = parse_xml(
4253 br#"<p:spPr><a:ln w="9525"><a:solidFill><a:schemeClr val="accent1"><a:tint val="50%"/><a:lumMod val="60000"/><a:extLst/></a:schemeClr></a:solidFill></a:ln></p:spPr>"#,
4254 "shape.xml",
4255 &mut ParseBudget::new(&limits),
4256 )
4257 .unwrap();
4258 let original_color = properties
4259 .child("ln")
4260 .unwrap()
4261 .child("solidFill")
4262 .unwrap()
4263 .clone();
4264 let mut outline =
4265 crate::drawing::parse_outline_element(properties.child("ln").unwrap()).unwrap();
4266 outline.width = Some(19050.0);
4267 let prefixes = Prefixes::from_root(&mut properties);
4268 set_outline(&mut properties, &outline, &prefixes);
4269 let line = properties.child("ln").unwrap();
4270 assert_eq!(line.attribute("w"), Some("19050"));
4271 assert_eq!(line.child("solidFill"), Some(&original_color));
4272 }
4273
4274 #[test]
4275 fn rebuilt_color_preserves_transform_order_and_exact_percentages() {
4276 let limits = ParseLimits::default();
4277 let source = parse_xml(
4278 br#"<a:solidFill><a:srgbClr val="4472C4"><a:tint val="50000"/><a:lumMod val="75000"/><a:shade val="12345"/><a:shade val="60000"/><a:hueOff val="-5400000"/><a:comp/><a:alpha val="50000"/></a:srgbClr></a:solidFill>"#,
4279 "color.xml",
4280 &mut ParseBudget::new(&limits),
4281 )
4282 .unwrap();
4283 let color = crate::drawing::parse_color_container(&source).unwrap();
4284 let mut properties = XmlElement::new("p:spPr");
4285 let prefixes = Prefixes::from_root(&mut properties);
4286 let written = color_element(&color, &prefixes).unwrap();
4287 assert_eq!(
4288 written
4289 .child_elements()
4290 .map(|child| (child.local_name(), child.attribute("val")))
4291 .collect::<Vec<_>>(),
4292 vec![
4293 ("tint", Some("50000")),
4294 ("lumMod", Some("75000")),
4295 ("shade", Some("12345")),
4296 ("shade", Some("60000")),
4297 ("hueOff", Some("-5400000")),
4298 ("comp", None),
4299 ("alpha", Some("50000")),
4300 ]
4301 );
4302 assert_eq!(
4303 crate::drawing::parse_color_container(
4304 &XmlElement::new("a:solidFill").with_child(written)
4305 ),
4306 Some(color)
4307 );
4308 }
4309
4310 #[test]
4311 fn mixed_color_alpha_survives_serialization() {
4312 use ooxml_drawingml::{ColorTransform, resolve_color_value_to_rgba_hex};
4313
4314 for step in [None, Some(ColorTransform::AlphaMod(0.5))] {
4315 let color = ColorValue {
4316 rgb: Some("112233".to_owned()),
4317 theme_tint: Some("00".to_owned()),
4318 alpha: Some(0.25),
4319 transforms: std::iter::once(ColorTransform::Tint(1.0))
4320 .chain(step)
4321 .collect(),
4322 ..Default::default()
4323 };
4324 let mut properties = XmlElement::new("p:spPr");
4325 let prefixes = Prefixes::from_root(&mut properties);
4326 let written = color_element(&color, &prefixes).unwrap();
4327 assert_eq!(written.child("alpha").is_some(), step.is_none());
4328 assert_eq!(written.child_elements().count(), 2);
4329 let reopened = crate::drawing::parse_color_container(
4330 &XmlElement::new("a:solidFill").with_child(written),
4331 )
4332 .unwrap();
4333 let expected = if step.is_none() {
4334 "#11223340"
4335 } else {
4336 "#11223380"
4337 };
4338 assert_eq!(
4339 resolve_color_value_to_rgba_hex(Some(&reopened), None).as_deref(),
4340 Some(expected)
4341 );
4342 }
4343 }
4344
4345 #[test]
4346 fn a_new_gradient_outline_writes_its_stops_and_angle() {
4347 let package =
4348 crate::parse_pptx(include_bytes!("../tests/fixtures/gradient-outline.pptx")).unwrap();
4349 let crate::ShapeNode::Shape(shape) = &package.slides[0].shapes[0] else {
4350 panic!("shape")
4351 };
4352 let outline = shape.outline.as_ref().unwrap();
4353 let mut properties = XmlElement::new("p:spPr");
4354 let prefixes = Prefixes::from_root(&mut properties);
4355 set_outline(&mut properties, outline, &prefixes);
4356 let line = properties.child("ln").unwrap();
4357 assert_eq!(line.attribute("w"), Some("76200"));
4358 let gradient = line.child("gradFill").unwrap();
4359 assert_eq!(gradient.child("gsLst").unwrap().child_elements().count(), 3);
4360 assert_eq!(gradient.child("lin").unwrap().attribute("ang"), Some("0"));
4361 assert!(line.child("solidFill").is_none());
4362 assert_eq!(
4363 crate::drawing::parse_outline_element(line).as_ref(),
4364 Some(outline)
4365 );
4366 }
4367
4368 #[test]
4369 fn source_ordinals_cover_the_shapes_an_alternate_content_contributes() {
4370 let limits = ParseLimits::default();
4371 let mut budget = ParseBudget::new(&limits);
4372 let part = "ppt/slides/slide1.xml";
4373 let mut root = parse_xml(
4374 br#"<p:sld><p:cSld><p:spTree><p:sp><p:nvSpPr><p:cNvPr id="2" name="one"/></p:nvSpPr></p:sp><mc:AlternateContent><mc:Choice xmlns:p14="http://schemas.microsoft.com/office/powerpoint/2010/main" Requires="p14"><p:sp><p:nvSpPr><p:cNvPr id="3" name="choice"/></p:nvSpPr></p:sp></mc:Choice><mc:Fallback><p:sp><p:nvSpPr><p:cNvPr id="4" name="two"/></p:nvSpPr></p:sp><p:pic><p:nvPicPr><p:cNvPr id="5" name="three"/></p:nvPicPr></p:pic></mc:Fallback></mc:AlternateContent><p:sp><p:nvSpPr><p:cNvPr id="6" name="four"/></p:nvSpPr></p:sp></p:spTree></p:cSld></p:sld>"#,
4375 part,
4376 &mut budget,
4377 )
4378 .unwrap();
4379 let parsed = crate::drawing::common_slide_data(
4380 &root,
4381 &[],
4382 part,
4383 &mut budget,
4384 ShapeElements::WithConnectors,
4385 )
4386 .unwrap();
4387 let tree = root
4388 .child_mut("cSld")
4389 .and_then(|common| common.child_mut("spTree"))
4390 .unwrap();
4391 let slots: Vec<Option<XmlNode>> = std::mem::take(&mut tree.children)
4392 .into_iter()
4393 .map(Some)
4394 .collect();
4395
4396 let found = shape_slots(&slots, ShapeElements::WithConnectors);
4397
4398 assert_eq!(found.len(), parsed.shapes.len());
4399 let names: Vec<String> = found
4400 .iter()
4401 .map(|slot| {
4402 let mut node = slots[slot.position].clone().unwrap();
4403 let element = element_at_path(&mut node, &slot.path).unwrap();
4404 element.descendants_named("cNvPr")[0]
4405 .attribute("name")
4406 .unwrap()
4407 .to_owned()
4408 })
4409 .collect();
4410 assert_eq!(names, ["one", "two", "three", "four"]);
4411 }
4412
4413 #[test]
4414 fn deleting_one_of_the_shapes_a_branch_holds_leaves_the_others_in_place() {
4415 let limits = ParseLimits::default();
4416 let mut budget = ParseBudget::new(&limits);
4417 let part = "ppt/slides/slide1.xml";
4418 let mut root = parse_xml(
4419 br#"<p:sld><p:cSld><p:spTree><p:sp><p:nvSpPr><p:cNvPr id="2" name="one"/></p:nvSpPr><p:spPr/></p:sp><mc:AlternateContent><mc:Fallback><p:sp><p:nvSpPr><p:cNvPr id="3" name="two"/></p:nvSpPr><p:spPr/></p:sp><p:sp><p:nvSpPr><p:cNvPr id="4" name="three"/></p:nvSpPr><p:spPr/></p:sp></mc:Fallback></mc:AlternateContent></p:spTree></p:cSld></p:sld>"#,
4420 part,
4421 &mut budget,
4422 )
4423 .unwrap();
4424
4425 let package = PptxPackage::default();
4426 let mut replacements = HashMap::new();
4427 let mut new_parts = Vec::new();
4428 let mut sink = PartSink {
4429 package: &package,
4430 replacements: &mut replacements,
4431 new_parts: &mut new_parts,
4432 };
4433 patch_slide(
4434 &mut root,
4435 &[
4436 ShapeWrite::Keep { source_index: 0 },
4437 ShapeWrite::Patch {
4438 source_index: 2,
4439 patch: Box::new(ShapePatch {
4440 fill: Some(ShapeFill {
4441 color: Some(ColorValue::from_attribute("DC2626")),
4442 ..ShapeFill::named("solid")
4443 }),
4444 ..ShapePatch::default()
4445 }),
4446 },
4447 ],
4448 None,
4449 None,
4450 part,
4451 ShapeElements::WithConnectors,
4452 &mut sink,
4453 &mut budget,
4454 )
4455 .unwrap();
4456
4457 let xml = String::from_utf8(serialize_xml(&root)).unwrap();
4458 assert!(xml.contains(r#"name="one""#));
4459 assert!(!xml.contains(r#"name="two""#), "{xml}");
4460 let survivor = xml.split(r#"name="three""#).nth(1).unwrap();
4461 assert!(survivor.contains(r#"<a:srgbClr val="DC2626"/>"#), "{xml}");
4462 }
4463
4464 fn patch_background_fixture(xml: &[u8], background: Option<&BackgroundWrite>) -> String {
4465 let limits = ParseLimits::default();
4466 let mut budget = ParseBudget::new(&limits);
4467 let part = "ppt/slides/slide1.xml";
4468 let mut root = parse_xml(xml, part, &mut budget).unwrap();
4469 let package = PptxPackage::default();
4470 let mut replacements = HashMap::new();
4471 let mut new_parts = Vec::new();
4472 let mut sink = PartSink {
4473 package: &package,
4474 replacements: &mut replacements,
4475 new_parts: &mut new_parts,
4476 };
4477 patch_slide(
4478 &mut root,
4479 &[ShapeWrite::Keep { source_index: 0 }],
4480 background,
4481 None,
4482 part,
4483 ShapeElements::WithConnectors,
4484 &mut sink,
4485 &mut budget,
4486 )
4487 .unwrap();
4488 String::from_utf8(serialize_xml(&root)).unwrap()
4489 }
4490
4491 #[test]
4492 fn a_new_background_precedes_the_shape_tree() {
4493 let xml = patch_background_fixture(
4494 br#"<p:sld><p:cSld><p:spTree><p:sp><p:nvSpPr><p:cNvPr id="2" name="one"/></p:nvSpPr></p:sp></p:spTree></p:cSld></p:sld>"#,
4495 Some(&BackgroundWrite::Set(Box::new(ShapeFill {
4496 color: Some(ColorValue::from_attribute("0F1720")),
4497 ..ShapeFill::named("solid")
4498 }))),
4499 );
4500 let common = xml.find("<p:cSld>").unwrap();
4501 let bg = xml.find("<p:bg>").unwrap();
4502 let effects = xml.find("<a:effectLst/>").unwrap();
4503 let tree = xml.find("<p:spTree>").unwrap();
4504 assert!(common < bg && bg < effects && effects < tree, "{xml}");
4505 assert!(xml.contains(r#"<a:srgbClr val="0F1720"/>"#), "{xml}");
4506 }
4507
4508 #[test]
4509 fn clearing_a_background_removes_only_the_declaration() {
4510 let xml = patch_background_fixture(
4511 br#"<p:sld><p:cSld><p:bg><p:bgPr><a:solidFill><a:srgbClr val="0F1720"/></a:solidFill><a:effectLst/></p:bgPr></p:bg><p:spTree><p:sp><p:nvSpPr><p:cNvPr id="2" name="one"/></p:nvSpPr></p:sp></p:spTree></p:cSld></p:sld>"#,
4512 Some(&BackgroundWrite::Clear),
4513 );
4514 assert!(!xml.contains("<p:bg>"), "{xml}");
4515 assert!(xml.contains(r#"name="one""#), "{xml}");
4516 }
4517
4518 #[test]
4519 fn an_untouched_background_survives_a_shape_patch() {
4520 let limits = ParseLimits::default();
4521 let mut budget = ParseBudget::new(&limits);
4522 let part = "ppt/slides/slide1.xml";
4523 let mut root = parse_xml(
4524 br#"<p:sld><p:cSld><p:bg><p:bgPr><a:solidFill><a:srgbClr val="0F1720"/></a:solidFill><a:effectLst/></p:bgPr></p:bg><p:spTree><p:sp><p:nvSpPr><p:cNvPr id="2" name="one"/></p:nvSpPr><p:spPr/></p:sp></p:spTree></p:cSld></p:sld>"#,
4525 part,
4526 &mut budget,
4527 )
4528 .unwrap();
4529 let package = PptxPackage::default();
4530 let mut replacements = HashMap::new();
4531 let mut new_parts = Vec::new();
4532 let mut sink = PartSink {
4533 package: &package,
4534 replacements: &mut replacements,
4535 new_parts: &mut new_parts,
4536 };
4537 patch_slide(
4538 &mut root,
4539 &[ShapeWrite::Patch {
4540 source_index: 0,
4541 patch: Box::new(ShapePatch {
4542 fill: Some(ShapeFill {
4543 color: Some(ColorValue::from_attribute("DC2626")),
4544 ..ShapeFill::named("solid")
4545 }),
4546 ..ShapePatch::default()
4547 }),
4548 }],
4549 None,
4550 None,
4551 part,
4552 ShapeElements::WithConnectors,
4553 &mut sink,
4554 &mut budget,
4555 )
4556 .unwrap();
4557
4558 let xml = String::from_utf8(serialize_xml(&root)).unwrap();
4559 assert!(xml.contains(r#"<a:srgbClr val="0F1720"/>"#), "{xml}");
4560 assert!(xml.contains(r#"<a:srgbClr val="DC2626"/>"#), "{xml}");
4561 }
4562
4563 #[test]
4564 fn a_patched_use_bg_fill_shape_keeps_the_attribute() {
4565 let limits = ParseLimits::default();
4566 let mut budget = ParseBudget::new(&limits);
4567 let part = "ppt/slides/slide1.xml";
4568 let mut root = parse_xml(
4569 br#"<p:sld><p:cSld><p:spTree><p:sp useBgFill="1"><p:nvSpPr><p:cNvPr id="2" name="one"/></p:nvSpPr><p:spPr/></p:sp></p:spTree></p:cSld></p:sld>"#,
4570 part,
4571 &mut budget,
4572 )
4573 .unwrap();
4574 let package = PptxPackage::default();
4575 let mut replacements = HashMap::new();
4576 let mut new_parts = Vec::new();
4577 let mut sink = PartSink {
4578 package: &package,
4579 replacements: &mut replacements,
4580 new_parts: &mut new_parts,
4581 };
4582 patch_slide(
4583 &mut root,
4584 &[ShapeWrite::Patch {
4585 source_index: 0,
4586 patch: Box::new(ShapePatch {
4587 fill: Some(ShapeFill {
4588 color: Some(ColorValue::from_attribute("DC2626")),
4589 ..ShapeFill::named("solid")
4590 }),
4591 ..ShapePatch::default()
4592 }),
4593 }],
4594 None,
4595 None,
4596 part,
4597 ShapeElements::WithConnectors,
4598 &mut sink,
4599 &mut budget,
4600 )
4601 .unwrap();
4602
4603 let xml = String::from_utf8(serialize_xml(&root)).unwrap();
4604 assert!(xml.contains(r#"useBgFill="1""#), "{xml}");
4605 assert!(xml.contains(r#"<a:srgbClr val="DC2626"/>"#), "{xml}");
4606 }
4607
4608 #[test]
4609 fn an_added_slide_background_precedes_the_shape_tree() {
4610 let package = PptxPackage::default();
4611 let mut replacements = HashMap::new();
4612 let mut new_parts = Vec::new();
4613 let mut sink = PartSink {
4614 package: &package,
4615 replacements: &mut replacements,
4616 new_parts: &mut new_parts,
4617 };
4618 let limits = ParseLimits::default();
4619 let mut budget = ParseBudget::new(&limits);
4620 let fill = ShapeFill {
4621 color: Some(ColorValue::from_attribute("F8FAFC")),
4622 ..ShapeFill::named("solid")
4623 };
4624 let xml = slide_xml(
4625 None,
4626 &[],
4627 Some(&fill),
4628 "ppt/slides/slide9.xml",
4629 &mut sink,
4630 &mut budget,
4631 )
4632 .unwrap();
4633 let xml = String::from_utf8(xml).unwrap();
4634 let common = xml.find("<p:cSld>").unwrap();
4635 let bg = xml.find("<p:bg>").unwrap();
4636 let tree = xml.find("<p:spTree>").unwrap();
4637 assert!(common < bg && bg < tree, "{xml}");
4638 assert!(xml.contains(r#"<a:srgbClr val="F8FAFC"/>"#), "{xml}");
4639 }
4640
4641 #[test]
4642 fn a_branch_that_still_holds_unmodeled_markup_outlives_its_only_shape() {
4643 let limits = ParseLimits::default();
4644 let mut budget = ParseBudget::new(&limits);
4645 let part = "ppt/slides/slide1.xml";
4646 let mut root = parse_xml(
4647 br#"<p:sld><p:cSld><p:spTree><mc:AlternateContent><mc:Fallback><p:sp><p:nvSpPr><p:cNvPr id="2" name="one"/></p:nvSpPr></p:sp><p:cxnSp><p:nvCxnSpPr><p:cNvPr id="3" name="two"/></p:nvCxnSpPr></p:cxnSp></mc:Fallback></mc:AlternateContent></p:spTree></p:cSld></p:sld>"#,
4648 part,
4649 &mut budget,
4650 )
4651 .unwrap();
4652
4653 let package = PptxPackage::default();
4654 let mut replacements = HashMap::new();
4655 let mut new_parts = Vec::new();
4656 let mut sink = PartSink {
4657 package: &package,
4658 replacements: &mut replacements,
4659 new_parts: &mut new_parts,
4660 };
4661 patch_slide(
4662 &mut root,
4663 &[],
4664 None,
4665 None,
4666 part,
4667 ShapeElements::WithoutConnectors,
4668 &mut sink,
4669 &mut budget,
4670 )
4671 .unwrap();
4672
4673 let xml = String::from_utf8(serialize_xml(&root)).unwrap();
4674 assert!(!xml.contains(r#"name="one""#), "{xml}");
4675 assert!(xml.contains(r#"name="two""#), "{xml}");
4676 }
4677
4678 fn run_properties(xml: &[u8]) -> (XmlElement, Prefixes, RunProperties) {
4679 let limits = ParseLimits::default();
4680 let mut budget = ParseBudget::new(&limits);
4681 let mut root = parse_xml(xml, "ppt/slides/slide1.xml", &mut budget).unwrap();
4682 let prefixes = Prefixes::from_root(&mut root);
4683 let properties = parse_run_properties(Some(&root));
4684 (root, prefixes, properties)
4685 }
4686
4687 #[test]
4688 fn an_unedited_gradient_fill_survives_a_rewrite() {
4689 let (mut element, prefixes, properties) = run_properties(
4690 br#"<a:rPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" lang="en"><a:gradFill><a:gsLst><a:gs pos="0"><a:srgbClr val="FFFFFF"/></a:gs><a:gs pos="100000"><a:srgbClr val="FFFFFF"/></a:gs></a:gsLst></a:gradFill></a:rPr>"#,
4691 );
4692 assert!(
4693 properties.color.is_some(),
4694 "the gradient should flatten to a colour"
4695 );
4696 let gradient = element.child("gradFill").unwrap().clone();
4697 apply_run_properties(&mut element, &properties, None, &prefixes);
4698 assert_eq!(element.child("gradFill"), Some(&gradient));
4699 let serialized = String::from_utf8(serialize_xml(&element)).unwrap();
4700 assert!(
4701 serialized.contains("gradFill"),
4702 "the authored gradient must be left alone"
4703 );
4704 assert!(
4705 !serialized.contains("solidFill"),
4706 "it must not be rewritten as a solid fill"
4707 );
4708 }
4709
4710 #[test]
4711 fn a_lowercase_flattened_colour_keeps_the_gradient() {
4712 let (mut element, prefixes, mut properties) = run_properties(
4713 br#"<a:rPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" lang="en"><a:gradFill><a:gsLst><a:gs pos="0"><a:srgbClr val="FF0000"/></a:gs><a:gs pos="100000"><a:srgbClr val="0000FF"/></a:gs></a:gsLst></a:gradFill></a:rPr>"#,
4714 );
4715 properties.color = Some(ColorValue {
4716 rgb: Some("ff0000".to_owned()),
4717 ..ColorValue::default()
4718 });
4719 let gradient = element.child("gradFill").unwrap().clone();
4720 apply_run_properties(&mut element, &properties, None, &prefixes);
4721 assert_eq!(element.child("gradFill"), Some(&gradient));
4722 }
4723
4724 #[test]
4725 fn an_edited_colour_replaces_the_gradient() {
4726 let (mut element, prefixes, mut properties) = run_properties(
4727 br#"<a:rPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" lang="en"><a:gradFill><a:gsLst><a:gs pos="0"><a:srgbClr val="FFFFFF"/></a:gs></a:gsLst></a:gradFill></a:rPr>"#,
4728 );
4729 properties.color = Some(ColorValue {
4730 rgb: Some("FF0000".to_owned()),
4731 ..ColorValue::default()
4732 });
4733 apply_run_properties(&mut element, &properties, None, &prefixes);
4734 let serialized = String::from_utf8(serialize_xml(&element)).unwrap();
4735 assert!(!serialized.contains("gradFill"));
4736 assert!(serialized.contains("solidFill"));
4737 assert_eq!(parse_run_properties(Some(&element)).color, properties.color);
4738 }
4739
4740 #[test]
4741 fn the_writer_recognises_exactly_what_the_parser_reads() {
4742 for local in ["sp", "cxnSp", "pic", "graphicFrame", "grpSp"] {
4743 assert!(
4744 ShapeElements::WithConnectors.contains(local),
4745 "{local} is parsed as a shape but not recognised here"
4746 );
4747 }
4748 for local in ["txBody", "nvGrpSpPr", "extLst"] {
4749 assert!(
4750 !ShapeElements::WithConnectors.contains(local),
4751 "{local} is not a shape element"
4752 );
4753 }
4754 }
4755
4756 #[test]
4757 fn the_pre_connector_set_leaves_out_only_connectors() {
4758 assert!(!ShapeElements::WithoutConnectors.contains("cxnSp"));
4759 for local in ["sp", "pic", "graphicFrame", "grpSp"] {
4760 assert!(ShapeElements::WithoutConnectors.contains(local));
4761 }
4762 }
4763
4764 const LINKED_PARAGRAPH: &[u8] = br#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><a:r><a:rPr lang="en-US" dirty="0"/><a:t>See </a:t></a:r><a:r><a:rPr lang="en-US" strike="sngStrike"><a:hlinkClick r:id="rId2"/></a:rPr><a:t>the docs</a:t></a:r><a:r><a:rPr lang="en-US"/><a:t> today</a:t></a:r></a:p>"#;
4765 const LINK_PROPERTIES: &str =
4766 r#"<a:rPr lang="en-US" strike="sngStrike"><a:hlinkClick r:id="rId2"/></a:rPr>"#;
4767 const FIELD_PARAGRAPH: &[u8] = br#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:fld id="{A}" type="slidenum"><a:rPr lang="en-US"/><a:t>1</a:t></a:fld><a:r><a:rPr lang="en-US"/><a:t> of </a:t></a:r><a:fld id="{B}" type="datetime1"><a:rPr lang="en-US"/><a:t>2024</a:t></a:fld></a:p>"#;
4768
4769 fn rebuilt_paragraph(xml: &[u8], runs: &[(&str, RunProperties)]) -> String {
4770 let limits = ParseLimits::default();
4771 let mut budget = ParseBudget::new(&limits);
4772 let mut root = parse_xml(xml, "ppt/slides/slide1.xml", &mut budget).unwrap();
4773 let prefixes = Prefixes::from_root(&mut root);
4774 let write = ParagraphWrite {
4775 source_paragraphs: Vec::new(),
4776 source_index: Some(0),
4777 preserved_xml: None,
4778 rebuild: true,
4779 properties_changed: false,
4780 alignment: None,
4781 level: 0,
4782 bullet: None,
4783 runs: runs
4784 .iter()
4785 .map(|(text, properties)| RunWrite {
4786 text: (*text).to_owned(),
4787 properties: properties.clone(),
4788 })
4789 .collect(),
4790 };
4791 let paragraph = build_paragraph(&write, Some(root), None, &prefixes);
4792 String::from_utf8(serialize_xml(¶graph)).unwrap()
4793 }
4794
4795 #[test]
4796 fn an_edit_spanning_several_runs_keeps_each_survivor_on_its_source_run() {
4797 let xml = rebuilt_paragraph(
4798 LINKED_PARAGRAPH,
4799 &[("Seethe doc now", RunProperties::default())],
4800 );
4801 assert!(
4802 xml.contains(r#"<a:r><a:rPr dirty="0" lang="en-US"/><a:t>See</a:t></a:r>"#),
4803 "{xml}"
4804 );
4805 assert!(
4806 xml.contains(&format!("<a:r>{LINK_PROPERTIES}<a:t>the doc</a:t></a:r>")),
4807 "{xml}"
4808 );
4809 assert!(
4810 xml.contains(r#"<a:r><a:rPr lang="en-US"/><a:t> now</a:t></a:r>"#),
4811 "{xml}"
4812 );
4813 assert_eq!(xml.matches("<a:r>").count(), 3, "{xml}");
4814 }
4815
4816 #[test]
4817 fn a_line_break_inside_the_span_keeps_the_runs_around_it_aligned() {
4818 let xml = rebuilt_paragraph(
4819 br#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><a:r><a:rPr lang="en-US"/><a:t>First</a:t></a:r><a:br/><a:r><a:rPr><a:hlinkClick r:id="rId2"/></a:rPr><a:t>Second</a:t></a:r></a:p>"#,
4820 &[("Firs\nSecond!", RunProperties::default())],
4821 );
4822 assert!(
4823 xml.contains(
4824 r#"<a:r><a:rPr lang="en-US"/><a:t>Firs</a:t></a:r><a:br/><a:r><a:rPr><a:hlinkClick r:id="rId2"/></a:rPr><a:t>Second!</a:t></a:r>"#
4825 ),
4826 "{xml}"
4827 );
4828 }
4829
4830 const FONT_PARAGRAPH: &str = r#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:pPr algn="ctr"/><a:r><a:rPr lang="en-US"><a:highlight><a:srgbClr val="FFFF00"/></a:highlight><a:latin charset="0" panose="020B0604020202020204" pitchFamily="34" typeface="Arial" vendorHint="1"/><a:cs charset="0" panose="020B0604020202020204" pitchFamily="34" typeface="Arial"/></a:rPr><a:t>38.8%</a:t></a:r><a:r><a:rPr b="1" lang="en-US"><a:latin charset="0" panose="020B0604020202020204" pitchFamily="34" typeface="Arial"/></a:rPr><a:t> of cases</a:t></a:r></a:p>"#;
4831 const ARIAL_LATIN: &str = r#"<a:latin charset="0" panose="020B0604020202020204" pitchFamily="34" typeface="Arial" vendorHint="1"/>"#;
4832
4833 fn font_runs(family: &str) -> [(&'static str, RunProperties); 2] {
4834 let arial = RunProperties {
4835 font_family: Some("Arial".to_owned()),
4836 ..RunProperties::default()
4837 };
4838 [
4839 (
4840 "39.8%",
4841 RunProperties {
4842 font_family: Some(family.to_owned()),
4843 ..RunProperties::default()
4844 },
4845 ),
4846 (
4847 " of cases",
4848 RunProperties {
4849 bold: Some(true),
4850 ..arial
4851 },
4852 ),
4853 ]
4854 }
4855
4856 fn edited_font_body(latin: &str) -> String {
4857 let (_, body) = FONT_PARAGRAPH.split_once('>').unwrap();
4858 let edited = body.replace("38.8%", "39.8%");
4859 edited.replacen(ARIAL_LATIN, latin, 1)
4860 }
4861
4862 #[test]
4863 fn a_text_edit_keeps_the_latin_font_element_whole() {
4864 let xml = rebuilt_paragraph(FONT_PARAGRAPH.as_bytes(), &font_runs("Arial"));
4865 assert!(xml.ends_with(&edited_font_body(ARIAL_LATIN)), "{xml}");
4866 }
4867
4868 #[test]
4869 fn a_new_font_family_drops_the_old_face_metrics_in_place() {
4870 let xml = rebuilt_paragraph(FONT_PARAGRAPH.as_bytes(), &font_runs("Calibri"));
4871 let calibri = edited_font_body(r#"<a:latin typeface="Calibri"/>"#);
4872 assert!(xml.ends_with(&calibri), "{xml}");
4873 }
4874
4875 const FILL_PARAGRAPH: &str = r#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:r><a:rPr lang="en-US"><a:solidFill><a:schemeClr val="accent6"><a:lumMod val="75000"/></a:schemeClr></a:solidFill></a:rPr><a:t>amongst</a:t></a:r><a:r><a:rPr lang="en-US"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill></a:rPr><a:t> them</a:t></a:r></a:p>"#;
4876
4877 fn hex_runs() -> Vec<(&'static str, RunProperties)> {
4879 let limits = ParseLimits::default();
4880 let mut budget = ParseBudget::new(&limits);
4881 let path = "ppt/slides/slide1.xml";
4882 let root = parse_xml(FILL_PARAGRAPH.as_bytes(), path, &mut budget).unwrap();
4883 root.child_elements()
4884 .zip(["among", " then"])
4885 .map(|(run, text)| {
4886 let color = parse_run_properties(run.child("rPr")).color;
4887 let hex = resolve_color_value_to_hex_with_theme(color.as_ref(), None);
4888 let color = ColorValue {
4889 rgb: hex.map(|hex| hex[1..].to_owned()),
4890 ..ColorValue::default()
4891 };
4892 let properties = RunProperties {
4893 color: Some(color),
4894 ..RunProperties::default()
4895 };
4896 (text, properties)
4897 })
4898 .collect()
4899 }
4900
4901 #[test]
4902 fn a_text_edit_keeps_theme_colours_and_their_transforms() {
4903 let xml = rebuilt_paragraph(FILL_PARAGRAPH.as_bytes(), &hex_runs());
4904 let (_, body) = FILL_PARAGRAPH.split_once('>').unwrap();
4905 let edited = body.replace("amongst", "among").replace(" them", " then");
4906 assert!(xml.ends_with(&edited), "{xml}");
4907 }
4908
4909 #[test]
4910 fn an_explicit_colour_edit_replaces_the_theme_fill() {
4911 let mut runs = hex_runs();
4912 runs[0].1.color = Some(ColorValue {
4913 rgb: Some("FF0000".to_owned()),
4914 ..ColorValue::default()
4915 });
4916 let xml = rebuilt_paragraph(FILL_PARAGRAPH.as_bytes(), &runs);
4917 let red = r#"<a:rPr lang="en-US"><a:solidFill><a:srgbClr val="FF0000"/></a:solidFill></a:rPr><a:t>among</a:t>"#;
4918 assert!(xml.contains(red), "{xml}");
4919 assert!(xml.contains(r#"<a:schemeClr val="tx1"/>"#), "{xml}");
4920 }
4921
4922 #[test]
4923 fn an_alpha_change_on_an_unchanged_theme_colour_is_written() {
4924 let xml = rebuilt_paragraph(
4925 br#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:r><a:rPr lang="en-US"><a:solidFill><a:schemeClr val="tx1"><a:alpha val="50000"/></a:schemeClr></a:solidFill></a:rPr><a:t>amongst</a:t></a:r></a:p>"#,
4926 &[(
4927 "among",
4928 RunProperties {
4929 color: Some(ColorValue {
4930 theme_color: Some("text1".to_owned()),
4931 alpha: Some(1.0),
4932 ..ColorValue::default()
4933 }),
4934 ..RunProperties::default()
4935 },
4936 )],
4937 );
4938 assert!(
4939 xml.contains(r#"<a:schemeClr val="tx1"><a:alpha val="100000"/></a:schemeClr>"#),
4940 "{xml}"
4941 );
4942 assert!(!xml.contains(r#"val="50000""#), "{xml}");
4943 }
4944
4945 #[test]
4946 fn switching_theme_slots_with_the_same_rgb_writes_the_new_slot() {
4947 let xml = rebuilt_paragraph(
4948 br#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:r><a:rPr lang="en-US"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill></a:rPr><a:t>amongst</a:t></a:r></a:p>"#,
4949 &[(
4950 "among",
4951 RunProperties {
4952 color: Some(ColorValue {
4953 theme_color: Some("dk1".to_owned()),
4954 ..ColorValue::default()
4955 }),
4956 ..RunProperties::default()
4957 },
4958 )],
4959 );
4960 assert!(xml.contains(r#"<a:schemeClr val="dk1"/>"#), "{xml}");
4961 assert!(!xml.contains(r#"<a:schemeClr val="tx1"/>"#), "{xml}");
4962 }
4963
4964 #[test]
4965 fn an_unchanged_transformed_theme_colour_survives_byte_identical() {
4966 let xml = rebuilt_paragraph(
4967 br#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:r><a:rPr lang="en-US"><a:solidFill><a:schemeClr val="accent1"><a:lumMod val="60000"/><a:lumOff val="40000"/></a:schemeClr></a:solidFill></a:rPr><a:t>amongst</a:t></a:r></a:p>"#,
4968 &[(
4969 "among",
4970 RunProperties {
4971 color: Some(ColorValue {
4972 rgb: Some("8FAADC".to_owned()),
4973 ..ColorValue::default()
4974 }),
4975 ..RunProperties::default()
4976 },
4977 )],
4978 );
4979 assert!(
4980 xml.contains(
4981 r#"<a:solidFill><a:schemeClr val="accent1"><a:lumMod val="60000"/><a:lumOff val="40000"/></a:schemeClr></a:solidFill>"#
4982 ),
4983 "{xml}"
4984 );
4985 assert!(!xml.contains("8FAADC"), "{xml}");
4986 }
4987
4988 #[test]
4989 fn an_edit_spanning_a_styled_line_break_keeps_the_break_properties() {
4990 let xml = rebuilt_paragraph(
4991 br#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:r><a:rPr lang="en-US"/><a:t>First line</a:t></a:r><a:br><a:rPr lang="en-US"><a:solidFill><a:schemeClr val="accent1"><a:lumMod val="60000"/><a:lumOff val="40000"/></a:schemeClr></a:solidFill><a:latin charset="0" panose="020B0604020202020204" pitchFamily="34" typeface="Arial"/><a:ea typeface="MS Gothic"/><a:cs typeface="Arial"/></a:rPr></a:br><a:r><a:rPr lang="en-US"/><a:t>Second line</a:t></a:r></a:p>"#,
4992 &[(
4993 "First line changed\nSecond line changed",
4994 RunProperties {
4995 color: Some(ColorValue {
4996 rgb: Some("8FAADC".to_owned()),
4997 ..ColorValue::default()
4998 }),
4999 font_family: Some("Arial".to_owned()),
5000 ..RunProperties::default()
5001 },
5002 )],
5003 );
5004 assert!(
5005 xml.contains(
5006 r#"<a:br><a:rPr lang="en-US"><a:solidFill><a:schemeClr val="accent1"><a:lumMod val="60000"/><a:lumOff val="40000"/></a:schemeClr></a:solidFill><a:latin charset="0" panose="020B0604020202020204" pitchFamily="34" typeface="Arial"/><a:ea typeface="MS Gothic"/><a:cs typeface="Arial"/></a:rPr></a:br>"#
5007 ),
5008 "{xml}"
5009 );
5010 }
5011
5012 #[test]
5013 fn an_inserted_line_break_does_not_inherit_a_neighbouring_run_properties() {
5014 let xml = rebuilt_paragraph(
5015 br#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><a:r><a:rPr lang="en-US" strike="sngStrike"><a:solidFill><a:srgbClr val="FF0000"/></a:solidFill><a:hlinkClick r:id="rId2"/></a:rPr><a:t>HelloWorld</a:t></a:r></a:p>"#,
5016 &[("Hello\nWorld", RunProperties::default())],
5017 );
5018 assert!(xml.contains("<a:br/>"), "{xml}");
5019 }
5020
5021 #[test]
5022 fn a_field_survives_an_edit_around_it_and_degrades_once_its_text_changes() {
5023 let xml = rebuilt_paragraph(
5024 FIELD_PARAGRAPH,
5025 &[("1 out of 2025", RunProperties::default())],
5026 );
5027 assert!(
5028 xml.contains(
5029 r#"<a:fld id="{A}" type="slidenum"><a:rPr lang="en-US"/><a:t>1</a:t></a:fld><a:r><a:rPr lang="en-US"/><a:t> out of </a:t></a:r><a:r><a:rPr lang="en-US"/><a:t>2025</a:t></a:r>"#
5030 ),
5031 "{xml}"
5032 );
5033 assert!(!xml.contains("datetime1"), "{xml}");
5034
5035 let xml = rebuilt_paragraph(FIELD_PARAGRAPH, &[("1X of 2024", RunProperties::default())]);
5036 assert!(
5037 xml.contains(
5038 r#"<a:fld id="{A}" type="slidenum"><a:rPr lang="en-US"/><a:t>1</a:t></a:fld><a:r><a:rPr lang="en-US"/><a:t>X</a:t></a:r><a:r><a:rPr lang="en-US"/><a:t> of </a:t></a:r><a:fld id="{B}" type="datetime1"><a:rPr lang="en-US"/><a:t>2024</a:t></a:fld>"#
5039 ),
5040 "{xml}"
5041 );
5042 }
5043
5044 #[test]
5045 fn text_typed_at_the_end_of_a_link_stays_inside_it() {
5046 let xml = rebuilt_paragraph(
5047 LINKED_PARAGRAPH,
5048 &[("See the docsX today", RunProperties::default())],
5049 );
5050 assert!(
5051 xml.contains(&format!("<a:r>{LINK_PROPERTIES}<a:t>the docsX</a:t></a:r>")),
5052 "{xml}"
5053 );
5054
5055 let bold = RunProperties {
5056 bold: Some(true),
5057 ..RunProperties::default()
5058 };
5059 let xml = rebuilt_paragraph(
5060 LINKED_PARAGRAPH,
5061 &[
5062 ("See ", RunProperties::default()),
5063 ("the docs", RunProperties::default()),
5064 ("X", bold),
5065 (" today", RunProperties::default()),
5066 ],
5067 );
5068 assert!(
5069 xml.contains(&format!(
5070 r#"<a:r>{LINK_PROPERTIES}<a:t>the docs</a:t></a:r><a:r><a:rPr b="1" lang="en-US" strike="sngStrike"><a:hlinkClick r:id="rId2"/></a:rPr><a:t>X</a:t></a:r><a:r><a:rPr lang="en-US"/><a:t> today</a:t></a:r>"#
5071 )),
5072 "{xml}"
5073 );
5074 }
5075
5076 #[test]
5077 fn deleting_a_linked_run_drops_its_link() {
5078 let xml = rebuilt_paragraph(
5079 LINKED_PARAGRAPH,
5080 &[("See today", RunProperties::default())],
5081 );
5082 assert!(!xml.contains("hlinkClick"), "{xml}");
5083 assert!(
5084 xml.contains(
5085 r#"<a:r><a:rPr dirty="0" lang="en-US"/><a:t>See </a:t></a:r><a:r><a:rPr lang="en-US"/><a:t> today</a:t></a:r>"#
5086 ),
5087 "{xml}"
5088 );
5089 }
5090
5091 #[test]
5092 fn a_plain_duplicate_at_the_front_does_not_steal_the_field() {
5093 let xml = rebuilt_paragraph(
5094 br#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:r><a:rPr lang="en-US"/><a:t>1</a:t></a:r><a:fld id="{A}" type="slidenum"><a:rPr lang="en-US"/><a:t>1</a:t></a:fld></a:p>"#,
5095 &[("1", RunProperties::default())],
5096 );
5097 assert!(
5098 xml.contains(
5099 r#"<a:fld id="{A}" type="slidenum"><a:rPr lang="en-US"/><a:t>1</a:t></a:fld>"#
5100 ),
5101 "{xml}"
5102 );
5103 assert_eq!(xml.matches("<a:r>").count(), 0, "{xml}");
5104 }
5105
5106 #[test]
5107 fn a_plain_duplicate_at_the_back_does_not_steal_the_field() {
5108 let xml = rebuilt_paragraph(
5109 br#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:r><a:rPr lang="en-US"/><a:t>X</a:t></a:r><a:fld id="{A}" type="slidenum"><a:rPr lang="en-US"/><a:t>1</a:t></a:fld><a:r><a:rPr lang="en-US"/><a:t>1</a:t></a:r></a:p>"#,
5110 &[
5111 ("X", RunProperties::default()),
5112 ("1", RunProperties::default()),
5113 ],
5114 );
5115 assert!(
5116 xml.contains(
5117 r#"<a:fld id="{A}" type="slidenum"><a:rPr lang="en-US"/><a:t>1</a:t></a:fld>"#
5118 ),
5119 "{xml}"
5120 );
5121 assert_eq!(xml.matches("<a:r>").count(), 1, "{xml}");
5122 assert!(
5123 !xml.contains(r#"<a:r><a:rPr lang="en-US"/><a:t>1</a:t></a:r>"#),
5124 "{xml}"
5125 );
5126 }
5127
5128 #[test]
5129 fn adjustments_without_preset_geometry_error_instead_of_no_opting() {
5130 let limits = ParseLimits::default();
5131 let mut budget = ParseBudget::new(&limits);
5132 let part = "ppt/slides/slide1.xml";
5133 let mut root = parse_xml(
5134 br#"<p:sld><p:cSld><p:spTree><p:sp><p:nvSpPr><p:cNvPr id="2" name="custom"/></p:nvSpPr><p:spPr><a:custGeom><a:pathLst><a:path w="10" h="10"><a:moveTo><a:pt x="0" y="0"/></a:moveTo><a:close/></a:path></a:pathLst></a:custGeom></p:spPr></p:sp></p:spTree></p:cSld></p:sld>"#,
5135 part,
5136 &mut budget,
5137 )
5138 .unwrap();
5139
5140 let package = PptxPackage::default();
5141 let mut replacements = HashMap::new();
5142 let mut new_parts = Vec::new();
5143 let mut sink = PartSink {
5144 package: &package,
5145 replacements: &mut replacements,
5146 new_parts: &mut new_parts,
5147 };
5148 let error = patch_slide(
5149 &mut root,
5150 &[ShapeWrite::Patch {
5151 source_index: 0,
5152 patch: Box::new(ShapePatch {
5153 adjust_values: Some(BTreeMap::from([("adj".to_owned(), 0.25)])),
5154 ..ShapePatch::default()
5155 }),
5156 }],
5157 None,
5158 None,
5159 part,
5160 ShapeElements::WithConnectors,
5161 &mut sink,
5162 &mut budget,
5163 )
5164 .unwrap_err();
5165 assert!(
5166 matches!(error, crate::PptxError::Write { ref message, .. } if message.contains("preset geometry")),
5167 "{error:?}"
5168 );
5169 }
5170}