Commit 708f0a3
feat(slides): add Presentation.append_from (slide-CRUD Phase 3 — cross-deck copy)
Implements the cross-deck slide copy sub-feature of issue #11. After
Phase 1 (in-deck CRUD: remove, move, indexed add) and Phase 2
(`Slide.duplicate`), Phase 3 closes the cross-package gap so users no
longer need to fork to `python-pptx-valutico` or roll their own
`copy_slide_from_external_prs` recipes.
API
---
- `Presentation.append_from(other_pres, slide_indexes=None) -> list[Slide]`
- `slide_indexes=None` appends every slide of `other_pres` in source
order. An iterable of zero-based ints appends only those slides in
that order (allowing reordering).
- Returns the list of newly-added `Slide` objects in insertion order.
- Raises `IndexError` if any value in `slide_indexes` is out of range.
Design
------
The implementation generalizes Phase 2's part-graph copy to work
between two `Package` instances. A `_PortContext` instance carries the
per-call dedup cache (`{source_part: target_part}` for masters, layouts,
themes) so source slide-masters / layouts / themes shared by multiple
appended source slides land as a single target part.
Three load-bearing choices, all driven by the upstream prior-art
recon and Phase 2's lessons:
1. **Port-fresh, not match-existing.** Always port a copy of source's
slide-layout, slide-master, and theme into target. Layout matching
by name or structural similarity is fragile — see upstream issue
scanny#934 attempts. Trade-off: target deck accumulates masters when
appending from many decks. Documented; preferred over fragile
matching.
2. **Image / media dedup uses target package's existing SHA1
machinery.** `Package.get_or_add_image_part(BytesIO(blob))` already
dedupes against target's existing image parts by SHA1 of bytes. We
hand it the foreign image's blob — no new dedup code needed.
3. **Notes-master is target's, not source's.** Notes-master is a
presentation-level singleton in OOXML. The ported notes-slide
relates to TARGET's existing (or auto-created) notes-master via
`RT.NOTES_MASTER`, with the back-rel (`RT.SLIDE`) rewired to the
new slide part — addresses upstream gotcha scanny#961 generalized to the
cross-package case.
Recursion handling
------------------
Master and layouts form a cycle (master refs its layouts, each layout
refs back to master). Broken by registering the new master in
`_master_map` BEFORE walking its rels, and by relating each new layout
to the master IMMEDIATELY after creation (so subsequent
`next_partname` calls see it via `iter_parts` and don't allocate
duplicate partnames — that bug surfaces as a zipfile "Duplicate name"
warning on save and is rejected by Office).
OOXML allocator quirk
---------------------
python-pptx's `CT_SlideMasterIdListEntry` / `CT_SlideLayoutIdListEntry`
only declare `rId` as a typed attribute, not the required `id` uint32.
Existing presentations round-trip fine because lxml preserves unknown
attributes verbatim; but a freshly-`_add_sldMasterId()`'d entry has no
`id` and PowerPoint rejects the file. The `_add_sldMasterId_to_presentation`
and `_add_sldLayoutId_to_master` helpers compute next-free id (max+1
with floor 2147483648) and set it via `el.set("id", str(next_id))`
directly on the lxml element.
Theme part loaded from disk is a base `Part` (binary blob), not an
`XmlPart` — `_port_theme` branches on `isinstance(... XmlPart)` and
either deepcopies the element or blob-copies, depending.
Test coverage
-------------
`tests/test_append_from.py` — 37 new tests across 6 describe classes:
- `DescribePresentation_AppendFrom_API` — argument validation, raises,
signature, return shape, empty `slide_indexes`, self-from-self edge
case.
- `DescribePresentation_AppendFrom_PerSlide` — partname uniqueness,
sldId uniqueness, modification isolation, source non-mutation.
- `DescribePresentation_AppendFrom_MasterPort` — master ported,
within-call dedup (3 source slides on shared master → 1 ported),
cross-call non-dedup, `<p:sldMasterId>` allocator, layout-tree
fidelity.
- `DescribePresentation_AppendFrom_ImageDedup` — cross-package SHA1
dedup, within-call dedup, round-trip preservation.
- `DescribePresentation_AppendFrom_NotesSlide` — own notes part,
text carried, back-rel rewired to new slide, target's existing
notes-master reused.
- `DescribePresentation_AppendFrom_RoundTrip` — open → append → save
→ reopen across all paths.
- `DescribePresentation_AppendFrom_Antis` — anti-criteria: source
unchanged, comments dropped, target's existing slides preserved.
`features/append-from.feature` + 7 new step impls — 4 acceptance
scenarios for default-all, selective, no-op-on-empty, and IndexError.
Verification
------------
```
$ python3 -m pytest tests/ -q
3162 passed in 4.32s
$ python3 -m ruff format src tests
204 files left unchanged
$ python3 -m ruff check src tests
All checks passed!
$ python3 -m behave features/ --no-color
56 features passed, 0 failed, 0 skipped
994 scenarios passed, 0 failed, 0 skipped
```
UAT script `uat_append_from.py` (untracked at repo root): builds a
3-slide source (textbox + picture + speaker notes) and a 1-slide
target sharing the same PNG; runs `target.append_from(source)`;
prints structural summary showing image_part_count = 2 BEFORE AND
AFTER (cross-package SHA1 dedup of both the PNG and the default
theme image holds), slide_part_count growing 1 → 4, master_part_count
growing 1 → 2; round-trips through save/reopen with title text and
speaker notes preserved. Includes a selective-subset variant
exercising `slide_indexes=[2, 0]`.
Out of scope (deferred per epic split):
- `Presentation.sections` (`<p14:sectionLst>`) — Phase 4.
- Append-mode open — Performance & Scale epic.
- Comments parts — explicitly dropped (consistent with Phase 2).
- Cross-deck font-table merging — fonts-in-OOXML are package-shared
but font-table parts beyond default-template baselines deferred.
Refs #11.1 parent 26dc97e commit 708f0a3
5 files changed
Lines changed: 1174 additions & 2 deletions
File tree
- features
- steps
- src/pptx
- parts
- tests
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
236 | 236 | | |
237 | 237 | | |
238 | 238 | | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
0 commit comments