Skip to content

Phase 6: Chart quick layouts, SVG support, theme-aware color inheritance, and rendering#15

Merged
CodeHalwell merged 4 commits intomasterfrom
claude/python-pptx-roadmap-tasks-J1jQp
Apr 28, 2026
Merged

Phase 6: Chart quick layouts, SVG support, theme-aware color inheritance, and rendering#15
CodeHalwell merged 4 commits intomasterfrom
claude/python-pptx-roadmap-tasks-J1jQp

Conversation

@CodeHalwell
Copy link
Copy Markdown
Owner

Summary

This PR implements Phase 6 features: chart quick layouts (PowerPoint's gallery presets), native SVG picture embedding with PNG fallbacks, theme-aware color inheritance helpers, slide thumbnail rendering via headless LibreOffice, chart color palettes, and platform support improvements.

refs ROADMAP.md Phase 6

Changes

Chart Quick Layouts

  • New pptx.chart.quick_layouts module with 10 named layout presets mirroring PowerPoint's Chart Design → Quick Layout gallery
  • Chart.apply_quick_layout(layout) and apply_quick_layout(chart, layout) functional form
  • Layouts control title, legend (with position), axis titles, and gridlines visibility
  • Composable: calling apply_quick_layout twice merges specs

Native SVG Support

  • New ShapeTree.add_svg_picture() embeds SVG with PNG fallback (Office 2016+ compatible)
  • Modern PowerPoint requires both <a:blip> (PNG) and <asvg:svgBlip> (SVG) in the same element
  • Optional cairosvg dependency for automatic rasterization; callers can supply their own PNG fallback
  • New pptx._svg module with SVG detection, blob loading, and OOXML element rewriting
  • Clear CairoSvgUnavailable error when rasterization is needed but dependency missing

Theme-Aware Color Inheritance

  • New pptx.inherit.resolve_color(color_format, theme=...) returns effective RGBColor for any ColorFormat
  • Resolves explicit RGB colors as-is, scheme colors through theme.colors[…], unset colors return None
  • Applies brightness adjustment by blending toward white/black, mirroring PowerPoint's lumMod/lumOff model
  • Non-mutating: doesn't walk placeholder hierarchy or touch XML

Slide Thumbnail Rendering

  • New pptx.render module for headless LibreOffice rendering
  • render_slide_thumbnails(prs, ...) and convenience methods on Presentation / Slide
  • Returns PNG paths or bytes; supports filtering by slide index
  • ThumbnailRendererUnavailable with actionable install hints when soffice missing
  • Configurable via POWER_PPTX_SOFFICE environment variable or explicit binary path

Chart Color Palettes

  • New pptx.chart.palettes module with six built-in palettes: modern, classic, editorial, vibrant, monochrome_blue, monochrome_warm
  • Chart.apply_palette(palette) recolors series independently of chart_style
  • Accepts palette names or iterables of color-likes; wraps when more series than colors
  • Per-series gradient and pattern fills work through chart.series[i].format.fill

Platform Support

  • Linux font directory support in FontFiles._linux_font_directories()
  • Graceful degradation on unrecognized platforms (returns empty sequence instead of raising)
  • Pillow's bundled default font fallback in text layout for systems without requested family

Composition Package

  • New pptx.compose package re-exports from_spec, import_slide, apply_template for unified public surface

Checklist

  • Code for the change itself
  • Unit tests covering new behavior (test_quick_layouts.py, test_svg_picture.py, test_render.py, test_palettes.py, test_inherit.py)
  • Integration tests (test_compose_package.py, test_round_trip.py updates)
  • HISTORY.rst entries under Phase 6 sections
  • ROADMAP.md updated with completion markers
  • No new pyright errors

Test Notes

New modules are covered by dedicated test suites:

  • tests/chart/test_quick_layouts.py: Layout application, composition, axis title handling
  • `tests/test

https://claude.ai/code/session_01ELm2Sv3FFC1QvwWzZEkQCb

claude added 2 commits April 28, 2026 20:47
Phase 10:
- Chart.apply_palette(palette) recolors series independently of
  chart_style, accepting a named built-in preset or any iterable of
  RGBColor / hex string / (r,g,b) values; palette wraps when the chart
  has more series than colors.
- New pptx.chart.palettes module ships six curated palettes (modern,
  classic, editorial, vibrant, monochrome_blue, monochrome_warm).
- Per-series gradient/pattern fills via ChartFormat are now covered by
  regression tests (the underlying FillFormat already supports them).

Phase 6:
- TextFrame.fit_text() works on Linux and on minimal runtimes without
  the requested font installed. FontFiles._font_directories enumerates
  /usr/share/fonts, /usr/local/share/fonts, /usr/share/fonts/truetype,
  ~/.fonts, and ~/.local/share/fonts; unknown platforms return [] now
  rather than raising OSError. _best_fit_font_size falls back to
  ImageFont.load_default(size=...) when no matching system font is
  found, so a fit_text() call with no font_file= argument produces a
  usable size on bare runtimes (CI, serverless, minimal Docker images).
… theme-aware color, SVG)

Phase 6:
- Theme-aware color inheritance: pptx.inherit.resolve_color(color_format,
  theme=...) returns the effective RGBColor for any ColorFormat. Explicit
  RGB → returned with brightness blend; scheme color → resolved through
  theme.colors[…]; unset → None (no XML mutation).
- Native SVG in add_picture: slide.shapes.add_svg_picture() embeds both
  an <asvg:svgBlip> and a PNG fallback in the same blip. Optional
  cairosvg dep rasterises when no fallback is supplied; image/svg+xml
  is registered as a first-class image content type.

Phase 7:
- pptx.compose is now a real package re-exporting from_spec,
  import_slide, and apply_template from a single import path.

Phase 10:
- Chart.apply_quick_layout(layout) ships ten built-in presets mirroring
  PowerPoint's gallery; missing spec keys leave the chart untouched so
  layouts compose cleanly. Pie/doughnut charts skip axis-only keys.
- pptx.render module + Presentation.render_thumbnails() /
  Slide.render_thumbnail() drive headless `soffice --convert-to png`.
  Raises ThumbnailRendererUnavailable with an install hint when
  LibreOffice isn't on PATH; ThumbnailRendererError on conversion fail.

Roadmap updated; documentation site rebuild explicitly deferred (large
dedicated effort outside the per-feature shipping cadence). Phase 11
(2.0 removals) intentionally remains 2.0 work — items kept on the
deprecation list for visibility but cannot be checked off on 1.x.

3103 tests pass.
Copilot AI review requested due to automatic review settings April 28, 2026 21:05
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces several major features, including native SVG support with PNG fallbacks, chart palette presets, chart 'quick layouts', and a slide-thumbnail renderer driven by headless LibreOffice. It also enhances the text-fit estimator with Linux font directory support and a fallback to Pillow's default font when system fonts are missing. Feedback identifies a critical bug in the thumbnail renderer where lexicographical sorting of filenames leads to incorrect slide ordering for presentations with ten or more slides. Additionally, a resource leak was found in the single-thumbnail rendering method, and there is a concern regarding the unexpected resetting of user-provided streams in the SVG module.

Comment thread src/pptx/render.py Outdated
% (result.returncode, (result.stderr or b"").decode("utf-8", "replace"))
)

png_paths = sorted(p for p in work_dir.glob("*.png") if p.name != deck_path.name)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The use of sorted() on file paths performs a lexicographical sort, which will lead to incorrect ordering if the presentation has 10 or more slides (e.g., slide1.png, slide10.png, slide2.png). This will cause _select_indexes to return the wrong slides. A natural sort or a sort based on the numeric index extracted from the filename should be used.

Comment thread src/pptx/_svg.py
return f.read(), os.path.basename(os.fspath(source))
# Assume file-like.
if callable(getattr(source, "seek", None)):
source.seek(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.

medium

Calling source.seek(0) on a file-like object passed by the user can be unexpected and may interfere with the caller's own management of the stream. It is generally preferred to read from the current position or to explicitly document that the stream will be reset to the beginning.

Comment thread src/pptx/render.py Outdated
Comment on lines +192 to +207
paths = render_slide_thumbnails(
prs,
slide_indexes=[idx],
soffice_bin=soffice_bin,
timeout=timeout,
return_bytes=return_bytes,
)
only = paths[0]
if return_bytes:
return only # type: ignore[return-value]
if out_path is not None:
target = Path(out_path)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(only, target)
return target
return only # type: ignore[return-value]
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

There is a resource leak in render_slide_thumbnail when return_bytes is False (the default). It calls render_slide_thumbnails without an out_dir, which causes a temporary directory to be created. However, render_slide_thumbnails only cleans up this directory if return_bytes is True. When out_path is provided, the file is copied but the temporary directory and its other contents (including the full presentation saved to disk) remain. The method should ensure that the temporary directory is removed after the thumbnail is successfully rendered and copied.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a85df8177d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/pptx/render.py Outdated
% (result.returncode, (result.stderr or b"").decode("utf-8", "replace"))
)

png_paths = sorted(p for p in work_dir.glob("*.png") if p.name != deck_path.name)
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 Badge Filter thumbnail candidates to the current render job

render_slide_thumbnails() currently collects every *.png in out_dir, so if the caller points to a non-empty directory (for example, a shared artifacts folder), stale/unrelated PNGs are treated as slide renders. That corrupts the returned list and can trigger incorrect slide_indexes behavior or bogus out-of-range errors. Restrict the scan to files generated from the current deck_path (or render into an isolated temp subdirectory first).

Useful? React with 👍 / 👎.

Comment thread src/pptx/render.py Outdated
% (result.returncode, (result.stderr or b"").decode("utf-8", "replace"))
)

png_paths = sorted(p for p in work_dir.glob("*.png") if p.name != deck_path.name)
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 Badge Sort rendered slide images by numeric slide index

Using plain sorted(...) on filenames misorders multi-digit slides lexicographically (e.g. ...-10.png before ...-2.png). Once a deck has 10+ slides, slide_indexes selection and render_slide_thumbnail() can return the wrong slide image because index-to-path ordering is no longer deck order. Parse and sort by the numeric slide suffix instead of raw filename text.

Useful? React with 👍 / 👎.

Comment thread src/pptx/render.py Outdated
Comment on lines +192 to +196
paths = render_slide_thumbnails(
prs,
slide_indexes=[idx],
soffice_bin=soffice_bin,
timeout=timeout,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use a disposable out_dir when rendering one slide

render_slide_thumbnail() calls render_slide_thumbnails() without out_dir, which creates a temp directory; with the default return_bytes=False, that temp directory is not cleaned up. In the common out_path=... flow, the function copies one file to the requested destination but leaves the hidden temp render directory behind on every call, causing disk buildup in long-running processes.

Useful? React with 👍 / 👎.

- render.py: lexicographic sort of LibreOffice's PNG output broke
  decks with 10+ slides ("deck-10.png" sorted before "deck-2.png").
  Switched to a natural-sort key that splits embedded digit runs and
  compares them as integers, with a regression test that pins the
  ordering for an 11-slide deck and confirms `slide_indexes=[9]` now
  hits "deck-10.png".

- render.py: `render_slide_thumbnail` was leaking the entire temp
  directory (including the saved deck) when called with `out_path=`
  but not `return_bytes=True`. The function now wraps the internal
  call in a `TemporaryDirectory` it controls and copies the PNG out
  before the context exits. The default no-args case persists a
  small NamedTemporaryFile PNG; the bigger temp dir is always
  cleaned up. Two regression tests assert no temp dir survives.

- _svg.py: documented the `seek(0)` rewind behavior on file-like
  inputs to `load_image_blob`. Matches the existing convention in
  `Image.from_file` (used by `add_picture`); the docstring now spells
  out that callers passing a partially-read stream will get the full
  blob, and points them at `bytes` for pre-sliced subsets.

3107 tests pass.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Implements Phase 6 roadmap features across chart UX presets, SVG embedding, theme-aware color resolution, headless thumbnail rendering, and composition conveniences, along with Linux/minimal-runtime text fitting improvements.

Changes:

  • Add chart quick-layout presets and series color palettes, exposed as Chart.apply_quick_layout() / Chart.apply_palette().
  • Add native SVG picture embedding (ShapeTree.add_svg_picture) and theme-aware color resolution (pptx.inherit.resolve_color).
  • Add headless LibreOffice slide thumbnail rendering (pptx.render) plus platform/font fallback improvements and a pptx.compose public package surface.

Reviewed changes

Copilot reviewed 27 out of 28 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/text/test_layout.py Adds unit tests for Pillow default-font fallback behavior in _Fonts.
tests/text/test_fonts.py Adds Linux font directory and unknown-platform behavior tests for FontFiles.
tests/test_svg_picture.py Adds integration tests validating SVG embedding + PNG fallback and round-trip.
tests/test_render.py Adds unit tests for LibreOffice thumbnail rendering plumbing and errors.
tests/test_inherit.py Adds unit tests for theme-aware resolve_color and brightness math.
tests/integration/test_compose_package.py Adds integration tests ensuring pptx.compose re-exports stay stable.
tests/chart/test_quick_layouts.py Adds tests for applying and composing chart quick-layout specs.
tests/chart/test_palettes.py Adds tests for palette resolution and color-like parsing.
tests/chart/test_chart.py Adds integration tests for palette application and per-series fill behaviors.
src/pptx/text/text.py Falls back gracefully when FontFiles.find() can’t locate a font.
src/pptx/text/layout.py Implements Pillow default font loading when font_path is None.
src/pptx/text/fonts.py Adds Linux font directories, avoids raising on unknown OS, skips unreadable/malformed fonts.
src/pptx/slide.py Adds Slide.render_thumbnail() convenience wrapper.
src/pptx/shapes/shapetree.py Adds ShapeTree.add_svg_picture() to embed SVG with PNG fallback.
src/pptx/render.py Introduces LibreOffice-based thumbnail rendering helpers and exceptions.
src/pptx/presentation.py Adds Presentation.render_thumbnails() convenience wrapper.
src/pptx/oxml/ns.py Registers the asvg namespace prefix for SVG blip extensions.
src/pptx/opc/spec.py Registers SVG content type in image_content_types.
src/pptx/opc/constants.py Adds CONTENT_TYPE.SVG = image/svg+xml.
src/pptx/inherit.py Adds resolve_color() and brightness blending helpers.
src/pptx/compose/from_spec.py Adds JSON/dict-driven authoring entry point from_spec().
src/pptx/compose/init.py Adds pptx.compose package re-export surface (from_spec, import_slide, apply_template).
src/pptx/chart/quick_layouts.py Adds named quick-layout presets and applicator helpers.
src/pptx/chart/palettes.py Adds built-in chart palettes and palette resolver utilities.
src/pptx/chart/chart.py Adds Chart.apply_quick_layout() and Chart.apply_palette() methods.
src/pptx/_svg.py Adds SVG blob loading, sniffing, rasterization, and OOXML extension injection helpers.
ROADMAP.md Marks Phase items complete and documents the new public APIs/behaviors.
HISTORY.rst Adds release notes entries describing new Phase features and APIs.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/pptx/render.py Outdated
Comment on lines +9 to +21
each slide, and return the resulting paths (or PNG bytes).

This is an *optional* feature with no hard dependency: callers must have
``soffice`` (LibreOffice) on ``PATH``. When it isn't available the
functions raise :class:`ThumbnailRendererUnavailable` with an actionable
hint so the failure mode is obvious.

The renderer prefers ``soffice``'s built-in ``png_Portable_Network_Graphic``
filter, which produces one PNG per slide named ``<deck>-<index>.png``
(0-based). Older LibreOffice versions only render slide 1 with the
plain ``-convert-to png`` shorthand; we work around that by issuing
``--convert-to "png:impress_png_Export:..."`` with a slide range when
asked for a specific slide.
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

The module docstring states the renderer prefers the png_Portable_Network_Graphic filter and uses an impress_png_Export + slide-range workaround for older LibreOffice versions, but _run_soffice() always shells out with plain --convert-to png and the implementation always renders the whole deck then filters. Either implement the described filter/range behavior or update the docstring to match what the code actually does so users aren't misled about compatibility and performance characteristics.

Suggested change
each slide, and return the resulting paths (or PNG bytes).
This is an *optional* feature with no hard dependency: callers must have
``soffice`` (LibreOffice) on ``PATH``. When it isn't available the
functions raise :class:`ThumbnailRendererUnavailable` with an actionable
hint so the failure mode is obvious.
The renderer prefers ``soffice``'s built-in ``png_Portable_Network_Graphic``
filter, which produces one PNG per slide named ``<deck>-<index>.png``
(0-based). Older LibreOffice versions only render slide 1 with the
plain ``-convert-to png`` shorthand; we work around that by issuing
``--convert-to "png:impress_png_Export:..."`` with a slide range when
asked for a specific slide.
slides, and return the resulting paths (or PNG bytes).
This is an *optional* feature with no hard dependency: callers must have
``soffice`` (LibreOffice) on ``PATH``. When it isn't available the
functions raise :class:`ThumbnailRendererUnavailable` with an actionable
hint so the failure mode is obvious.
The current implementation shells out with plain ``--convert-to png`` and
relies on LibreOffice to emit PNG files for the presentation, typically one
per slide named ``<deck>-<index>.png``. When callers request a specific
slide, the renderer still converts the deck and then filters the generated
files in Python rather than requesting a slide-range export from ``soffice``.

Copilot uses AI. Check for mistakes.
Comment thread src/pptx/render.py
Comment on lines +128 to +153

cleanup_tmp = out_dir is None
work_dir = Path(out_dir) if out_dir is not None else Path(tempfile.mkdtemp(prefix="pptx-thumbs-"))
work_dir.mkdir(parents=True, exist_ok=True)

try:
deck_path = work_dir / "_render_input.pptx"
_save_to_path(prs, deck_path)

result = _run_soffice(bin_path, deck_path, work_dir, timeout)
if result.returncode != 0:
raise ThumbnailRendererError(
"soffice exited with status %d: %s"
% (result.returncode, (result.stderr or b"").decode("utf-8", "replace"))
)

png_paths = sorted(
(p for p in work_dir.glob("*.png") if p.name != deck_path.name),
key=_natural_sort_key,
)
if not png_paths:
raise ThumbnailRendererError(
"soffice produced no PNG output; ensure your LibreOffice "
"build includes the `impress_png_Export` filter."
)

Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

render_slide_thumbnails collects output PNGs using glob('*.png') in out_dir and sorts them lexicographically. This can (1) include unrelated pre-existing PNGs in a user-provided out_dir, corrupting the returned list/index mapping, and (2) mis-order slides for 10+ pages (e.g., ...-10.png sorts before ...-2.png), breaking slide_indexes selection and deck-order return values. Consider rendering into a dedicated temporary work directory and then copying/returning only the PNGs produced for this deck, and sort by the slide index parsed from the filename (numeric sort) rather than simple sorted().

Copilot uses AI. Check for mistakes.
- render.py: filter rendered PNGs to ones produced by the current
  render job. Snapshot the set of pre-existing `*.png` files in
  `out_dir` before invoking `soffice` and subtract them from the
  result list, so a non-empty `out_dir` (a shared artifacts folder,
  a cache, etc.) doesn't leak stray PNGs into the returned slide
  list and corrupt `slide_indexes` lookups. Two regression tests:
  one stray PNG is dropped from the result, and a stray that would
  lex-sort between two real outputs doesn't shift index alignment.

- render.py: corrected the module docstring. The previous wording
  claimed a sophisticated `png_Portable_Network_Graphic` filter +
  `impress_png_Export:...` slide-range workaround that the actual
  `_run_soffice` impl never used. The docstring now describes what
  the code does: plain `--convert-to png` followed by Python-side
  filtering of generated files.

Codex / Copilot also flagged the lex-sort and `render_slide_thumbnail`
temp-dir leak; both were already fixed in commit 4f3dcf2.

3109 tests pass.
@CodeHalwell CodeHalwell merged commit a1c6a69 into master Apr 28, 2026
5 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.

3 participants