Skip to content

pptx: stop losing content on a customer template (#195) - #196

Merged
dvejsada merged 9 commits into
masterfrom
claude/friendly-ramanujan-vxejrj
Sep 20, 2026
Merged

dvejsada merged 9 commits into
masterfrom
claude/friendly-ramanujan-vxejrj

Conversation

@dvejsada

Copy link
Copy Markdown
Collaborator

Fixes #195.

A deck built on a corporate template came out badly broken. Investigating it turned up six defects, five of which failed silently — the caller got a success response and no warning. Two of them affect the templates this repo ships, not just the customer one.

Every fix is verified against the real template, and the test for each one fails on the code before it.

What was wrong, and what changed

Two-column slides lost a column and both headings

_build_two_column_slide addressed placeholders by idx, assuming PowerPoint's built-in numbering. The customer template numbered its two cards 4 and 2 — left and right in that order — and its comparison layout 4, 13, 14, 15. The right column was written into the left card, the left column and both headings were dropped, three placeholders were left showing "Click to add text", and nothing was reported. The no-headings path lost a column too.

content_columns() reads the layout's shape instead: placeholders overlapping horizontally are one column, a column's tallest is its body, a shorter box above it is the heading strip.

Nothing warned

Per the warnings rule in AGENTS.md, every shortfall now reports itself — heading_inlined (info: no heading strip, so it becomes a bold first line), columns_merged (warning: one content area, columns merged in order), column_dropped (error: nothing to write into). A test asserts the invariant directly: the content is in the file, or a warning says why not.

Autofit measured against a hardcoded 18pt

_fit_text always passed DEFAULT_BODY_FONT_SIZE. Both shipped templates set 28pt, so every estimate was low by the square of the ratio (~2.4×). Text needing 1.9× its box measured as 0.86×, no fontScale was written, and PowerPoint renders a bare <a:normAutofit/> at full size until someone clicks into it — so the text ran off the slide. The overflow warning comes from the same number, so it never fired either.

read_body_font_size() resolves the size the way PowerPoint inherits it. test_shipped_template_body_is_not_the_built_in_default pins the 28pt so the assumption cannot drift back unnoticed.

"Comparison" was decided by counting placeholders

Four content placeholders claimed ROLE_COMPARISON. The customer template spends four on three cards plus a caption bar, so every two-column slide landed on three cards. A layout now has to resolve to exactly two columns each with a heading strip; anything else returns None and is selectable by name only.

That change alone made things worse at first: with comparison unprovided the fallback was the positional index, and position 4 there is Title Only — no body placeholder at all — so the slide lost both columns rather than merely looking wrong. ROLE_ALTERNATIVES now tries a near-neighbour role the template actually has first (comparison → two_column → content), each of which can still hold the text.

Empty placeholders were left in the file

_drop_unused_placeholders() sweeps them after the build — the third card, an unused heading strip, a Section Header's body, a title slide's absent subtitle. Placeholders holding a picture, table or chart are kept; footer chrome is left for the footer pass, which runs afterwards.

Drawn text and table fills ignored the template

Text the builder draws is a plain text box, so it inherited <p:defaultTextStyle> (tx1, black) — on a dark template the KPI figures and timeline captions were black on near-black. Chart text was worse: it lives in its own part and inherits nothing. Both now take the master's body colour, kept as an MSO_THEME_COLOR so it keeps tracking the theme.

Table colours were literals (TABLE_HEADER_FILL = 4172C4), so a table came out Office blue on every template. They are now the theme names accent1 and bg2 — _set_cell_fill() already wrote a theme name as schemeClr, only the default was not one. Per-slide header_color/fills and the registry's table defaults still win, and a test pins that.

Separately: closing had no role

It was hardcoded to ROLE_TITLE, so closing slides always used the cover layout with no way to redirect them per template. ROLE_CLOSING is the first entry in CONFIGURED_ROLE_DEFAULT: a role classification never assigns, supplied by the registry's layouts: mapping. Unmapped it resolves to title without a warning — that is its documented default, not a substitution. missing_roles() and the admin's analysis both exclude such roles, or every template would be reported as lacking something no placeholder arrangement can supply.

Verification

  • 2512 tests pass, up from 2407; ruff clean. Four new test files, each written to fail against the code it fixes.
  • All 14 slide types build on the corrected customer template with zero warnings, checked by rendering through LibreOffice and inspecting every slide.
  • On the original, uncorrected template the same deck now keeps every word, with each compromise reported — that is the check that these are code fixes and not template workarounds.

Notes for the reviewer

  • Behaviour change: table headers move from a fixed blue to the template's accent1. On the shipped templates that is 156082 rather than 4172C4, so existing decks will look different — deliberately, toward the template's own palette. TABLE_HEADER_TEXT stays an explicit white; choosing it from the theme needs a luminance decision the tool cannot make safely.
  • Docs are in the same commits, per AGENTS.md. The idx assumption was written down as both an invariant and a known limitation in docs/development/tools/powerpoint.md; both are replaced. AGENTS.md gains two rules, and docs/templates.md and docs/powerpoint-slides.md cover the caller-facing parts.
  • History was rewritten once before this PR opened: the commits originally cited #194, which is an unrelated admin PR merged before this branch existed. No one else had the branch.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UiV7z58sAHrFbTr5VXF6ie


Generated by Claude Code

`_build_two_column_slide` addressed placeholders by `idx`, assuming
PowerPoint's built-in numbering: 1/2 for Two Content, 1-4 for Comparison.
That is a convention a customer template need not follow. The template in
#195 numbered its two cards 4 and 2 — left and right in that order — and
its comparison layout 4, 13, 14 and 15, so the builder wrote the right
column into the left card, dropped the left column and both headings, left
three placeholders showing "Click to add text", and reported none of it.
Both spellings were affected: the no-headings path lost a column too.

`content_columns()` reads the layout's shape instead. Placeholders that
overlap horizontally are one column, a column's tallest placeholder is its
body, and a shorter placeholder above that body is its heading strip. The
builder fills column by column, left to right.

Every shortfall now reports itself, per the warnings rule in AGENTS.md:
a layout with no heading strips folds each heading into a bold first line
(`heading_inlined`, info), a layout with one content area merges both
columns into it in order (`columns_merged`, warning), and a layout with no
content placeholder cannot take them at all (`column_dropped`, error).
Nothing is dropped in silence.

Docs: the `idx` assumption was written down as an invariant and a known
limitation in docs/development/tools/powerpoint.md; both are replaced with
the geometry rule, and AGENTS.md gains it too. docs/powerpoint-slides.md
gains the caller-facing account of the three degraded paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiV7z58sAHrFbTr5VXF6ie
)

`_fit_text` passed `DEFAULT_BODY_FONT_SIZE` — 18pt — to
`estimate_text_fill` whatever the template said. Both templates this server
ships set 28pt in the master's `<p:bodyStyle>`, so every fill estimate was
low by the square of the ratio, roughly 2.4x. Text needing 1.9x its
placeholder measured as 0.86x, `apply_autofit()` was handed no scale and
wrote a bare `<a:normAutofit/>`, and PowerPoint renders that at full size
until someone clicks into the box: the text ran off the bottom of the slide.
The overflow warning is derived from the same estimate, so the caller was
not told either.

`read_body_font_size()` resolves the size the way PowerPoint inherits it —
the shape's own list style, the layout placeholder behind it, the master's
body placeholder, the master's `<p:bodyStyle>`, the presentation's default
text style. `_fit_text()` reads it per placeholder. The bullet boxes the
builder draws itself go through `read_master_body_font_size()`, because
`apply_list_style()` gives them the master's body style rather than the
built-in default. `DEFAULT_BODY_FONT_SIZE` stays as the last resort for a
template that states no size anywhere.

`test_shipped_template_body_is_not_the_built_in_default` pins the 28pt that
made this silent, so the assumption cannot drift back unnoticed.

Docs: the fit-estimation section gains which size is measured and why it
matters, the known limitation is narrowed to what is still approximate, and
both AGENTS.md and the invariants gain the rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiV7z58sAHrFbTr5VXF6ie
#195)

`classify_layout()` claimed ROLE_COMPARISON for any layout with four content
placeholders. The template in #195 spends four on a layout that is three
cards side by side plus a caption bar, so it took the comparison role and
every two-column slide in the deck was laid out on three cards — one column
written into the first card, the rest of the slide empty.

A layout now has to resolve, through `content_columns()`, to exactly two
columns each with a heading strip above its body. Anything else returns
None, which per this module's stated conservatism leaves it selectable by an
explicit name and never picked automatically.

That alone made things worse before `ROLE_ALTERNATIVES` existed: with
`comparison` unprovided the fallback was the positional index, and position
4 on that template is Title Only, which has no body placeholder at all — so
a two-column slide lost both columns rather than being laid out oddly. A
near-neighbour role the template actually has is now tried first, and every
alternative listed can still hold the content: comparison falls to
two_column, where the headings inline, and then to content, where the
columns merge. The positional index stays as the last resort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiV7z58sAHrFbTr5VXF6ie
`add_slide()` copies every placeholder its layout defines, so a layout that
offers more than the slide filled left "Click to add text" boxes in the
finished deck: the third card of a three-card layout, the heading strip of a
Comparison column given no heading, the body of a Section Header — a
`section` slide carries only a title — and the subtitle of a title slide
without one. On the template in #195 there were three on one slide.

`_drop_unused_placeholders()` sweeps them once after the build. A
placeholder holding a picture, table or chart is no longer an `<p:sp>` with
a text frame, so filling one keeps it; date, footer and slide-number
placeholders are skipped, because the footer pass runs afterwards and fills
them. Nothing a reader sees changes — the boxes never printed or appeared in
a slideshow — and Reset Slide restores them from the layout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiV7z58sAHrFbTr5VXF6ie
Anything the builder draws rather than places in a placeholder is a plain
text box, so it inherited the presentation's default text style — tx1,
black — instead of the body style a placeholder gets. On the dark template
in #195 the KPI figures and timeline detail lines were black on near-black.
Chart text was worse: it lives in its own part and inherits nothing at all,
so scatter axis labels came out black whatever the deck looked like.

`read_body_color()` reads the master's `<p:bodyStyle>` colour and returns a
MSO_THEME_COLOR for a scheme colour, so it keeps following the theme rather
than being flattened to today's value. `_paint()` applies it to the KPI
cells, timeline captions, quotes, blank-slide text and the bulleted box
drawn beside a picture or chart; `_paint_chart()` does it through
`chart.font`. A timeline chevron's label and a drawn title are left alone:
the first takes its colour from the shape style against the accent fill it
sits on, the second from the template's title style. A template that states
no body colour is left exactly as it was.

Table fills were pinned to Office's old default blue and a grey beside it,
so a table came out that blue on every template — including one whose own
accent is a brand teal. `TABLE_HEADER_FILL` and `TABLE_ALT_ROW_FILL` are now
the theme names `accent1` and `bg2`; `_set_cell_fill()` already wrote a
theme name as `schemeClr`, only the default was not one. Header text stays
explicit white, because picking it from the theme needs a luminance decision
this tool cannot make safely. Per-slide `header_color` and `fills` and the
registry's `table` defaults still win, and a test pins that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiV7z58sAHrFbTr5VXF6ie
…195)

`closing` slides asked for ROLE_TITLE, so they were always built on the
cover layout and there was no way to point them anywhere else short of
naming a layout on every slide. A contact or thank-you slide is a designer's
layout — the template in #195 has a "kontakt" layout with a QR code, a photo
and the firm's details — and no arrangement of placeholders identifies one,
so no signature rule could ever detect it.

ROLE_CLOSING is the first entry in CONFIGURED_ROLE_DEFAULT: a role that
classification never assigns, supplied by the registry's `layouts:` mapping
or a slide's own `layout`. Unmapped, it resolves to `title` with no warning,
because that is its documented default rather than a substitution.
`missing_roles()` and the admin's analysis both exclude such roles — they
would otherwise tell every template it lacks something no placeholder
arrangement can give it, and the admin page's role map and the resolver must
agree or the page is confidently wrong.

Also adds `closing` to the alternatives chain (title, then section, then
title_only), so a template with no cover layout still puts the slide
somewhere that can hold its lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiV7z58sAHrFbTr5VXF6ie
The six commits before this one, and the comments and docs they added,
cited #194 throughout. #194 is an unrelated admin UI pull request that was
merged before this branch opened, so every reference sent a reader
somewhere that says nothing about any of it. The PowerPoint defects are
#195; the earlier commits' messages carry the corrected number too.

No behaviour change — comments, docstrings and prose only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiV7z58sAHrFbTr5VXF6ie
Brings in the admin UI section reorganisation so this branch is tested
against its real base.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiV7z58sAHrFbTr5VXF6ie

Copy link
Copy Markdown
Collaborator Author

@claude review

Worth a close look at:

  • content_columns() in pptx_tools/placeholder_style.py — the clustering thresholds (50% horizontal overlap for "same column", 60% height ratio for "heading strip"). Are there real template shapes these get wrong?
  • _drop_unused_placeholders() — it removes any non-chrome placeholder with an empty text frame. I believe filled picture/table/chart placeholders are safe because they are no longer <p:sp> with a text frame, but that is the riskiest assumption in the PR.
  • ROLE_ALTERNATIVES — whether any chain can put a slide somewhere that cannot hold its content.
  • The table colour change is user-visible on existing decks (fixed blue → the template's accent1).

Generated by Claude Code

@claude

claude Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Claude finished @dvejsada's task in 5m 2s —— View job


Review

  • Gather context (PR description, changed files list)
  • Read content_columns() in pptx_tools/placeholder_style.py and evaluate clustering thresholds
  • Read _drop_unused_placeholders() in pptx_tools/slide_builder.py and verify the picture/table/chart-placeholder safety assumption
  • Read ROLE_ALTERNATIVES in pptx_tools/constants.py/layouts.py and trace each chain
  • Review the table colour change (accent1 vs fixed blue) for user-facing impact
  • Post final review

Overall this is a careful, well-tested fix — I traced each of the four areas you flagged and didn't find a case where content silently disappears. Findings below, none blocking.

content_columns() (pptx_tools/placeholder_style.py:136-176)

The clustering compares each new placeholder only against column[0] — the first placeholder assigned to that column — rather than against every member already in it or a running bounding box. In principle a three-placeholder row where the middle box overlaps the third by >50% but overlaps the first by <50% would misfile it. In practice this doesn't bite you here, because the failure mode is self-limiting: classify_layout() (pptx_tools/layouts.py:236-239) requires the result to be exactly two columns with a heading each, so any degenerate clustering (extra columns, a merged caption bar, a missing heading) resolves to None rather than a wrong-but-confident match. test_three_cards_and_a_bar_is_not_a_comparison and test_four_stacked_bodies_are_not_a_comparison cover the two shapes most likely to trip this up, and both correctly return None. The one real template shape this heuristic would still miss: a genuine 2-column layout whose caption sits below each body rather than above it (above = [... ph.top < body.top ...] only looks upward) — that would classify as headingless rather than as Comparison. Worth a code comment noting the "heading is always above" assumption, but not a bug since the fallback (None → resolved by explicit layout name or ROLE_TWO_COLUMN) is safe.

_drop_unused_placeholders() (pptx_tools/slide_builder.py:346-371)

The picture case is verified: insert_picture() replaces the placeholder's <p:sp> with a <p:pic> element in place, and Picture/GraphicFrame don't override has_text_frame (default False in python-pptx), so a filled picture placeholder is correctly skipped — and it's the one case with a dedicated test (test_a_filled_picture_placeholder_survives in tests/test_pptx_unused_placeholders.py). Table and chart placeholders, though, are never actually filled via placeholder.insert_table()/insert_chart() anywhere in this codebase — _add_title_content_slide (pptx_tools/helpers.py:392-405) always removes the content placeholder outright and adds a fresh table/chart shape at its rectangle. So the "holding a picture, table or chart" docstring language is a little broader than what's exercised; it's not incorrect (nothing currently could leave a filled table/chart placeholder behind), just worth tightening if a future change starts inserting into placeholders directly.

ROLE_ALTERNATIVES (pptx_tools/layouts.py:98-108)

Traced all nine chains. None land content somewhere it can't go, because title and content placement are decoupled from whichever layout the slide actually resolves to: title_style()/content_area() are resolved once per template from whichever layout best answers the question (pptx_tools/layouts.py:400-444), not from the slide's own layout. So even ROLE_TITLE_ONLY → (ROLE_CONTENT, ROLE_BLANK) landing all the way on a Blank layout still draws a title and (for kpi/timeline) a body rectangle correctly. The one place content really can be lost is _build_content_slide/_build_agenda_slide/_build_two_column_slide exhausting their alternatives down to a layout with no body placeholder at all — but that's the explicitly-designed last resort, and it's always reported (BULLETS_DROPPED/AGENDA_DROPPED/COLUMN_DROPPED), matching the PR's stated invariant rather than violating it.

Table colour (pptx_tools/constants.py:109-120)

The accent1/bg2/white-text combination is well-tested (test_table_header_uses_the_template_accent, test_an_explicit_header_colour_still_wins) and the user-visible behaviour change is already called out plainly in the PR description, so no surprise there. One thing to flag for awareness rather than fix: TABLE_ALT_ROW_FILL = "bg2" for zebra striping has the same theoretical luminance risk you already called out for TABLE_HEADER_TEXT — on a template whose bg2 sits close in luminance to the table's own base row fill, the zebra stripe could read as barely-there. You've clearly already accepted that class of risk as unavoidable without a luminance heuristic; I'd only suggest a one-line note next to TABLE_ALT_ROW_FILL making that trade-off explicit the way TABLE_HEADER_TEXT's comment already does, so a future reader doesn't have to rediscover it.

No blocking issues found. Nice test coverage — the two-column and unused-placeholder test files in particular exercise exactly the pathological template shapes described in the PR body.

…me (#195)

Review follow-ups on #196.

`content_columns()` clustered each placeholder against `column[0]` — the box
that happened to sort first — rather than against the column's extent. A
column headed by a narrow strip therefore measured every later box against
the *heading's* width, so a box sitting inside the wider body below it
started a spurious third column. `classify_layout()` was never at risk
(it demands exactly two columns and returns None otherwise), but
`_build_two_column_slide()` calls `content_columns()` on whatever layout the
slide landed on, vetted or not, and fills columns [0] and [1] — a spurious
column there is content in the wrong box. Each column now carries a running
span, and the test fails on the previous code.

Three things the review found under-documented, no behaviour change:

- `content_columns()` only ever looks for a heading *above* its body. A
  layout captioning its columns from below reads as headingless; the
  docstring now says so and what happens instead.
- `_drop_unused_placeholders()` claimed a placeholder "holding a picture,
  table or chart" survives. Only the picture path can produce one today —
  tables and charts never reach a placeholder, because
  `_add_title_content_slide()` removes it and draws a fresh shape. The
  docstring now describes the actual test and why a future graphic fill
  would be safe too.
- `TABLE_ALT_ROW_FILL` carries the same luminance caveat already spelled out
  for `TABLE_HEADER_TEXT`, so the trade-off does not have to be rediscovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiV7z58sAHrFbTr5VXF6ie

Copy link
Copy Markdown
Collaborator Author

Thanks — all four addressed in e8247b4. One turned out to be a real fix rather than a comment.

Clustering against column[0] — fixed, with a test that fails on the old code.

You were right that it's a live gap, but the self-limiting argument only covers the classification path. classify_layout() is indeed safe, because it demands exactly two columns with a heading each. But _build_two_column_slide() also calls content_columns() — on whatever layout the slide actually landed on, which may never have been vetted by classify_layout() at all (an explicit layout: override, or a layout resolved for ROLE_TWO_COLUMN). It then fills columns[0] and columns[1]. A spurious column there puts content in the wrong box, silently, which is the exact failure this PR exists to remove.

The reproducer is a column headed by a narrow strip: it pins the column's identity to the heading's width, so a box sitting inside the wider body below it starts a third column.

(1.0, 1.5, 2.0, 0.5)   narrow heading, spans 1.0–3.0
(1.0, 2.2, 5.0, 3.5)   its body, wider, spans 1.0–6.0
(3.5, 2.2, 2.5, 3.5)   inside the body's span, outside the heading's
(7.5, 2.2, 4.0, 3.5)   the genuine second column

Before: three columns ([(1.0, 5.0), (3.5, 2.5), (7.5, 4.0)]). After: two. Each column now carries a running span that widens as boxes join it. test_a_column_is_matched_on_its_whole_extent_not_its_first_box covers it.

Heading is only looked for above the body — documented in content_columns(), including what happens instead: a below-captioned layout reads as headingless, the captions become body text, and a slide that supplied its own headings gets heading_inlined. I left the behaviour alone deliberately — looking downward as well would risk reading a genuine caption as a heading, and the headingless path already keeps every word.

_drop_unused_placeholders() docstring — tightened. It now describes the actual test (has_text_frame plus empty), names the picture path as the only one that produces a graphic-holding placeholder today, notes that _add_title_content_slide() removes the placeholder outright for tables and charts, and says why a future insert-into-placeholder change would be safe for the same reason.

TABLE_ALT_ROW_FILL luminance caveat — added next to it, matching the note TABLE_HEADER_TEXT already carries.

On ROLE_ALTERNATIVES: agreed, and your reasoning matches mine — title_style() and content_area() resolve per template rather than per slide, so the chains cannot strand a title or a body. The genuine last resort (a layout with no body placeholder at all) is always reported.

2513 tests pass; ruff clean. Also re-verified against the real customer template: the corrected one still resolves every role and builds the original deck with zero warnings, and the uncorrected one still keeps every word with each compromise reported.


Generated by Claude Code

@dvejsada
dvejsada merged commit 92f2ad2 into master Sep 20, 2026
1 check passed
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.

PowerPoint: a customer template loses content silently — columns dropped, text overflowing, drawn text unreadable

2 participants