Skip to content

Paint slide backgrounds in PDF output and open gradients without ang or path - #175

Open
hadim wants to merge 3 commits into
tensorbee:mainfrom
hadim:fix/rpptx-pdf-backgrounds-and-optional-gradient-attributes
Open

hadim wants to merge 3 commits into
tensorbee:mainfrom
hadim:fix/rpptx-pdf-backgrounds-and-optional-gradient-attributes

Conversation

@hadim

@hadim hadim commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

Summary

  • oxml-drawing: a:lin/@ang and a:path/@path become optional on read, as
    ECMA-376 declares them. A missing angle reads as 0 and a missing path as
    rect. A private flag on LinearGradient and PathGradient records that
    the source omitted the attribute, and the writer then leaves out a value
    still equal to that default, so <a:lin scaled="0"/> and
    <a:path><a:fillToRect .../></a:path> round trip unchanged. No public type
    changes.
  • oxml-pdf: the PDF writer paints PageFrame::background under the page
    content. It fills the page rectangle through the same resolve_paint and
    set_fill_paint path a shape fill uses, so solid, linear and radial paint
    are covered, including a translucent solid.
  • Archive rows of oxml-drawing, oxml-pdf and rpptx re-recorded, since
    each package grows. Each crate is added to ARCHIVE_REMEASUREMENT_DATES
    and its README row carries the re-measure date.

Closes #170

Why

A deck made by python-pptx with fill.gradient() could not be opened. python-pptx
writes <a:lin scaled="0"/> for every gradient, and oxml-drawing parsed
@ang (and @path on a:path) as required, so Presentation::from_bytes
refused the whole package with "DrawingML lin requires @ang".

Separately, a slide background given as a fill, on the slide, its layout or its
master, was drawn by the PNG export and missing from to_pdf() and
rpptx convert --to pdf. rpptx-render lowers it into PageFrame::background,
the rasteriser paints that field, and the PDF writer never read it. White text
on a coloured title slide vanished from the PDF. Picture backgrounds were not
affected because they are lowered as page elements.

Notes

  • API impact: none. LinearGradient::angle stays Angle and
    PathGradient::kind stays PathGradientKind. The two new fields are
    private, and neither struct can be built with a struct literal outside the
    crate (both already carry a private raw_children), so the change is
    invisible through oxml-drawing, through rpptx::Fill and through
    rdocx::CT_OfficeStyleSheet and Document::theme(). A patch release of
    each family can carry it. Values built in code are unaffected:
    LinearGradient::default() still writes <a:lin ang="0"/>. The writer
    leaves an attribute out only when the source omitted it and the value still
    equals the default that every reader assumes for a missing attribute.
    Equality compares the flag, so <a:lin/> and <a:lin ang="0"/> parse to
    unequal values, as two fills that differ only in preserved raw children
    already do.
  • Defaults for the missing attributes. ECMA-376 gives neither attribute a
    default. I checked LibreOffice's oox import at master
    (oox/source/drawingml/misccontexts.cxx and fillproperties.cxx). It reads
    a missing @ang as 0 (moShadeAngle.value_or(0)), and it reads an
    a:path without @path as rect
    (rAttribs.getToken(XML_path, XML_rect), with the comment "always set a
    path type, this disables linear gradient in conversion"). So a missing
    @path does not fall back to a linear gradient there. oxml-drawing
    follows both rules, and the field docs say so. rpptx-layout needs no
    change. Because rpptx does not render path gradients yet, a missing path
    produces the existing "rectangle path gradient" unsupported diagnostic and
    the page keeps its white default, exactly as path="rect" does. The file
    now opens and every other part of the slide renders.
  • Theme side effect in rdocx and rpptx. The same parser reads the fill lists
    of a theme's a:fmtScheme. rdocx reads its theme with
    CT_OfficeStyleSheet::from_xml(..).ok(), so a docx whose theme carried
    such a gradient used to get no theme at all: layout fell back to the
    default theme fonts and colours, Document::theme() returned None, and
    ensure_authored_chart_theme replaced the theme with the Office default
    when a chart was authored. All three now use the author's theme. rpptx's
    render paths returned an error for such a theme and now render. This is the
    intended direction, but rdocx output changes for these inputs. No hash
    harness sample has such a theme, so the harness does not move.
  • The range of @ang (ST_PositiveFixedAngle, 0 to 21600000) is still not
    validated. That is unchanged and out of scope.
  • The background is registered in the gradient registry under a new private
    GradientTarget::Background at leaf 0 of its page, with the page flip as the
    pattern matrix, and a translucent solid background registers its alpha
    state. On a page without a background nothing is registered or emitted, so
    those PDFs are byte-identical. rdocx never sets the field, and
    hash_harness.py --check reports all 49 entries matching.
  • Slide PDFs change for nearly every deck. Most slide masters carry a
    p:bg, usually a bg1 fill, so their PDF pages now start with a full page
    fill, as their PNG export already did. It is white in most themes.
  • Notes and handout PDFs change too. render_export_surface clears the shell
    layout and slide master backgrounds, but the page keeps the p:bg of its
    notes or handout master. The bundled default notes master has
    <p:bgRef idx="1001"><a:schemeClr val="bg1"/></p:bgRef>, so every notes
    page PDF now starts with a full page bg1 fill, as its PNG export already
    did. It is usually white, and a theme with a non-white lt1 now shows its
    colour in the PDF as well.
  • Paint::Tile backgrounds stay unpainted in PDF, as tile fills of shapes
    already are, and as the rasteriser leaves them. rpptx never produces one,
    since picture backgrounds are lowered as page elements. PDF/A preflight
    already rejects a tile background.
  • On a tagged page (LayoutResult::structure present) the background fill is
    wrapped in /Artifact BMC ... EMC, as the HLD asks of decorative paint. No
    producer sets both today (rdocx tags but has no page background, rpptx has
    backgrounds but no structure), so this only keeps the shared writer correct
    for PDF/UA. Tell me if you prefer to drop it.
  • No rpptx PDF digests or golden baselines move. The recorded PDF SHA-256
    values in crates/rpptx/tests/integration.rs belong to PowerPoint oracle
    files, not to rpptx output, and golden_png_harness.py and
    pptx_ssim_harness.py compare rasters. Rust PDFs of decks with a fill
    background (for example the F-116 cross-viewer deck, which sets a green
    slide background) now carry the fill, and no test pins those bytes.
  • The HLD PDF backend section in 08-rendering-spec.md does not mention page
    backgrounds, and nothing in it becomes false, so I left it alone. A sentence
    there may be worth adding in your sprint.

Tests

Added:

  • oxml-drawing fill::tests::gradient_geometry_without_angle_or_path_round_trips_without_adding_them:
    <a:lin scaled="0"/>, <a:lin/>, <a:path/> and <a:path> with a
    fillToRect write back byte for byte. The parsed values are Angle(0) and
    PathGradientKind::Rectangle, and assigning Angle(5400000) or Circle to
    them writes the attribute. ang="0" and path="rect" in the source are
    kept, and LinearGradient::default() still writes ang="0".
  • oxml-pdf writer tests: solid_page_background_fills_the_page_before_its_content,
    gradient_page_background_fills_the_page_with_its_pattern_first (linear and
    radial, content order, page pattern resource and /Matrix),
    translucent_page_background_selects_a_registered_alpha_state,
    tile_page_background_leaves_the_content_unchanged and
    tagged_page_background_is_an_artifact. The test helper content_for gains
    a content_for_page variant that takes a page and an optional structure.
  • rpptx integration tests:
    gradient_backgrounds_without_angle_or_path_open_render_and_round_trip
    opens a deck whose slide background is <a:lin scaled="0"/>, checks the
    raster is a left to right red to blue gradient, and checks the saved slide
    keeps <a:lin scaled="0"/>. It then decodes to_pdf_deterministic() with
    lopdf and checks that the page content starts with
    q cm q cs scn re f Q, and that the named page pattern's axial shading has
    a horizontal, left to right axis from [1 0 0] to [0 0 1]. This ties the
    paint that rpptx-layout resolves to the PDF pattern. The same deck with an
    a:path without @path checks the diagnostic and the saved XML.
    solid_backgrounds_from_slide_layout_and_master_reach_pdf_and_png puts a
    7B1E3A solid background on the slide, the layouts or the master, and checks
    that the PNG corner is that colour and that the PDF page content starts with
    q cm q rg re f Q where rg is that colour and re is the MediaBox. Both
    decode the PDF with lopdf, so they need no Poppler.

Without the fixes, both rpptx tests fail (with "DrawingML lin requires @ang",
and with a page content of only q cm Q), the oxml-drawing test fails, and
four of the five writer tests fail. The tile test pins output that does not
change. With the writer fix and without the emit_background call, the new
gradient PDF assertion fails on q cm Q.

The issue's two Python reproductions, run against rpptx-py rebuilt with
maturin develop from this branch, now print:

background on the slide   PNG corner (123, 30, 58)   PDF corner (123, 30, 58)
background on the layout  PNG corner (123, 30, 58)   PDF corner (123, 30, 58)
background on the master  PNG corner (123, 30, 58)   PDF corner (123, 30, 58)
a:lin without ang    opens, PNG left (122, 31, 58) right (63, 73, 89), saved <a:lin scaled="0"/>
a:lin with ang       opens, PNG left (92, 52, 74) right (92, 52, 74), saved <a:lin ang="5400000" scaled="0"/>
a:path without path  opens, PNG left (255, 255, 255) right (255, 255, 255), saved <a:path><a:fillToRect .../></a:path>
a:path with path     opens, PNG left (255, 255, 255) right (255, 255, 255), saved <a:path path="circle"><a:fillToRect .../></a:path>

The two path variants render the white default because path gradients are
not rendered yet, with or without @path.

Run:

  • cargo fmt --all --check: clean.
  • cargo clippy -p oxml-drawing -p oxml-pdf -p rpptx -p rpptx-layout --all-targets --all-features -- -D warnings: clean.
  • cargo test --no-fail-fast for oxml-drawing, oxml-pdf, oxml-chart,
    rpptx-oxml, rpptx-layout, rpptx-render, rpptx-cli, rdocx-oxml,
    rdocx-layout, rdocx-pdf, rdocx-cli, rpptx and rdocx: everything
    passes except the environment-only failures listed below.
  • cargo doc --no-deps --all-features of oxml-drawing, oxml-pdf and
    rpptx with RUSTDOCFLAGS=-D warnings: clean.
  • cargo check --target wasm32-unknown-unknown -p rdocx-wasm -p rpptx-wasm:
    clean.
  • python3 scripts/hash_harness.py --check: 49 entries match.
  • python3 scripts/readme_doctests.py: passes with the re-recorded rows and
    dates. The rpptx-layout row is unchanged and still matches its package.
  • python3 scripts/prose_check.py and python3 scripts/sync_agent_skills.py --check: clean.
  • pytest crates/rpptx-py/tests against the rebuilt extension: 40 passed. No
    binding or stub changed.

Environment-only failures seen on this Mac:

  • oxml-pdf writer::tests::rotated_linear_gradient_renders_with_its_axis_rotated
    (pinned pdftoppm 26.01, local 26.09).
  • rpptx-layout all_corpus_preset_geometries_evaluate_or_fallback,
    all_corpus_slides_resolve_without_panics and
    corpus_slide_resolves_without_theme_references, and rpptx-cli
    validate_rejects_corruption_and_accepts_the_pinned_corpus (gitignored
    /corpus/pptx missing).
  • rdocx lib large_word_and_presentation_pdfs_preserve_logical_reading_order
    and word_and_powerpoint_chart_pixels_are_identical (pinned Poppler), and
    integration odt_reader_matches_pinned_libreoffice_structure,
    public_authored_theme_and_fonts_match_pinned_word_resolution and
    sanitized_public_authoring_fixture_passes_every_conformance_stage.
  • rdocx integration
    f269_section_page_semantics::section_page_semantics_match_pinned_libreoffice_render
    and f267_table_style_conditional_tests::every_conditional_table_region_matches_word.
    Both stop on their first assertion, which compares soffice --version
    (local LibreOffice 26.8.0.3) with the pinned 26.2.5.2. They fail the same
    way on origin/main.

ECMA-376 makes CT_LinearShadeProperties/@ang and
CT_PathShadeProperties/@path optional, and python-pptx writes
<a:lin scaled="0"/> for every fill.gradient(). oxml-drawing parsed both
as required, so a deck carrying such a gradient failed to open at all,
not only to render that fill.

A missing angle now reads as 0 and a missing path as rect, as
LibreOffice's oox import does, so the public angle and kind fields keep
their types. A private flag records that the source omitted the
attribute, and the writer leaves out a value still equal to that
default, so a round trip does not add it. Path gradients stay
unsupported in the rpptx resolver, so a missing path now reports the
rectangle path gradient diagnostic instead of refusing the file.

The same parser reads the fill lists of a theme's format scheme. rdocx
therefore stops dropping a theme that carries such a gradient, or
replacing it with the Office default when it authors a chart.

GitHub issue tensorbee#170.
rpptx-render lowers a slide, layout or master background fill into
PageFrame::background, and the rasteriser paints it, but the PDF writer
never read the field. to_pdf() and rpptx convert --to pdf therefore
wrote a white page where the PNG export draws the authored colour, and
white text on a coloured slide vanished from the PDF.

The writer now fills the page rectangle with the background before any
element, through the resolve_paint and set_fill_paint path a shape fill
uses. A gradient background registers its pattern with the page flip as
its matrix, and a translucent solid registers its alpha state. Tile
paint stays unpainted, as it does for shapes. On a tagged page the fill
is marked as an artifact. Notes and handout PDFs now also paint the
background of their notes or handout master, as their PNG export
already did. A page without a background writes the same bytes as
before, so rdocx output and the hash harness do not move.

GitHub issue tensorbee#170.
The two fixes grow the oxml-drawing, oxml-pdf and rpptx packages, so
each README archive row and its ARCHIVE_MEASUREMENTS entry in
scripts/readme_doctests.py are re-measured from cargo package. Each
crate is listed in ARCHIVE_REMEASUREMENT_DATES with the re-measure date,
and its README row carries that date.

GitHub issue tensorbee#170.

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.

rpptx: slide backgrounds and gradients: the PDF export drops fill backgrounds, and a gradient without ang or path refuses the file

1 participant