pptx: stop losing content on a customer template (#195) - #196
Conversation
`_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
|
@claude review Worth a close look at:
Generated by Claude Code |
|
Claude finished @dvejsada's task in 5m 2s —— View job 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.
The clustering compares each new placeholder only against
The picture case is verified:
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: Table colour ( The 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
|
Thanks — all four addressed in e8247b4. One turned out to be a real fix rather than a comment. Clustering against You were right that it's a live gap, but the self-limiting argument only covers the classification path. 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. Before: three columns ( Heading is only looked for above the body — documented in
On 2513 tests pass; Generated by Claude Code |
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_slideaddressed placeholders byidx, 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_textalways passedDEFAULT_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×, nofontScalewas 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_defaultpins 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 returnsNoneand is selectable by name only.That change alone made things worse at first: with
comparisonunprovided 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_ALTERNATIVESnow 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 anMSO_THEME_COLORso 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 namesaccent1andbg2—_set_cell_fill()already wrote a theme name asschemeClr, only the default was not one. Per-slideheader_color/fillsand the registry'stabledefaults still win, and a test pins that.Separately:
closinghad no roleIt was hardcoded to
ROLE_TITLE, so closing slides always used the cover layout with no way to redirect them per template.ROLE_CLOSINGis the first entry inCONFIGURED_ROLE_DEFAULT: a role classification never assigns, supplied by the registry'slayouts:mapping. Unmapped it resolves totitlewithout 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
ruffclean. Four new test files, each written to fail against the code it fixes.Notes for the reviewer
accent1. On the shipped templates that is156082rather than4172C4, so existing decks will look different — deliberately, toward the template's own palette.TABLE_HEADER_TEXTstays an explicit white; choosing it from the theme needs a luminance decision the tool cannot make safely.idxassumption was written down as both an invariant and a known limitation indocs/development/tools/powerpoint.md; both are replaced.AGENTS.mdgains two rules, anddocs/templates.mdanddocs/powerpoint-slides.mdcover the caller-facing parts.#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