Skip to content

rpptx: effective placeholder geometry, geometry setters that keep it, and changing a slide's layout - #192

Open
hadim wants to merge 3 commits into
tensorbee:mainfrom
hadim:feat/rpptx-effective-placeholder-geometry
Open

hadim wants to merge 3 commits into
tensorbee:mainfrom
hadim:feat/rpptx-effective-placeholder-geometry

Conversation

@hadim

@hadim hadim commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

Summary

  • rpptx-layout, rpptx, rpptx-py: inherited placeholder geometry (rpptx Python bindings: what is left for a production deck chain, as one checklist #169,
    item "Inherited placeholder geometry").
    • rpptx-layout exposes inherited_xfrm(placeholder, layout, master), the
      layout and master part of the transform chain. ResolveCtx::effective_xfrm
      and effective_picture_xfrm now fall back to it, so there is still one
      placeholder matching rule and rendering is unchanged.
    • rpptx adds Presentation::effective_geometry(slide_index, shape_path),
      which returns (left, top, width, height) as rendering places the shape:
      its own transform, or for a placeholder without one, the transform it
      inherits from its layout placeholder, then that placeholder's master
      counterpart. It also adds Presentation::materialize_geometry(slide_index, shape_path), which copies the missing offset or extent of the inherited
      transform onto a placeholder, or the whole transform, rotation and flips
      included, when it has none.
    • Python: Shape.effective_geometry() returns a tuple of four Length
      values, or None when the resolved transform has no extent. The
      left, top, width, height and rotation setters call
      materialize_geometry first, so one assignment on a placeholder keeps
      the values it does not assign, and the placeholder stays in the PDF, the
      PNGs and text_layout(). The four getters are unchanged and still report
      the shape's own values.
    • On the Production readiness for editing real docx and pptx files: an acceptance contract, two realistic fixtures and two matrices #158 deck fixture, the title of slide 2 now gives
      effective_geometry() == (457200, 274638, 8229600, 1143000), as
      python-pptx reports, and all 10 placeholders of the deck agree with
      python-pptx.
  • rpptx, rpptx-py: changing the layout a slide uses (rpptx Python bindings: what is left for a production deck chain, as one checklist #169, item "Changing
    the layout a slide uses"). Presentation::set_slide_layout(slide_index, layout_index) retargets the slide's layout relationship as a staged change.
    Placeholders re-inherit from the new layout, while a placeholder without its
    own transform that the new layout chain does not place first receives the
    transform it inherited, so it stays where it was drawn. Python
    Slide.slide_layout becomes assignable and advances the revision once.
  • Archive rows of rpptx and rpptx-layout re-recorded.

Part of #169. This closes its two items "Inherited placeholder geometry" and
"Changing the layout a slide uses". The other items of the checklist are in
#181 and in the rpptx-py tables branch, or still open.

Why

A placeholder without its own a:xfrm is the normal case in a deck made in
PowerPoint, and every placeholder add_slide creates has none. Its left,
top, width and height read None, while the renderer already placed it
through ResolveCtx::effective_xfrm. Worse, shape.left = x wrote an
a:xfrm with y="0" and no a:ext, by the zero-partner rule of HLD 10. The
resolver takes the slide's transform whole and skips a shape without an
extent, so the placeholder silently disappeared from to_pdf(), the PNGs and
text_layout(). I reproduced this on main with the fixture deck: after
title.left = 548640 the title of slide 2 read (548640, 0, None, None) and
shape id 2 was gone from text_layout(). shape.rotation = 30 did the same,
because it wrote an a:xfrm that carries only rot.

Changing a slide's layout had only a read side (slide_layout_index, the
Python getter).

Notes

Getters stay explicit. I kept the explicit-values rule of PR #149 (and
inspect --json of PR #151): left and the others still read None on an
inheriting placeholder, and the effective values come from the separate
accessor, one of the two answers #169 accepts. As a consequence the "shape
moved and resized" step of workflow_pptx.py still fails as written, because
it computes sh.left + EMU // 10. With effective_geometry() it passes (see
Tests). If you prefer python-pptx parity for the getters instead, the binding
could fall back to effective_geometry() in the four getters only. I
recommend the accessor, which keeps None meaning "not set on this shape"
everywhere.

What materializing does. Only placeholders are touched, and only when
their own transform lacks an offset or an extent and the layout chain
supplies a transform. A placeholder without a transform copies the inherited
one whole. A partial transform keeps what it has and takes the missing part,
which is also how python-pptx reads a partial a:xfrm it wrote itself.
The rotation setter materializes too, so rotating an inheriting
placeholder keeps its offset and extent, and python-pptx reads the same four
values and the new angle back. Shapes that are not placeholders keep the
zero-partner rule, as in python-pptx, and the existing test for it is
unchanged. HLD 10 now states both rules.

Rendering is unchanged. The resolver refactor is a pure move: every
existing rpptx-layout, rpptx-render and rpptx render test passes as it
was, and the docx hash harness is unaffected. I did not make effective_xfrm
fall back per field, so a deck that already carries a partial transform
written by python-pptx or by an older rpptx still does not draw that
placeholder until it is edited again. That would change the pptx render
output of such decks, so I left it for a separate decision.

shape_path. The two geometry methods name a shape by its index in the
slide tree, then its index in each enclosing group, the same path a Python
Shape handle carries. I preferred it to a shape id, which real decks
sometimes duplicate. A placeholder inside a group inherits as the renderer
resolves it. An empty path or one that names no shape is
Error::InvalidShapeMutation, for the read effective_geometry as well.
That follows picture_image, a read that already reports a missing picture
with that variant, and the doc comment says so. If you would rather give
reads their own variant before this API is released, the integration test
that pins it is the one place to change. I recommend keeping the existing
convention.

Layout change policy. Retarget, and keep the geometry of placeholders the
new layout does not place. Placeholders of the new layout that the slide lacks
are not added, and empty orphan placeholders are not removed, unlike
PowerPoint's own layout switch. Only the geometry is kept for an unplaced
placeholder: its text keeps the master's title, body or other text style by
type, but no longer gets the insets, anchor, autofit or list style of its old
layout and master placeholders. I recommend keeping this scope and adding
placeholder creation later as an option if needed. A layout of another master
is accepted: the slide relates only to its layout, and it then follows the new
master's theme, colour map and text styles, which is what choosing that layout
does in PowerPoint. A test builds a second master and checks the title
re-inherits from it, validate() is clean and the deck renders. If you would
rather refuse other masters, it is one comparison of the two layouts' master
parts.

Feature gate. The three facade methods need render, like text_layout,
because the placeholder matching rule lives in rpptx-layout, an optional
dependency of rpptx. rpptx-wasm builds without render and is unaffected.
Moving the matching rule to rpptx-oxml would lift the gate, at the cost of a
third crate in this PR.

API additions, no breaking change. rpptx_layout::inherited_xfrm in
rpptx-layout, Presentation::effective_geometry, materialize_geometry
and set_slide_layout in rpptx, and Python Shape.effective_geometry()
and the Slide.slide_layout setter. Stub, typing_smoke.py, HLD 06, HLD 07 and
HLD 10 updated. The Python geometry setters change behaviour on placeholders
only, as described above. Suggested release note line: "Setting one
coordinate or the rotation of a placeholder keeps its other values at the
ones it inherited, and Shape.effective_geometry() reports them."

Conflicts to expect. crates/rpptx/src/lib.rs (new methods after
slide_layout_index), the rpptx-py stub, test_documented_examples.py and
typing_smoke.py with #173, #181 and the rpptx-py tables branch (my additions
sit next to the geometry and slide layout entries), HLD 10, and the rpptx
archive row, which the maintainer re-records at merge. The measurement dates
are left as recorded, as in the other PRs of this round.

Tests

Added:

  • crates/rpptx-layout/src/context.rs:
    inherited_transform_skips_the_slide_shape_and_follows_the_layout_key
    (the slide's own transform is ignored, the layout key selects the master
    placeholder, latent footers match across indices, a master match alone is
    not inherited, and effective_xfrm agrees).
  • crates/rpptx/tests/integration.rs, right after the text_layout group:
    effective_geometry_follows_the_placeholder_chain_as_rendering_does (master
    and layout inheritance on the bundled template, the drawn text frames equal
    the effective geometry, slide number with a different index, picture
    placeholder, unmatched placeholder, non-placeholder, partial transforms,
    a placeholder in a group, path and slide errors),
    materialized_placeholder_geometry_keeps_the_shape_drawn_after_one_coordinate_changes
    (the bug without materializing, then idempotence, a moved title still laid
    out, rotation and flips copied, partial transforms completed, non-placeholder
    and unmatched shapes unchanged), and
    changing_a_slide_layout_retargets_it_and_keeps_unplaced_placeholders_in_place
    (Title and Content to Title Only and to Title Slide, relative relationship
    target, validate(), both frames still laid out, no-op on the same layout,
    errors leave the bytes unchanged, and a layout of a second master).
  • crates/rpptx-py/tests/test_documented_examples.py:
    test_placeholder_effective_geometry_matches_python_pptx_and_one_setter_keeps_the_rest
    (every placeholder of four python-pptx layouts against python-pptx, then a
    moved title, a resized body and a rotated title, which keeps its effective
    geometry and its text_layout() frame, all read back by python-pptx) next
    to the geometry setter test, and
    test_slide_layout_assignment_retargets_the_slide_and_keeps_unplaced_placeholders
    (one revision bump, python-pptx reads the new layout and the kept body
    geometry, foreign layout and wrong type refused) next to the slide layout
    test.

Run on macOS arm64 at the final branch head:

  • cargo fmt --all --check, python3 scripts/prose_check.py,
    python3 scripts/sync_agent_skills.py --check: clean.
  • cargo clippy -p rpptx-layout -p rpptx -p rpptx-py -p rpptx-render -p rpptx-cli -p rpptx-wasm -p rdocx --all-targets --all-features -- -D warnings: clean.
  • cargo test -p rpptx-layout -p rpptx -p rpptx-render -p rpptx-cli -p rpptx-wasm --all-features --no-fail-fast: rpptx lib 84 passed and
    integration 246 passed, rpptx-render 100 passed, rpptx-wasm 4 passed.
    rpptx-layout 130 passed with the 3 known corpus tests failing for lack of
    /corpus/pptx, and rpptx-cli 24 passed with
    validate_rejects_corruption_and_accepts_the_pinned_corpus failing for the
    same reason.
  • cargo test -p rdocx --no-fail-fast (consumer of rpptx): lib 465 passed,
    integration 314 passed, regression 561 passed, doctests 2 passed. The 7
    failures are the known pinned-tool ones
    (large_word_and_presentation_pdfs_preserve_logical_reading_order,
    word_and_powerpoint_chart_pixels_are_identical,
    odt_reader_matches_pinned_libreoffice_structure,
    public_authored_theme_and_fonts_match_pinned_word_resolution,
    sanitized_public_authoring_fixture_passes_every_conformance_stage,
    section_page_semantics_match_pinned_libreoffice_render,
    every_conditional_table_region_matches_word).
  • cargo test -p rpptx-py with DYLD_LIBRARY_PATH set: passes.
  • cargo check --target wasm32-unknown-unknown -p rpptx-wasm: passes.
  • python3 scripts/hash_harness.py --check: 49 entries match.
  • python3 scripts/readme_doctests.py: passes with the re-recorded rows.
  • Python 3.12, python-pptx 1.0.2: pytest crates/rpptx-py/tests 42 passed,
    mypy --strict on typing_smoke.py and the package clean,
    mypy.stubtest rpptx clean. The rotation part of the new geometry test
    fails when the rotation setter does not materialize, as expected.

workflow_pptx.py from #158 against this build, with a debug rpptx CLI for
the validate step. As written, the geometry step still fails because the
explicit getters stay None:

PASS  run text edited, font kept                   (None, 381000, None)
PASS  paragraph and text frame properties          ok
FAIL  shape moved and resized                      TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'
PASS  filled, outlined shape and connector added   9
PASS  picture added and replaced                   slide 0, shape 4
PASS  notes, hide, move, add, remove slides        7 slides
FAIL  slide duplicated                             AttributeError: 'builtins.SlideCollection' object has no attribute 'duplicate'
FAIL  counted replacement                          AttributeError: 'builtins.Presentation' object has no attribute 'replace_text'
FAIL  comment added then resolved                  AttributeError: 'builtins.Slide' object has no attribute 'resolve_comment'
PASS  overflow check (text_layout)                 [(4, 19)]
PASS  save                                         55067 bytes
PASS  validate                                     Validation passed: wf-edited.pptx
PASS  independent re-read                          python-pptx reads 7 slides
PASS  render                                       77066 bytes of PDF

The three steps that call duplicate, replace_text and resolve_comment
fail as on main and are covered by #181. With the geometry step changed to
read the accessor, it passes, and the moved title keeps its inherited top and
height:

sh = text_shape()
left, _, width, _ = sh.effective_geometry()
sh.left = left + EMU // 10
sh.width = width - EMU // 10
return (text_shape().left, text_shape().top, text_shape().width, text_shape().height)
moved and resized (548640, 274638, 8138160, 1143000)

Rotating the title of slide 2 of the same deck with t.rotation = 30.0 keeps
effective_geometry() at (457200, 274638, 8229600, 1143000), the explicit
getters now read those four values, shape id 2 stays in text_layout(), and
python-pptx reads the saved deck back as (457200, 274638, 8229600, 1143000)
at 30 degrees.

A placeholder without its own a:xfrm, the normal case in a deck made in
PowerPoint, reported None for left, top, width and height, although
rendering already placed it from its layout and master. Setting one
coordinate or the rotation then wrote an a:xfrm with a zero partner or
with only a rotation, and no a:ext, which the PDF, the PNGs and
text_layout skip, so the placeholder vanished.

rpptx-layout now exposes inherited_xfrm, the layout and master part of
the transform chain, and the resolver falls back to it, so rendering is
unchanged. The facade adds Presentation::effective_geometry, which
reports what rendering places, and materialize_geometry, which copies
the missing parts of the inherited transform onto a placeholder. The
Python Shape gains effective_geometry(), and its left, top, width,
height and rotation setters call materialize_geometry first, so one
assignment keeps the other values. The explicit getters still report
only the shape's own values.

GitHub issue tensorbee#169.
Only the read side existed: slide_layout_index and the Python
Slide.slide_layout getter followed the slide's layout relationship, and
nothing could point it at another layout.

Presentation::set_slide_layout retargets that relationship to any
layout the masters reach, as a staged change that publishes only after
the package reopens. Placeholders re-inherit from the new layout, while
a placeholder without its own transform that the new layout chain does
not place first receives the transform it inherited, so it stays where
it was drawn instead of vanishing. Placeholders of the new layout that
the slide lacks are not added. A layout of another master is accepted,
because the slide only relates to its layout and follows the new
master's theme and text styles, as it does in PowerPoint. The Python
Slide.slide_layout becomes assignable and advances the revision once.

GitHub issue tensorbee#169.
The inherited_xfrm function and its unit test grow the rpptx-layout
package, and the effective geometry, geometry materialization and
layout change methods with their integration tests grow the rpptx
package, so the README archive rows of both crates and their
ARCHIVE_MEASUREMENTS entries are re-measured.

GitHub issue tensorbee#169.

This branch has not been deployed

No deployments
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.

1 participant