Skip to content

fix(pptx): replay metafile gradients and source-free blits - #357

Open
dsaad68 wants to merge 1 commit into
openooxml:mainfrom
dsaad68:fix/pptx-metafile-gradient
Open

fix(pptx): replay metafile gradients and source-free blits#357
dsaad68 wants to merge 1 commit into
openooxml:mainfrom
dsaad68:fix/pptx-metafile-gradient

Conversation

@dsaad68

@dsaad68 dsaad68 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

TL;DR:

Before/After:

metafile gradients and blits

LibreOffice, before, after, on the committed fixture. LibreOffice's EMF import draws the
rectangle-mode gradient and the pattern blit but leaves triangle-mode gradients blank, so the top
half is empty in the reference pane and shaded in ours; PowerPoint shades it.

Repro file:
crates/pptx-render/tests/fixtures/metafile-gradient.pptx, added here with
metafile-gradient.md describing its records. Every corpus slide that reproduces the bug is a
third-party presentation and cannot be published, so the fixture is synthetic: one 200x200 unit EMF
holding a triangle-mode gradient, a rectangle-mode gradient, a PATCOPY blit and a DSTCOPY blit.

Summary:

  • Replay EMR_GRADIENTFILL as flat-shaded bands, one Primitive::Shape per band, for both
    rectangle modes and for Gouraud triangles. Colour is affine over a triangle, so the bands are
    slices taken perpendicular to the gradient of the widest-varying channel, each filled with the
    colour sampled at its own midpoint. Band count follows the colour spread and is capped at 64, so
    a two-level gradient costs two shapes.
  • Each band runs from its own edge to the far end of the shape rather than to the next band, so
    neighbouring bands overlap. Abutting bands leave an antialiased crack on every boundary; this was
    visible as white hairlines across the whole gradient before the change.
  • Replay EMR_BITBLT for the raster operations that name no source bitmap: DSTCOPY draws
    nothing, PATCOPY fills the destination with the selected brush, BLACKNESS and WHITENESS
    fill it with their constant. A blit that declares source bits, or any other ROP, still rejects
    the metafile — those genuinely need a raster the display list cannot carry.
  • Both records were previously unhandled, and an unhandled record rejects the whole part, so a
    single 100-byte no-op blit was deleting an otherwise fully supported picture. That is the actual
    user-visible defect; the gradients are the smaller half.
  • Bound everything a hostile file declares: vertex and shape counts are capped before any
    allocation, the record must be long enough for the arrays it declares, and an out-of-range vertex
    index rejects the record rather than being clamped. Malformed shapes are tested next to the happy
    path, as in the rest of the file.

Where the metric disagrees:

The change moves exactly one corpus slide, in a deck that cannot be named here: fine 14.51 → 14.20,
coarse 9.72 → 9.38, skipped_images 1 → 0. Its EMF was rejected for a single DSTCOPY record and
now draws a small figure icon that matches the reference. Every other slide in the two decks that
own the affected parts is byte-identical.

Nothing else moves, for two reasons worth stating plainly:

  • Eighteen of the corpus's 42 EMF parts are still rejected whole by clip and text records that this
    PR does not touch (75, 67, 30, 29, 28, 58, 82, 84). The one corpus part carrying GRADIENTFILL
    is among them, so no corpus slide shows a metafile gradient even after this change. The
    gradient half is carried by the fixture and the unit tests, not by the metric.
  • A second slide whose EMF now decodes is a picture with an a:blip/a:duotone, and
    render_picture sends any picture with blip effects down the Primitive::Image path, so it
    keeps being skipped. That is the pre-existing interaction between metafiles and blip effects, not
    a regression here.

Test plan:

  • cargo test -p betteroffice-pptx-render
  • cargo test -p betteroffice-pptx-edit (schema migrations, unaffected: no model change)
  • cargo clippy -p betteroffice-pptx-render --all-targets and cargo fmt --all -- --check
  • cargo check inside bindings/python-pptx (separate workspace)
  • Render crates/pptx-render/tests/fixtures/metafile-gradient.pptx and confirm
    skipped_images is 0 and both gradients are smooth
  • Re-render the corpus and confirm only the one affected slide moves

Closes #356

- GRADIENTFILL becomes flat-shaded bands, one display-list shape per band,
  for both rectangle modes and Gouraud triangles.
- BITBLT is honoured for the raster operations that name no source bitmap:
  PATCOPY paints the current brush, BLACKNESS and WHITENESS paint their
  constant, DSTCOPY draws nothing. Anything else still rejects the metafile.
- Vertex counts, index ranges and record sizes are bounded and checked, so a
  malformed record produces no geometry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@openooxml-bot

openooxml-bot Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA — thank you! ✍️ ✅

Posted by the CLA bot.

@greptile-apps

greptile-apps Bot commented Sep 7, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds bounded replay support for EMF gradient fills and source-free BITBLT operations, with a synthetic PPTX fixture and integration coverage.

  • Converts rectangle and triangle gradients into overlapping flat-shaded vector bands.
  • Handles PATCOPY, DSTCOPY, BLACKNESS, and WHITENESS when no source bitmap is present.
  • Rejects malformed counts, indexes, unsupported modes, source-bearing blits, and replay-budget overflow.
  • The triangle implementation currently loses color variation when a Gouraud field varies independently in two dimensions.

Confidence Score: 4/5

The PR should not merge until triangle gradients preserve color variation across both dimensions of a general Gouraud triangle.

The source-free blit, rectangle-gradient, validation, and budget paths are supported, but the triangle algorithm paints each dominant-axis slice with one color and therefore renders valid independently colored vertices incorrectly.

Files Needing Attention: crates/pptx-render/src/metafile.rs

Important Files Changed

Filename Overview
crates/pptx-render/src/metafile.rs Adds bounded GRADIENTFILL and source-free BITBLT replay, but triangle gradients reduce a generally two-dimensional Gouraud field to uniformly colored one-dimensional slices.
crates/pptx-render/tests/metafile_gradient.rs Verifies fixture-level gradient bands and PATCOPY output, although its triangle colors vary only in one dimension and therefore miss the general Gouraud case.
crates/pptx-render/tests/fixtures/metafile-gradient.md Documents the synthetic EMF records, expected primitive counts, and visual-reference limitations.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[EMF record] --> B{Record type}
    B -->|GRADIENTFILL| C[Validate mode, counts, arrays, and indexes]
    C --> D{Gradient mode}
    D -->|Rectangle| E[Emit overlapping horizontal or vertical bands]
    D -->|Triangle| F[Choose dominant channel axis]
    F --> G[Clip triangle into overlapping flat-color bands]
    B -->|BITBLT| H{Source bitmap declared?}
    H -->|Yes| I[Reject metafile]
    H -->|No| J{Raster operation}
    J -->|DSTCOPY| K[No operation]
    J -->|PATCOPY| L[Fill with selected brush]
    J -->|BLACKNESS or WHITENESS| M[Fill with constant color]
    J -->|Other| I
    E --> N[Display-list shapes]
    G --> N
    L --> N
    M --> N
Loading

Reviews (1): Last reviewed commit: "fix(pptx): replay metafile gradients and..." | Re-trigger Greptile

Comment on lines +1057 to +1059
let dominant = (0..3)
.max_by(|a, b| spread(*a).total_cmp(&spread(*b)))
.unwrap_or(0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Triangle gradients lose variation

A valid Gouraud triangle can have independent colors at all three corners, such as red, green, and blue. This code chooses one color channel as the slicing axis and assigns one uniform sampled color to each complete slice. Color variation along each slice is therefore erased, so these general two-dimensional gradients render incorrectly. The added fixture varies color only along the slicing direction and does not cover this case.

Knowledge Base Used: PPTX rendering and WebAssembly

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@dsaad68

dsaad68 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Triangle gradients lose variation — correct, and it is an approximation rather than a bug I can
close here.

You have read it right. shade_triangle picks the dominant channel's gradient direction, slices
perpendicular to it, and fills each slice with one colour sampled at its midpoint. Variation
along a slice is erased, so a red/green/blue triangle renders as bands rather than a true Gouraud
surface. And you are right that the fixture does not cover it: its triangles vary along the slicing
direction, which is the easy case.

The constraint is the display list. MetafileOp carries filled paths and strokes, not per-vertex
colour, so a real Gouraud fill has nowhere to live without a new primitive — and that primitive
would have to be honoured by tiny-skia, the canvas backend and the native viewer before it drew
anything. Subdividing the triangle into a mesh fine enough to hide the error is the other option,
and it multiplies the op count on a path that already has a per-slide budget.

What made banding the right trade for this change specifically: every GRADIENTFILL in the
corpus is a two-triangle rectangle with colour varying along one axis
, which is the case bands
reproduce exactly. The general case is unreachable from any deck I can measure.

So I have left it, and written it up rather than left it implied — the pull request body and the
fixture's .md both say bands, not Gouraud. I have added your three-corner case to the cluster
notes as the thing that would justify a mesh primitive, so whoever picks that up has the failing
shape ready. If you would rather this not ship until it is exact, say so and I will pull the
GRADIENTFILL arm and land only the blit half — which is where all the corpus movement is anyway.

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.

pptx: one unhandled EMF record discards the whole picture, so a source-free blit deletes the drawing

1 participant