Skip to content

fix(pptx): apply a blip's a:lum brightness and contrast - #352

Merged
eliahilse merged 4 commits into
openooxml:mainfrom
dsaad68:fix/pptx-blip-lum
Sep 8, 2026
Merged

fix(pptx): apply a blip's a:lum brightness and contrast#352
eliahilse merged 4 commits into
openooxml:mainfrom
dsaad68:fix/pptx-blip-lum

Conversation

@dsaad68

@dsaad68 dsaad68 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

TL;DR:

Before/After:

blip-lum

LibreOffice (reference), main, and this branch rendering
crates/pptx-render/tests/fixtures/blip-lum.pptx. The five rows are the control, PowerPoint's
Washout pair (bright="70000" contrast="-70000"), brightness only, contrast only, and the
bright="3000" contrast="77000" pair the corpus also carries. On main every row is the control.
The same two images are committed as blip-lum.before.png and blip-lum.after.png.

Repro file:
crates/pptx-render/tests/fixtures/blip-lum.pptx, added here. It is synthetic and publishable: one
six-swatch bitmap drawn five times, once per a:lum setting, with the sample coordinates and their
expected colours written down in blip-lum.md next to it. Every corpus deck that reproduces this
is a third-party presentation and is not publishable.

Summary:

  • Parse a:blip's a:lum into a new BlipEffect::Luminance { brightness, contrast }, in document
    order with the effects fix(pptx): apply a blip's duotone, biLevel and colour-change effects #312 added, and resolve it into ImageEffect::Luminance in the layout
    pass. bright and contrast are ST_FixedPercentage, so they need a signed reader; the existing
    percentage_attribute rejects negatives, and the corpus is full of contrast="-70000".

  • Apply it in both backends as a 256-entry ramp per channel, alpha untouched, over straight alpha
    before the premultiply, exactly where the other effects run.

  • The curve is measured, not read off a spec. ECMA-376 and MS-OI29500 both say only "the
    percentage to change the brightness/contrast". It was fitted to LibreOffice 26.8 by rendering 121
    bright × contrast pairs over a 16-step grey ramp through pptx-pdf and reading the pixels:

    slope  = c >= 0 ? 128 / (128 - 127c) : (128 + 127c) / 128
    offset = 128 - 128 * slope + 255b * (1 + slope) / 2
    

    with b, c fractions in -1..=1. That reproduces the reference to within one level on every
    pair sampled, per channel, with one exception below.

  • Clamp both attributes to -100 %..=100 % at parse and again in layout. contrast = 100 % is the
    pole of the slope expression; past it the ramp inverts.

  • Pin the identical sample points in a Rust unit test and a bun test so the two backends cannot
    drift, a parse test for the signed percentages, a layout test for the clamp, and an end-to-end
    pixel test over the new fixture.

Where this deliberately differs from the reference:

bright="70000" contrast="-70000" — exactly, and only, that pair — is the one place the formula
disagrees with LibreOffice, by 10–11 levels (black lands on CDCDCD here and D8D8D8 there).
LibreOffice special-cases MSO's Washout preset into its own watermark colour mode: its immediate
neighbours (69 %/−70 %, 71 %/−70 %, 70 %/−69 %, 70 %/−71 %) all follow the formula above, so this
is a LibreOffice compatibility shim rather than the meaning of the element, and reproducing it
would bake one renderer's quirk into the contract. The gap is far inside the harness's 40-level
per-pixel threshold and no measured slide changes verdict because of it.

Measured, whole decks re-rendered against the LibreOffice reference, baseline built from this same
worktree with the commit reverted:

deck slides mean before mean after delta
a 32-slide deck whose masters carry duotone + lum 32 77.48 61.58 −15.90
a 23-slide deck with three lum blips 23 3.98 3.97 −0.02

Every one of the first deck's 32 slides improves, by −8.80 to −19.40; the decorative strip its
masters draw goes from a solid orange band to the near-white the reference draws. In the second
deck the two slides that carry a slide-level lum move −0.16 and −0.19 and nothing else moves; its
third occurrence is on a layout whose media is an .emf, which the decoder skips before any effect
could apply, so that slide is byte-identical. No slide in either deck moved the wrong way. Neither
deck is publishable, which is why the screenshots above are of the committed fixture.

No deck schema bump: a:lum adds no key. A snapshot built on main from a lum deck stores no
blipEffectsJson for those pictures, so the existing schema-17 backfill restores them the first
time the source is reattached, and the restored snapshot equals a fresh one.

Test plan:

  • cargo fmt --all --check and cargo clippy --workspace --all-targets -- -D warnings.
  • cargo test on betteroffice-pptx-parse, betteroffice-pptx-render, betteroffice-pptx-raster, betteroffice-pptx-edit and betteroffice-pptx --features raster: 463 passed.
  • cargo check --manifest-path apps/native-viewer/Cargo.toml --no-default-features --features pptx and cargo check in bindings/python-pptx, both separate workspaces.
  • Fresh bun run build:pptx-wasm (each .wasm starts 0061736d), bun run typecheck:packages, and absolute-path bun test on packages/pptx and packages/pptx-react: 186 passed.
  • The Rust and TypeScript ramp tests assert the same nine input/output triples.
  • All 43 tracked PPTX fixtures / 112 slides serialised on origin/main and on this branch with the same fonts: 111 slides byte-identical, and the one differing slide differs only by the four new Luminance effects.
  • Repro measured, not asserted: on origin/main all five fixture rows render as the control (000000, 03A7DF, …); on this branch they render CDCDCD/E1E1E1/CEFFFF, 808080, 404040/C0C0C0/4194B0 and 949494, matching blip-lum.md.
  • Six mutations, each red then green: dropping the lum parse arm; dropping the parse clamp; perturbing either branch of the Rust slope; dropping the layout clamp; returning None from the layout arm; perturbing the TypeScript slope.
  • A schema-20 snapshot built on origin/main from the fixture carries no bitmap effects, gains exactly the four Luminance effects when its source is reattached on this branch, equals a fresh snapshot, and saves byte-identically per ZIP part.
  • Exact CI demo-seed check: create-demo-deck, create-demo-workbook, check:seeds, and a clean git diff on both committed assets.

Closes #351

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 parses DrawingML a:lum brightness and contrast and applies a matched per-channel luminance ramp in the Rust and Canvas rendering backends.

  • Adds signed percentage parsing and luminance variants to the parse and display-list models.
  • Implements matching Rust and TypeScript pixel transforms while preserving alpha and effect order.
  • Adds unit, end-to-end pixel, fixture, and visual-reference coverage.
  • Luminance remains unhandled for blips used as ordinary shape picture fills.

Confidence Score: 4/5

The PR should not merge until luminance effects are also preserved for ordinary shape picture fills.

The new transform is consistently implemented in both rendering backends, but the parser and layout pipeline silently drop the same a:lum effect when its blip is carried by a shape's a:blipFill.

Files Needing Attention: crates/pptx-parse/src/drawing.rs, crates/pptx-parse/src/model.rs, crates/pptx-render/src/layout.rs

Important Files Changed

Filename Overview
crates/pptx-parse/src/drawing.rs Parses signed luminance attributes for pictures, but does not preserve them for ordinary shape picture fills.
crates/pptx-parse/src/model.rs Adds the unresolved luminance effect variant; PictureFill still cannot carry effects.
crates/pptx-render/src/layout.rs Resolves luminance values into display-list effects, while shape picture fills continue to emit an empty effect list.
crates/pptx-render/src/image_effects.rs Applies the bounded luminance ramp per RGB channel while preserving alpha.
packages/pptx/src/render/canvas.ts Implements the corresponding Canvas luminance ramp within the existing private-surface recolouring pipeline.
crates/betteroffice-pptx/tests/render.rs Verifies parsing-to-raster behavior and representative output pixels for p:pic images.
packages/pptx/src/render/canvas.test.ts Pins the TypeScript luminance ramp to the same representative values as the Rust backend.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  XML["DrawingML a:blip / a:lum"] --> Parse["PPTX parser"]
  Parse --> Pic["p:pic effects"]
  Parse -. "effects currently discarded" .-> Fill["shape a:blipFill"]
  Pic --> Layout["ImageEffect::Luminance"]
  Layout --> Rust["Rust raster ramp"]
  Layout --> JSON["Display-list JSON"]
  JSON --> Canvas["Canvas ramp"]
  Rust --> Output["Rendered image"]
  Canvas --> Output
Loading

Reviews (1): Last reviewed commit: "fix(pptx): apply a blip's a:lum brightne..." | Re-trigger Greptile

Comment on lines +234 to +237
"lum" => Some(BlipEffect::Luminance {
brightness: fixed_percentage_attribute(child, "bright").unwrap_or(0.0),
contrast: fixed_percentage_attribute(child, "contrast").unwrap_or(0.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 Shape fills drop luminance

a:lum is parsed only for p:pic nodes. When it appears in an ordinary shape's stretched a:blipFill, parse_picture_fill ignores the blip's child effects and picture_filled emits an empty effect list. As a result, that image renders without its specified brightness and contrast. Preserve these effects in PictureFill and pass them through the layout path.

@dsaad68

dsaad68 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Shape fills drop luminance — the reading is right, the reach is nil.

Confirmed structurally: parse_blip_effects is called only from the p:pic path, PictureFill
carries crop and fill_rect but no effects, so a shape whose a:blipFill has an a:lum gets
none. A picture-filled shape and a p:pic with the same blip render differently, which is an
inconsistency this pull request introduces rather than inherits — fair.

What I measured before deciding: across all 48 decks, zero p:sp carries a blipFill with an
a:lum inside it. So threading it through moves no pixel anywhere in the corpus and would need a
synthetic fixture to test at all.

I have left it out rather than widen a mergeable change on reasoning alone, and recorded it as its
own cluster with that measurement attached. It is a small change — effects on PictureFill, then
through picture_filled the way #336 threads crop — so it is cheap to take on its own once
something exercises it. Say the word if you would rather it went in here.

eliahilse and others added 2 commits September 8, 2026 20:17
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@eliahilse
eliahilse merged commit abb1e2c into openooxml:main Sep 8, 2026
9 checks 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.

pptx: a:lum brightness and contrast are dropped, so recoloured pictures keep their full strength

2 participants