Phase 6: Chart quick layouts, SVG support, theme-aware color inheritance, and rendering#15
Conversation
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.
There was a problem hiding this comment.
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.
| % (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) |
There was a problem hiding this comment.
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.
| return f.read(), os.path.basename(os.fspath(source)) | ||
| # Assume file-like. | ||
| if callable(getattr(source, "seek", None)): | ||
| source.seek(0) |
There was a problem hiding this comment.
| 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] |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
💡 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".
| % (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) |
There was a problem hiding this comment.
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 👍 / 👎.
| % (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) |
There was a problem hiding this comment.
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 👍 / 👎.
| paths = render_slide_thumbnails( | ||
| prs, | ||
| slide_indexes=[idx], | ||
| soffice_bin=soffice_bin, | ||
| timeout=timeout, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 apptx.composepublic 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.
| 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. |
There was a problem hiding this comment.
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.
| 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``. |
|
|
||
| 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." | ||
| ) | ||
|
|
There was a problem hiding this comment.
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().
- 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.
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
pptx.chart.quick_layoutsmodule with 10 named layout presets mirroring PowerPoint's Chart Design → Quick Layout galleryChart.apply_quick_layout(layout)andapply_quick_layout(chart, layout)functional formapply_quick_layouttwice merges specsNative SVG Support
ShapeTree.add_svg_picture()embeds SVG with PNG fallback (Office 2016+ compatible)<a:blip>(PNG) and<asvg:svgBlip>(SVG) in the same elementcairosvgdependency for automatic rasterization; callers can supply their own PNG fallbackpptx._svgmodule with SVG detection, blob loading, and OOXML element rewritingCairoSvgUnavailableerror when rasterization is needed but dependency missingTheme-Aware Color Inheritance
pptx.inherit.resolve_color(color_format, theme=...)returns effectiveRGBColorfor anyColorFormattheme.colors[…], unset colors returnNonebrightnessadjustment by blending toward white/black, mirroring PowerPoint'slumMod/lumOffmodelSlide Thumbnail Rendering
pptx.rendermodule for headless LibreOffice renderingrender_slide_thumbnails(prs, ...)and convenience methods onPresentation/SlideThumbnailRendererUnavailablewith actionable install hints whensofficemissingPOWER_PPTX_SOFFICEenvironment variable or explicit binary pathChart Color Palettes
pptx.chart.palettesmodule with six built-in palettes:modern,classic,editorial,vibrant,monochrome_blue,monochrome_warmChart.apply_palette(palette)recolors series independently ofchart_stylechart.series[i].format.fillPlatform Support
FontFiles._linux_font_directories()Composition Package
pptx.composepackage re-exportsfrom_spec,import_slide,apply_templatefor unified public surfaceChecklist
Test Notes
New modules are covered by dedicated test suites:
tests/chart/test_quick_layouts.py: Layout application, composition, axis title handlinghttps://claude.ai/code/session_01ELm2Sv3FFC1QvwWzZEkQCb