Skip to content

Edit table cells and rows, picture crop, z-order and run hyperlinks from rpptx and Python - #189

Open
hadim wants to merge 6 commits into
tensorbee:mainfrom
hadim:feat/rpptx-py-tables-crop-zorder-hyperlinks
Open

hadim wants to merge 6 commits into
tensorbee:mainfrom
hadim:feat/rpptx-py-tables-crop-zorder-hyperlinks

Conversation

@hadim

@hadim hadim commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

Summary

One commit per checklist item of #169, then the archive re-record.

  • Table cells (F), binding only. Python Cell gains python-pptx's
    merge(other_cell), split(), is_merge_origin, is_spanned,
    span_height and span_width over the facade's staged merge_to and
    split, plus fill (a FillFormat through a new table cell fill target)
    and margin_left, margin_right, margin_top and margin_bottom. Merge
    and split keep the rectangular grid, so no handle goes stale and the
    revision does not advance.
  • Row heights and cell borders (H1). TableRef and TableMut read
    row_height, and TableMut::set_row_height stages the change like
    set_column_width and keeps the graphic frame height equal to the sum of the
    rows. Both setters now share one checked extent helper. The new
    CellBorder enum names an edge, TableCellRef::border and
    TableCellMut::border read it, and TableCellMut::set_border replaces or
    clears it. Python gains Table.rows (a RowCollection of Row handles with
    a writable height) and Cell.border_left, border_right, border_top
    and border_bottom as LineFormat views, reusing the width, colour and
    fill logic of shape lines.
  • Picture crop (J). ShapeRef::crop and ShapeMut::set_crop read and
    write a picture's a:srcRect as four Percent1000 insets, now re-exported
    from rpptx. Python Shape gains python-pptx's crop_left, crop_top,
    crop_right and crop_bottom floats. No oxml-drawing change, so no
    overlap with Paint slide backgrounds in PDF output and open gradients without ang or path #175's fill.rs work.
  • Z-order (I). CT_ShapeTree::move_child(from, to) in rpptx-oxml moves a
    child's bytes past exactly the children between its two indices and
    reparses, as remove_child_by_id does, so unmodelled members such as
    p:contentPart and a schema-final p:extLst keep their bytes and place.
    Both now share one range finder. Presentation::move_shape(slide_index, from_index, to_index) exposes it with move_slide's final-index semantics,
    and Python gains ShapeCollection.move(from_, to).
  • Run hyperlinks (L1). Presentation::hyperlink_address(slide_index, relationship_id) resolves the r:id of an a:hlinkClick, and
    Presentation::set_run_hyperlink(slide_index, shape_id, paragraph_index, run_index, address) adds, retargets or removes a run's click hyperlink in
    one staged step, reusing the slide's relationship to the same address and
    pruning the old one once nothing on the slide names it. Python gains
    Run.hyperlink, a Hyperlink view whose address follows python-pptx.
  • Archive rows. rpptx and rpptx-oxml re-recorded in
    scripts/readme_doctests.py and their READMEs.

Every new Python name is in _rpptx.pyi and typing_smoke.py. HLD 06
describes the new facade methods and HLD 10 the new binding surface. The
rpptx-py README capability list gains one line per feature.

Part of #169: checklist items "Table cells: merge_to / split, set_fill,
set_margins", "Table rows and columns: row height, cell borders", "Z-order",
"Picture crop" and the run half of "Hyperlinks on runs and shapes". Still open
after this PR and #181 (items A to E):

Why

These are the #169 items the facade either had and Python did not bind (F),
or that the model parsed but no public API reached (H1, J, L1), or that did
not exist at all (I). A deck pass that merged a header, sized a row, drew a
cell edge, cropped a picture, brought a shape forward or linked a run had to
keep a python-pptx step for it.

Notes

Tests

Added to crates/rpptx/tests/integration.rs:

  • row_heights_and_cell_borders_round_trip_with_the_frame_height_in_step:
    row height and frame height after set_row_height, rejected heights leave
    the table unchanged, borders set, cleared and reopened, and lnL, lnB
    before the cell fill in the XML.
  • picture_crop_round_trips_and_rewrites_only_the_changed_edges: four zero
    insets add nothing, a non-picture is refused, the reopened crop, a:srcRect
    between the blip and the fill mode, and the edge rules on a hand-written
    a:srcRect.
  • moving_a_shape_changes_the_draw_order_and_keeps_every_shape_id: the pixel
    under two overlapping rectangles changes colour in the rendered PNG, ids
    keep their values, bad indices are refused, and the reopened deck validates.
  • run_hyperlinks_reuse_resolve_and_prune_their_relationships: rejected
    addresses and runs change nothing, one relationship per address, retargeting
    prunes, removal prunes only when unused, the layout relationship is never
    pruned, a run inside a group, and a shared shape id is refused.
  • Four #[ignore] pinned python-pptx 1.0.2 oracles in the style of
    add_table_matches_pinned_python_pptx_table_semantics, through one new
    helper python_pptx_1_0_2_reads: row heights and borders, crop, z-order
    names and run hyperlink addresses as python-pptx reads them.

Added to rpptx-oxml's existing shape_tree unit test module:
moving_a_child_passes_only_the_children_between_its_indices, with a raw
member between children and a schema-final p:extLst.

Added to crates/rpptx-py/tests/test_documented_examples.py, each checked
against python-pptx 1.0.2 in both directions:

  • test_table_cells_merge_split_fill_and_margins_like_python_pptx
  • test_table_row_heights_and_cell_borders_write_what_python_pptx_reads
  • test_picture_crop_matches_python_pptx_in_both_directions (a two-colour
    picture renders differently once cropped)
  • test_shapes_move_changes_the_z_order_and_stales_handles_once
  • test_run_hyperlink_address_reads_writes_and_prunes_like_python_pptx
    (repeated retargeting keeps one relationship per live address, and the PDF
    carries the new URI)

typing_smoke.py gains one small function per feature.

Run on this branch (macOS arm64, Python 3.12.14, python-pptx 1.0.2):

  • cargo fmt --all --check: pass.
  • cargo clippy -p rpptx-oxml -p rpptx -p rpptx-py --all-targets --all-features -- -D warnings: pass, also on each commit alone.
  • cargo test -p rpptx: 84 lib and 238 integration tests pass.
    cargo test -p rpptx --test integration -- --include-ignored on the eight
    new tests, the four python-pptx oracles included: pass.
  • cargo test -p rpptx-oxml: pass (16 unit, 155 integration).
  • cargo test -p rpptx-py with DYLD_LIBRARY_PATH set to the libpython the
    test binary links: pass, 0 Rust tests.
  • Consumers: cargo test -p rpptx-cli passes except
    validate_rejects_corruption_and_accepts_the_pinned_corpus (missing
    /corpus/pptx, environment only). cargo check --target wasm32-unknown-unknown -p rpptx-wasm: pass.
  • maturin develop --locked then pytest crates/rpptx-py/tests: 45 passed, 0
    skipped. Each commit alone: 41, 42, 43, 44 and 45 passed.
  • mypy --strict crates/rpptx-py/tests/typing_smoke.py crates/rpptx-py/python/rpptx: no issues. python -m mypy.stubtest rpptx:
    no issues.
  • python3 scripts/hash_harness.py --check: 49 entries match.
  • python3 scripts/prose_check.py: 0 violations.
    python3 scripts/readme_doctests.py: pass after the re-record.
    python3 scripts/sync_agent_skills.py --check: in sync.

workflow_pptx.py from #158 on fixture-deck.pptx gives the same rows as
main: the geometry, duplicate, replacement and comment rows belong to G and
to #181, and validate needs the CLI on PATH. A probe on the same fixture
merges two cells of the slide 4 table, sets a row height, a bordered, filled
and inset cell, crops the slide 1 picture, moves it to the back and links a
run on slide 2 twice. The result reopens in rpptx, rpptx validate passes,
the PDF carries the second URI, and python-pptx reads the span, the row
height, the margin, the crop, the new order and the link.

Environment-only failures seen: the missing-corpus test above, and the known
libpython load path of the rpptx-py Rust test binary without
DYLD_LIBRARY_PATH.

The rpptx facade already merges and splits table cells, reports merge
origins and spans, and reads and writes cell fills and margins, but the
Python Cell exposed only its text. A deck pass that merged a header or
shaded a cell had to fall back to python-pptx for that step.

Cell now has python-pptx's merge(other_cell), split(), is_merge_origin,
is_spanned, span_height and span_width over the native staged table
operations. They keep the rectangular grid, so no handle goes stale and
the revision does not advance. A cell of another table raises
ValueError, and the native refusals of an overlapping merge or of a
split on a cell that is not a merge origin raise RpptxError with the
table unchanged. Cell.fill is a FillFormat through a new table cell
fill target, and margin_left, margin_right, margin_top and
margin_bottom read the tcPr margins as Length, or None when absent,
with the write rules of the text frame margins.

GitHub issue tensorbee#169.
A table row's stored height and a cell's lnL, lnR, lnT and lnB border
lines were parsed and written by oxml-drawing, but the rpptx facade had
no accessor for either, and Python had no rows collection at all. A
deck pass could not size a row or draw a cell edge without python-pptx
and raw XML edits.

TableRef and TableMut now read row_height, and TableMut::set_row_height
stages the change like set_column_width and keeps the graphic frame
height equal to the sum of the rows. Both setters share one checked
extent helper. The new CellBorder enum names an edge, and TableCellRef
and TableCellMut read border(edge) while set_border replaces or clears
it. The writer already emits the four lines in schema order before the
cell fill.

In Python, Table.rows is a RowCollection of Row handles whose height
reads and writes through the facade. python-pptx has no border API, so
Cell.border_left, border_right, border_top and border_bottom are
LineFormat views over a cell border target, reusing the width, colour
and fill logic of shape lines. Reading a border never creates one, and
none of these writes advances the revision.

GitHub issue tensorbee#169.
A picture's a:srcRect is modelled as BlipFill.source_rect, written in
schema order and honoured by the renderer, but no facade method reached
it, so neither Rust nor Python callers could read or change a crop.
Only replace_image kept an existing one.

ShapeRef::crop returns the four insets as Percent1000, now re-exported
from rpptx, with zero for an absent edge and None for other shape
kinds. ShapeMut::set_crop writes them on a picture and rejects other
kinds. It keeps the stored attribute of an unchanged edge, drops a
changed edge of zero, and adds no element for four zero insets on a
picture without one, so a no-op write leaves the XML as it was.

Python Shape gains python-pptx's crop_left, crop_top, crop_right and
crop_bottom floats. A write rounds half to even like ST_Percentage,
rejects a value that is not finite or out of range, changes nothing
when the value is unchanged, and does not advance the revision.

GitHub issue tensorbee#169.
New shapes land on top of a slide's shape tree, and nothing in
rpptx-oxml, the facade or the binding could bring one forward or send
it back. CT_ShapeTree only appended and removed children.

CT_ShapeTree::move_child(from, to) serializes the tree, moves the
child's bytes past exactly the children between the two indices, and
reparses, as remove_child_by_id already does, so unmodelled members
such as p:contentPart and a schema-final p:extLst keep their bytes and
their place. Both now share one finder that returns the byte range of
every typed child in order. Presentation::move_shape moves an immediate
slide child with final-index semantics like move_slide and rejects an
index past the last child without change. Animations, connector glue
and relationships name shape ids, so they need no rewrite.

Python gains ShapeCollection.move(from_, to), mirroring
SlideCollection.move: negative indices, one revision bump, and a
ValueError on the read-only nested collections.

GitHub issue tensorbee#169.
A run's a:hlinkClick is modelled with only its relationship id, and
nothing public resolved, added or retargeted the slide's hyperlink
relationship, so neither Rust nor Python callers could read a link's
address or point a run at a URL. Validation also reports a hyperlink
relationship that nothing references, so a relationship-level helper
would have left a window where the deck does not validate.

Presentation::hyperlink_address resolves the r:id an a:hlinkClick
stores, like python-pptx's address. Presentation::set_run_hyperlink
does the write in one staged step: it finds the ordinary shape by its
unique id, inside groups too, gives the regular run a fresh a:hlinkClick
naming the slide's external hyperlink relationship to the address,
reuses that relationship when it exists, and removes the old one once
nothing on the slide names it, so repeated retargeting does not grow
the part. None removes the link, the current address is a no-op, and a
rejected address or run leaves the slide and its relationships as they
were.

Python gains Run.hyperlink, a Hyperlink view whose address getter and
setter follow python-pptx, with None or an empty string removing the
link. The write stays in place and does not advance the revision.

GitHub issue tensorbee#169.
The table, crop, z-order and hyperlink changes add public methods and
their documentation to rpptx and a shape tree move to rpptx-oxml, so
both packaged archives grew. The measurements in
scripts/readme_doctests.py and the archive rows of both READMEs now
match cargo package, which the Docs job checks.

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