Skip to content

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

Description

@hadim

Two defects on fills, met while building the umbrella's deck fixture (#158).

1. The PDF export drops solid slide backgrounds; the PNG export draws them

A slide background given as a fill (p:bg/p:bgPr), on the slide, its layout or
its master, is drawn in render_slide_to_png and rpptx convert --to png, and missing from to_pdf() and
rpptx convert --to pdf: the PDF page is white. White text on a coloured title slide then disappears from
the PDF. The umbrella's deck fixture shows it on its first slide, and a background set from Python through
Slide.background.fill (#142) is missing from the PDF the same way.

The render lowers the fill into PageFrame.background (rpptx-render/src/lib.rs:546). The rasteriser paints
it (oxml-pdf/src/raster.rs:294); the PDF writer (oxml-pdf/src/writer.rs) never reads the field. Picture
backgrounds are lowered as page elements instead, so they reach the PDF.

import io, subprocess
import rpptx
from pptx import Presentation
from pptx.dml.color import RGBColor
from PIL import Image


def build(path, where):
    prs = Presentation()
    layout = prs.slide_layouts[6]
    slide = prs.slides.add_slide(layout)
    owner = {"slide": slide, "layout": layout, "master": prs.slide_master}[where]
    owner.background.fill.solid()
    owner.background.fill.fore_color.rgb = RGBColor(0x7B, 0x1E, 0x3A)
    prs.save(path)


def corner(png):
    return Image.open(io.BytesIO(png)).convert("RGB").getpixel((5, 5))


for where in ("slide", "layout", "master"):
    build(f"bg_{where}.pptx", where)
    prs = rpptx.Presentation(f"bg_{where}.pptx")
    png = prs.render_slide_to_png(0, 20)
    open(f"bg_{where}.pdf", "wb").write(prs.to_pdf())
    pdf_png = subprocess.run(["pdftoppm", "-r", "20", "-png", "-singlefile", f"bg_{where}.pdf"], capture_output=True).stdout
    print(f"background on the {where:<6}  PNG corner {corner(png)}   PDF corner {corner(pdf_png)}")
background on the slide   PNG corner (123, 30, 58)   PDF corner (255, 255, 255)
background on the layout  PNG corner (123, 30, 58)   PDF corner (255, 255, 255)
background on the master  PNG corner (123, 30, 58)   PDF corner (255, 255, 255)

(pdftoppm from poppler rasterises the PDF.) Acceptance: the PDF writer paints PageFrame.background
under the page content, for every paint the field can hold, and a test asserts that PDF and PNG agree on
it.
The writer is shared, so any other producer that sets the field gets the same fix.

2. A gradient whose a:lin has no ang, or whose a:path has no path, refuses the whole file

In ECMA-376 both attributes are optional (CT_LinearShadeProperties/@ang and CT_PathShadeProperties/@path,
use="optional"), and python-pptx writes <a:lin scaled="0"/> for every fill.gradient(). rpptx parses
them as required (oxml-drawing/src/fill.rs:1039 and 1055), so opening the file fails, not only the
rendering of that fill. Of the required_* calls of oxml-drawing I checked against the schema, these two
are the only ones on an optional attribute.

import zipfile
import rpptx
from pptx import Presentation
from pptx.dml.color import RGBColor

prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[6])
slide.background.fill.gradient()          # python-pptx writes <a:lin scaled="0"/>, without ang
slide.background.fill.gradient_stops[0].color.rgb = RGBColor(0x7B, 0x1E, 0x3A)
slide.background.fill.gradient_stops[1].color.rgb = RGBColor(0x3E, 0x4A, 0x59)
prs.save("grad.pptx")


def variant(name, old, new):
    src = zipfile.ZipFile("grad.pptx")
    with zipfile.ZipFile(name, "w", zipfile.ZIP_DEFLATED) as z:
        for info in src.infolist():
            data = src.read(info.filename)
            if info.filename == "ppt/slides/slide1.xml":
                data = data.replace(old.encode(), new.encode())
            z.writestr(info, data)
    try:
        rpptx.Presentation(name)
        return "opens"
    except Exception as e:
        return f"{type(e).__name__}: {e}"


LIN = '<a:lin scaled="0"/>'
print("a:lin without ang   ", variant("g1.pptx", LIN, LIN))
print("a:lin with ang      ", variant("g2.pptx", LIN, '<a:lin ang="5400000" scaled="0"/>'))
print("a:path without path ", variant("g3.pptx", LIN, '<a:path><a:fillToRect l="50000" t="50000" r="50000" b="50000"/></a:path>'))
print("a:path with path    ", variant("g4.pptx", LIN, '<a:path path="circle"><a:fillToRect l="50000" t="50000" r="50000" b="50000"/></a:path>'))
a:lin without ang    XmlError: malformed PresentationML part /ppt/slides/slide1.xml: invalid value: DrawingML lin requires @ang
a:lin with ang       opens
a:path without path  XmlError: malformed PresentationML part /ppt/slides/slide1.xml: invalid value: DrawingML path requires @path
a:path with path     opens

Acceptance: both files open and render, and a round trip does not add the missing attribute.

Environment: main at 9a7ed714 (S75), release build, linux x86_64, Python 3.11, python-pptx 1.0.2 for the
fixtures.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions