Skip to content

feat: HandoutMaster + add_text_watermark helper — Phase 5, closing #20#52

Merged
MHoroszowski merged 1 commit into
masterfrom
feature/headers-footers-phase5
May 14, 2026
Merged

feat: HandoutMaster + add_text_watermark helper — Phase 5, closing #20#52
MHoroszowski merged 1 commit into
masterfrom
feature/headers-footers-phase5

Conversation

@MHoroszowski
Copy link
Copy Markdown
Owner

Phase 5 of #20 — HandoutMaster + watermark helper (closes the epic)

Ships the final two pieces of issue #20: a public HandoutMaster Python class so users can read and toggle handout-master visibility settings without dropping into XML, and a SlideMaster.add_text_watermark() helper that wraps the common "DRAFT every slide" pattern into one call.

What this PR completes

# read existing handout master
prs = Presentation("with_handout.pptx")
hm = prs.handout_master                 # raises ValueError when absent
hm.show_footer = False                  # _HeaderFooterVisibility from Phase 2

# add a watermark to every slide using a master
sm = prs.slide_masters[0]
wm = sm.add_text_watermark("DRAFT", transparency=0.7)
prs.save("out.pptx")

Open out.pptx in PowerPoint or Keynote: every slide using the modified master shows a centered, 72pt, ~70%-transparent "DRAFT" — and the handout-master footer toggle round-trips.

Changes

  • pptx.slide.HandoutMaster (NEW) — _HeaderFooterVisibility + _BaseMaster, mirrors NotesMaster structure. Inherits placeholders, shapes, and the four show_* properties.
  • pptx.parts.slide.HandoutMasterPart (NEW) — BaseSlidePart mirror of NotesMasterPart, without create_default() / _new() / _new_theme_part(). Read-only.
  • pptx.parts.presentation.PresentationPart — new handout_master_part and handout_master lazyproperties. No auto-create — raises ValueError("presentation has no handout master; auto-create is deferred …") when the relationship is absent. Chained from KeyError so the original lookup failure is preserved.
  • pptx.presentation.Presentation.handout_master (NEW property) — pass-through, matches notes_master shape.
  • pptx.shapes.shapetree.MasterShapes.add_textbox (NEW, three lines) — _BaseShapes only exposed add_textbox on SlideShapes/LayoutShapes. The watermark helper needs it on the master.
  • pptx.slide.SlideMaster.add_text_watermark(text, *, font_size=Pt(72), transparency=0.7, font_name="Calibri") (NEW) — centered textbox at 6in × 1.5in on the standard 10in × 7.5in slide; mid-gray (#808080) text; routes transparency through the existing FillFormat.transparency from PR feat(dml): add transparency support for solid fills and colors (closes #17) #30. Returns the Shape for fine-tuning.
  • pptx.__init__ — registered HandoutMasterPart against CT.PML_HANDOUT_MASTER in content_type_to_part_class_map.

Out of scope (deliberate)

  • Auto-create of handout master when absent — needs a handoutMaster.xml template file plus theme wiring. Raising ValueError with a clear message is the explicit Phase 5 contract.
  • add_image_watermark — text only; users who need a picture watermark call master.shapes.add_picture(...) directly today.
  • MSO_WATERMARK_PRESET enum / canned styles — string text + numeric transparency, that's the whole API surface.

Verification (local, CPython 3.14.4)

python3 -m pytest tests/ -q                       → 3651 passed in 5.25s (+19 vs Phase 4)
python3 -m ruff check src tests                   → All checks passed!
python3 -m ruff format --check src tests          → 216 files already formatted
python3 -m behave features/ --no-color            → 1048 scenarios, 0 failed
python3 uat/uat_headers_footers_phase5.py         → PASS (absent-path ValueError + watermark text + transparency round-trip)

19 new test cases:

  • DescribeHandoutMaster (6 cases): class existence, mixin chain, show_* round-trips.
  • DescribeHandoutMasterPart (4 cases): instantiation, handout_master lazyproperty, content-type registration, no create_default.
  • DescribePresentationPart extension (3 cases): handout_master_part returns related part, raises ValueError when absent, error message contains "no handout master".
  • DescribePresentation extension (1 case): Presentation.handout_master pass-through.
  • DescribeSlideMasterAddTextWatermark (4 cases): returns Shape, text matches argument, default transparency, centered position.

Epic close summary

Five phases shipped:

Phase PR Surface
1 #48 CT_HeaderFooter + CT_HandoutMaster + CT_TextField attrs (OOXML wrappers)
2 #49 Slide.footer/has_footer/has_slide_number/has_date/date_text + show_* on layout/master/notes-master
3 #50 _Paragraph.add_field() + _Field class + CT_TextField.text setter + control-char escape
4 #51 _Paragraph.fields discovery accessor
5 this HandoutMaster + HandoutMasterPart + Presentation.handout_master + SlideMaster.add_text_watermark

Test count: 3514 (issue-#20 epic start) → 3651 (epic close). Behave: 1048 / 0 throughout. Zero regressions across all five phases.

Closes #20.

Closes the headers/footers epic (#20). Phase 1 (PR #48) shipped the
`CT_HandoutMaster` OOXML wrapper; Phases 2-4 (PRs #49, #50, #51) built
out the public headers/footers/fields surface. Phase 5 adds the two
remaining pieces: a public `HandoutMaster` Python class so users can
read a handout master's visibility settings without dropping into XML,
and a `SlideMaster.add_text_watermark()` helper that wraps the common
"DRAFT"-style watermark pattern into one call.

Why this is small. `_HeaderFooterVisibility` (Phase 2) and
`CT_HandoutMaster` (Phase 1) already do the heavy lifting; Phase 5 just
wires a Python proxy + part + accessor onto them and adds a thin
convenience method.

Changes:
- pptx.slide.HandoutMaster — new class, inherits
  `_HeaderFooterVisibility, _BaseMaster` in the same order as
  `NotesMaster`. Docstring names the deferred auto-create.
- pptx.slide.SlideMaster.add_text_watermark — new method. Adds a
  centered 6in x 1.5in textbox to the master at standard 10in x 7.5in
  slide coordinates (left=2in, top=3in). Text gets `PP_ALIGN.CENTER`;
  the first run gets `font.name`, `font.size`, mid-gray solid fill,
  and the configured `transparency` (default 0.7). Returns the new
  `Shape` so callers can re-position or restyle.
- pptx.slide._HeaderFooterVisibility — `_element` type union widened
  to include `CT_HandoutMaster` so the mixin types cleanly under
  `HandoutMaster`.
- pptx.parts.slide.HandoutMasterPart — new `BaseSlidePart` subclass.
  Defines `handout_master` lazyproperty; deliberately omits
  `create_default` / `_new` / `_new_theme_part` because no
  `handoutMaster.xml` template ships in this fork yet.
- pptx.parts.presentation.PresentationPart.handout_master_part — new
  lazyproperty. Returns `part_related_by(RT.HANDOUT_MASTER)` when
  present; raises `ValueError` with a "no handout master" message
  (chained `from KeyError`) when absent. Unlike
  `notes_master_part`, this does NOT auto-create.
- pptx.parts.presentation.PresentationPart.handout_master — new
  lazyproperty pass-through to `.handout_master_part.handout_master`.
- pptx.presentation.Presentation.handout_master — new property
  pass-through, matching the shape of `notes_master`.
- pptx.shapes.shapetree.MasterShapes.add_textbox — new method,
  parallel to the existing `SlideShapes.add_textbox`. Needed so
  `SlideMaster.add_text_watermark` can drop a textbox directly on
  the master's shape tree (master shapes previously had no
  `add_textbox`, only the slide / layout collections did).
- pptx/__init__.py — registers `HandoutMasterPart` against
  `CT.PML_HANDOUT_MASTER` in `content_type_to_part_class_map` and
  adds it to the cleanup `del (...)` tuple.

Out of scope for Phase 5 (deliberate, see ISA Out of Scope):
- Auto-create of handout master when absent. Synthesizing one from
  scratch needs a known-good `handoutMaster.xml` template, and
  shipping that template + the theme wiring is risky without a
  reference deck. Phase 6+ if real users ask. The error message
  surfaces the deferral explicitly.
- `HandoutMaster.add_placeholder()` / placeholder cloning — read-only
  this phase; the inherited visibility props are write-enabled, but
  the placeholder set on the handout master is static.
- Image-watermark helper — `master.shapes.add_picture(...)` already
  does this in one line; wrapping it adds little value.
- Per-slide watermark — `add_text_watermark` lives on `SlideMaster`
  so every slide using that master gets the watermark by inheritance;
  per-slide watermark is just `slide.shapes.add_textbox(...)` today.
- `MSO_WATERMARK_PRESET` enum or canned styles — string text +
  numeric transparency is the entire API surface.

Anti-criteria upheld:
- No `handoutMaster.xml` template file added (verified by
  `find src -name '*handoutMaster*'`).
- `Presentation.handout_master` does NOT silently auto-create — the
  `but_it_raises_ValueError_when_no_handout_master_is_present` test
  in `tests/parts/test_presentation.py` pins this.
- No `add_image_watermark` or `MSO_WATERMARK_PRESET` (verified by
  `grep -rn 'add_image_watermark|MSO_WATERMARK' src/ tests/`).
- `NotesMaster` and `NotesMasterPart` bodies are unchanged; only
  import lines that previously named just `NotesMaster` now also
  name `HandoutMaster`.
- `it_has_no_create_default_classmethod` in
  `tests/parts/test_slide.py` regression-pins that
  `HandoutMasterPart` does NOT acquire `create_default` over time.

Test counts: 19 new test cases (16 new `it_/but_/and_` functions; the
`it_round_trips_show_attrs_via_hf` parametrize fans out to 4 cases).
Distribution: 6 in `DescribeHandoutMaster`, 4 in
`DescribeSlideMasterAddTextWatermark`, 4 in `DescribeHandoutMasterPart`,
3 in `DescribePresentationPart`, 1 in `DescribePresentation`. Pytest
runs at 3651 passed (baseline 3632 + 19).

Verification (local, CPython 3.14.4):

  $ python3 -m pytest tests/ -q | tail -3
  ........................................................................                                                                          [100%]
  ...................................................                      [100%]
  3651 passed in 5.28s

  $ python3 -m ruff check src tests | tail -3
  All checks passed!

  $ python3 -m ruff format --check src tests | tail -3
  216 files already formatted

  $ python3 -m behave features/ --no-color 2>&1 | tail -3
  1048 scenarios passed, 0 failed, 0 skipped
  3151 steps passed, 0 failed, 0 skipped
  Took 0min 1.696s

  $ python3 uat/uat_headers_footers_phase5.py
  opening /Users/mhoroszowski/Projects/AI/python-pptx/tests/test_files/test.pptx
  OK — missing handout master raises ValueError: presentation has no handout master; auto-create is deferred because no handout master template ships in this fork yet
  adding watermark 'DRAFT' to slide_masters[0]
  watermark shape: left=1828800 top=2743200 width=5486400 height=1371600
  saving to /Users/mhoroszowski/Projects/AI/python-pptx/uat/out_headers_footers_phase5.pptx
  re-opening /Users/mhoroszowski/Projects/AI/python-pptx/uat/out_headers_footers_phase5.pptx
  round-tripped watermark: text='DRAFT' font='Calibri' size=72.0 transparency=0.7
  OK — watermark transparency round-tripped at 0.7
  PASS — handout-master absent-path and watermark helper verified

Closes #20.
@MHoroszowski MHoroszowski merged commit 2c96f97 into master May 14, 2026
16 checks passed
@MHoroszowski MHoroszowski deleted the feature/headers-footers-phase5 branch May 14, 2026 00:48
MHoroszowski added a commit that referenced this pull request May 14, 2026
Sharpens the agent/maintainer boundary on UAT: agents may execute
uat/uat_*.py to QA the script itself (verify it asserts what it claims,
doesn't crash, exits non-zero on failure), but a green script PASS does
NOT constitute signoff. Signoff requires a human opening the .pptx in
PowerPoint or Keynote, unless explicitly delegated in a specific case.

Adds:
- Explicit examples of acceptable vs. unacceptable summary phrasing.
- Clarification that the §7 trinity (pytest + ruff + behave) is the
  agent's full self-verification surface; UAT is not the fourth gate.
- Stop-and-ask directive when uncertain whether signoff has been
  delegated for a particular case.

The prior §6 step 4 ("The maintainer runs the UAT") was the seed; this
codifies the execute-vs-signoff distinction that was implied but not
enforced. Five recent epic phases (#48..#52) collapsed the distinction
in their PR bodies; §6a closes that loophole.
MHoroszowski added a commit that referenced this pull request May 14, 2026
fix(oxml): scope CT_GroupShape.max_shape_id to spTree descendants (post-#52 hotfix)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Epic] Headers, Footers, Slide Numbers, Dates & Watermarks

1 participant