main
  1#!/usr/bin/env python3
  2"""
  3Rearrange PowerPoint slides based on a sequence of indices.
  4
  5Usage:
  6    python rearrange.py template.pptx output.pptx 0,34,34,50,52
  7
  8This will create output.pptx using slides from template.pptx in the specified order.
  9Slides can be repeated (e.g., 34 appears twice).
 10"""
 11
 12import argparse
 13import shutil
 14import sys
 15from copy import deepcopy
 16from pathlib import Path
 17
 18import six
 19from pptx import Presentation
 20
 21
 22def main():
 23    parser = argparse.ArgumentParser(
 24        description="Rearrange PowerPoint slides based on a sequence of indices.",
 25        formatter_class=argparse.RawDescriptionHelpFormatter,
 26        epilog="""
 27Examples:
 28  python rearrange.py template.pptx output.pptx 0,34,34,50,52
 29    Creates output.pptx using slides 0, 34 (twice), 50, and 52 from template.pptx
 30
 31  python rearrange.py template.pptx output.pptx 5,3,1,2,4
 32    Creates output.pptx with slides reordered as specified
 33
 34Note: Slide indices are 0-based (first slide is 0, second is 1, etc.)
 35        """,
 36    )
 37
 38    parser.add_argument("template", help="Path to template PPTX file")
 39    parser.add_argument("output", help="Path for output PPTX file")
 40    parser.add_argument(
 41        "sequence", help="Comma-separated sequence of slide indices (0-based)"
 42    )
 43
 44    args = parser.parse_args()
 45
 46    # Parse the slide sequence
 47    try:
 48        slide_sequence = [int(x.strip()) for x in args.sequence.split(",")]
 49    except ValueError:
 50        print(
 51            "Error: Invalid sequence format. Use comma-separated integers (e.g., 0,34,34,50,52)"
 52        )
 53        sys.exit(1)
 54
 55    # Check template exists
 56    template_path = Path(args.template)
 57    if not template_path.exists():
 58        print(f"Error: Template file not found: {args.template}")
 59        sys.exit(1)
 60
 61    # Create output directory if needed
 62    output_path = Path(args.output)
 63    output_path.parent.mkdir(parents=True, exist_ok=True)
 64
 65    try:
 66        rearrange_presentation(template_path, output_path, slide_sequence)
 67    except ValueError as e:
 68        print(f"Error: {e}")
 69        sys.exit(1)
 70    except Exception as e:
 71        print(f"Error processing presentation: {e}")
 72        sys.exit(1)
 73
 74
 75def duplicate_slide(pres, index):
 76    """Duplicate a slide in the presentation."""
 77    source = pres.slides[index]
 78
 79    # Use source's layout to preserve formatting
 80    new_slide = pres.slides.add_slide(source.slide_layout)
 81
 82    # Collect all image and media relationships from the source slide
 83    image_rels = {}
 84    for rel_id, rel in six.iteritems(source.part.rels):
 85        if "image" in rel.reltype or "media" in rel.reltype:
 86            image_rels[rel_id] = rel
 87
 88    # CRITICAL: Clear placeholder shapes to avoid duplicates
 89    for shape in new_slide.shapes:
 90        sp = shape.element
 91        sp.getparent().remove(sp)
 92
 93    # Copy all shapes from source
 94    for shape in source.shapes:
 95        el = shape.element
 96        new_el = deepcopy(el)
 97        new_slide.shapes._spTree.insert_element_before(new_el, "p:extLst")
 98
 99        # Handle picture shapes - need to update the blip reference
100        # Look for all blip elements (they can be in pic or other contexts)
101        # Using the element's own xpath method without namespaces argument
102        blips = new_el.xpath(".//a:blip[@r:embed]")
103        for blip in blips:
104            old_rId = blip.get(
105                "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed"
106            )
107            if old_rId in image_rels:
108                # Create a new relationship in the destination slide for this image
109                old_rel = image_rels[old_rId]
110                # get_or_add returns the rId directly, or adds and returns new rId
111                new_rId = new_slide.part.rels.get_or_add(
112                    old_rel.reltype, old_rel._target
113                )
114                # Update the blip's embed reference to use the new relationship ID
115                blip.set(
116                    "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed",
117                    new_rId,
118                )
119
120    # Copy any additional image/media relationships that might be referenced elsewhere
121    for rel_id, rel in image_rels.items():
122        try:
123            new_slide.part.rels.get_or_add(rel.reltype, rel._target)
124        except Exception:
125            pass  # Relationship might already exist
126
127    return new_slide
128
129
130def delete_slide(pres, index):
131    """Delete a slide from the presentation."""
132    rId = pres.slides._sldIdLst[index].rId
133    pres.part.drop_rel(rId)
134    del pres.slides._sldIdLst[index]
135
136
137def reorder_slides(pres, slide_index, target_index):
138    """Move a slide from one position to another."""
139    slides = pres.slides._sldIdLst
140
141    # Remove slide element from current position
142    slide_element = slides[slide_index]
143    slides.remove(slide_element)
144
145    # Insert at target position
146    slides.insert(target_index, slide_element)
147
148
149def rearrange_presentation(template_path, output_path, slide_sequence):
150    """
151    Create a new presentation with slides from template in specified order.
152
153    Args:
154        template_path: Path to template PPTX file
155        output_path: Path for output PPTX file
156        slide_sequence: List of slide indices (0-based) to include
157    """
158    # Copy template to preserve dimensions and theme
159    if template_path != output_path:
160        shutil.copy2(template_path, output_path)
161        prs = Presentation(output_path)
162    else:
163        prs = Presentation(template_path)
164
165    total_slides = len(prs.slides)
166
167    # Validate indices
168    for idx in slide_sequence:
169        if idx < 0 or idx >= total_slides:
170            raise ValueError(f"Slide index {idx} out of range (0-{total_slides - 1})")
171
172    # Track original slides and their duplicates
173    slide_map = []  # List of actual slide indices for final presentation
174    duplicated = {}  # Track duplicates: original_idx -> [duplicate_indices]
175
176    # Step 1: DUPLICATE repeated slides
177    print(f"Processing {len(slide_sequence)} slides from template...")
178    for i, template_idx in enumerate(slide_sequence):
179        if template_idx in duplicated and duplicated[template_idx]:
180            # Already duplicated this slide, use the duplicate
181            slide_map.append(duplicated[template_idx].pop(0))
182            print(f"  [{i}] Using duplicate of slide {template_idx}")
183        elif slide_sequence.count(template_idx) > 1 and template_idx not in duplicated:
184            # First occurrence of a repeated slide - create duplicates
185            slide_map.append(template_idx)
186            duplicates = []
187            count = slide_sequence.count(template_idx) - 1
188            print(
189                f"  [{i}] Using original slide {template_idx}, creating {count} duplicate(s)"
190            )
191            for _ in range(count):
192                duplicate_slide(prs, template_idx)
193                duplicates.append(len(prs.slides) - 1)
194            duplicated[template_idx] = duplicates
195        else:
196            # Unique slide or first occurrence already handled, use original
197            slide_map.append(template_idx)
198            print(f"  [{i}] Using original slide {template_idx}")
199
200    # Step 2: DELETE unwanted slides (work backwards)
201    slides_to_keep = set(slide_map)
202    print(f"\nDeleting {len(prs.slides) - len(slides_to_keep)} unused slides...")
203    for i in range(len(prs.slides) - 1, -1, -1):
204        if i not in slides_to_keep:
205            delete_slide(prs, i)
206            # Update slide_map indices after deletion
207            slide_map = [idx - 1 if idx > i else idx for idx in slide_map]
208
209    # Step 3: REORDER to final sequence
210    print(f"Reordering {len(slide_map)} slides to final sequence...")
211    for target_pos in range(len(slide_map)):
212        # Find which slide should be at target_pos
213        current_pos = slide_map[target_pos]
214        if current_pos != target_pos:
215            reorder_slides(prs, current_pos, target_pos)
216            # Update slide_map: the move shifts other slides
217            for i in range(len(slide_map)):
218                if slide_map[i] > current_pos and slide_map[i] <= target_pos:
219                    slide_map[i] -= 1
220                elif slide_map[i] < current_pos and slide_map[i] >= target_pos:
221                    slide_map[i] += 1
222            slide_map[target_pos] = target_pos
223
224    # Save the presentation
225    prs.save(output_path)
226    print(f"\nSaved rearranged presentation to: {output_path}")
227    print(f"Final presentation has {len(prs.slides)} slides")
228
229
230if __name__ == "__main__":
231    main()