
Slides
- 16 installs
- 295 repo stars
- Updated June 29, 2026
- jetbrains/skills
slides builds PPTX decks with PptxGenJS and validates via render_slides.
About
slides builds PPTX decks with PptxGenJS and validates via render_slides.py, slides_test.py overflow checks, and detect_font.py. Delivers editable .pptx plus authoring .js with explicit 16:9 layout and typography rules.
- Create and edit presentation slide decks (`.
- Installation and configuration patterns for slides.
- When-to-use guidance versus common alternatives.
- Evidence-backed steps from the upstream SKILL.md guide.
Slides by the numbers
- 16 all-time installs (skills.sh)
- +6 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #452 of 688 Office & Documents skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
slides capabilities & compatibility
- Capabilities
- slides quick start · slides when to use guidance · slides integration patterns
- Use cases
- presentations
What slides says it does
source: https://github.com/openai/skills/tree/e6afb0d74cc75d220df2faf3dd6c635c2dc6a108/skills/.curated/slides
Keep work in a task-local directory. Only copy final artifacts to the requested destination after rendering and validation pass.
npx skills add https://github.com/jetbrains/skills --skill slidesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 16 |
|---|---|
| repo stars | ★ 295 |
| Last updated | June 29, 2026 |
| Repository | jetbrains/skills ↗ |
How do I use slides correctly?
Create and edit presentation slide decks (`.pptx`) with PptxGenJS and render/validation utilities. Use when tasks involve building a new PowerPoint deck, recreating slides from screenshots/PDFs/refer.
Who is it for?
Teams implementing slides workflows from the research catalog.
Skip if: Skip when requirements clearly match a different specialized stack.
When should I use this skill?
User asks about slides, create and edit presentation slide decks (`.pptx`) with pptxgenjs and render/val.
What you get
Working slides setup with validated configuration and next steps.
Files
Slides
Overview
Use PptxGenJS for slide authoring. Do not use python-pptx for deck generation unless the task is inspection-only; keep editable output in JavaScript and deliver both the .pptx and the source .js.
Keep work in a task-local directory. Only copy final artifacts to the requested destination after rendering and validation pass.
Bundled Resources
scripts/render_slides.py: Rasterize a.pptxor.pdfto per-slide PNGs.scripts/slides_test.py: Detect content that overflows the slide canvas.scripts/create_montage.py: Build a contact-sheet style montage of rendered slides.scripts/detect_font.py: Report missing or substituted fonts as LibreOffice resolves them.
Workflow
1. Inspect the request and determine whether you are creating a new deck, recreating an existing deck, or editing one. 2. Set the slide size up front. Default to 16:9 (LAYOUT_WIDE) unless the source material clearly uses another aspect ratio. 3. Build the deck in JavaScript with PptxGenJS, an explicit theme font, stable spacing, and editable PowerPoint-native elements when practical. 4. Run the bundled scripts from this skill directory or copy the needed ones into the task workspace. Render the result with render_slides.py, review the PNGs, and fix layout issues before delivery. 5. Run slides_test.py for overflow checks when slide edges are tight or the deck is dense. 6. Deliver the .pptx, the authoring .js, and any generated assets that are required to rebuild the deck.
Authoring Rules
- Set theme fonts explicitly. Do not rely on PowerPoint defaults if typography matters.
- Size text boxes deliberately and verify by rendering; do not rely on PptxGenJS
fitorautoFitfor final layout. - Use bullet options, not literal
•characters. - Use explicit crop/contain calculations when placing images so aspect ratio and framing are intentional.
- Prefer native PowerPoint charts for simple bar/line/pie/histogram style visuals so reviewers can edit them later.
- For charts or diagrams that PptxGenJS cannot express well, render SVG externally and place the SVG in the slide.
- Fix all unintentional overlap and out-of-bounds issues before delivering. If an overlap is intentional, leave a short code comment near the relevant element.
Recreate Or Edit Existing Slides
- Render the source deck or reference PDF first so you can compare slide geometry visually.
- Match the original aspect ratio before rebuilding layout.
- Preserve editability where possible: text should stay text, and simple charts should stay native charts.
Validation Commands
Examples below assume you copied the needed scripts into the working directory. If not, invoke the same script paths relative to this skill folder.
# Render slides to PNGs for review
python3 scripts/render_slides.py deck.pptx --output_dir rendered
# Build a montage for quick scanning
python3 scripts/create_montage.py --input_dir rendered --output_file montage.png
# Check for overflow beyond the original slide canvas
python3 scripts/slides_test.py deck.pptx
# Detect missing or substituted fonts
python3 scripts/detect_font.py deck.pptx --jsoninterface:
display_name: "Slides"
short_description: "Create and edit PPTX slide decks"
icon_small: "./assets/slides-small.svg"
icon_large: "./assets/slides.png"
default_prompt: "Use $slides to create or update this PPTX slide deck with PptxGenJS and validate the layout."
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.4678 7.86641C13.4678 7.29758 13.4671 6.90292 13.442 6.5961C13.4174 6.29551 13.372 6.12642 13.3076 6.00001C13.1669 5.7238 12.9424 5.49935 12.6662 5.3586C12.5398 5.2942 12.3707 5.24879 12.0701 5.22423C11.7633 5.19916 11.3686 5.19845 10.7998 5.19845H5.19981C4.63097 5.19845 4.23631 5.19916 3.92949 5.22423C3.62893 5.24879 3.4598 5.2942 3.3334 5.3586C3.0572 5.49935 2.83273 5.7238 2.69199 6.00001C2.62758 6.12642 2.58218 6.29551 2.55762 6.5961C2.53255 6.90292 2.53184 7.29758 2.53184 7.86641V9.86641C2.53184 10.4352 2.53255 10.8299 2.55762 11.1367C2.58218 11.4373 2.62759 11.6064 2.69199 11.7328C2.83273 12.009 3.05721 12.2335 3.3334 12.3742C3.45981 12.4386 3.62893 12.484 3.92949 12.5086C4.23631 12.5337 4.63097 12.5344 5.19981 12.5344H10.7998C11.3686 12.5344 11.7633 12.5337 12.0701 12.5086C12.3707 12.484 12.5398 12.4386 12.6662 12.3742C12.9424 12.2335 13.1669 12.009 13.3076 11.7328C13.372 11.6064 13.4174 11.4373 13.442 11.1367C13.4671 10.8299 13.4678 10.4352 13.4678 9.86641V7.86641ZM9.81465 2.11563L12.0053 2.13438L12.1123 2.1461C12.3542 2.19814 12.5344 2.41482 12.5318 2.67188C12.5292 2.92875 12.3448 3.14096 12.1021 3.18829L11.9943 3.19845L9.80996 3.1797C7.87499 3.16721 6.50265 3.17347 4.00527 3.19845C3.71153 3.20138 3.4708 2.96561 3.46777 2.67188C3.46484 2.37813 3.7006 2.1374 3.99434 2.13438C6.49686 2.10936 7.87466 2.10313 9.81465 2.11563ZM14.5318 9.86641C14.5318 10.4177 14.5324 10.8633 14.5029 11.2234C14.473 11.5897 14.4095 11.9143 14.2561 12.2156C14.0133 12.6921 13.6255 13.0799 13.149 13.3227C12.8477 13.4761 12.5231 13.5396 12.1568 13.5695C11.7966 13.599 11.3511 13.5984 10.7998 13.5984H5.19981C4.64853 13.5984 4.20297 13.599 3.84277 13.5695C3.47655 13.5396 3.15187 13.4761 2.85059 13.3227C2.37416 13.0799 1.98631 12.6921 1.74356 12.2156C1.5901 11.9143 1.52661 11.5897 1.49668 11.2234C1.46725 10.8633 1.46777 10.4177 1.46777 9.86641V7.86641C1.46777 7.31514 1.46725 6.86958 1.49668 6.50938C1.52661 6.14315 1.59012 5.81849 1.74356 5.5172C1.98631 5.04077 2.37416 4.65292 2.85059 4.41016C3.15188 4.25671 3.47653 4.19322 3.84277 4.16329C4.20297 4.13386 4.64853 4.13438 5.19981 4.13438H10.7998C11.3511 4.13438 11.7966 4.13386 12.1568 4.16329C12.5231 4.19322 12.8477 4.25671 13.149 4.41016C13.6255 4.65292 14.0133 5.04077 14.2561 5.5172C14.4095 5.81849 14.473 6.14315 14.5029 6.50938C14.5324 6.86958 14.5318 7.31514 14.5318 7.86641V9.86641Z" fill="currentColor"/>
</svg>
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
#!/usr/bin/env python3
# Copyright (c) OpenAI. All rights reserved.
import argparse
import re
from math import ceil
from os import listdir
from os.path import basename, expanduser, isfile, join, splitext
from typing import Literal
from PIL import Image, ImageDraw, ImageFont, ImageOps
SUPPORTED_EXTS = {".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tif", ".tiff", ".webp"}
def _make_placeholder(w: int, h: int) -> Image.Image:
"""Create a visible placeholder tile with a light gray fill and a red X cross."""
ph = Image.new("RGBA", (w, h), (220, 220, 220, 255))
ph_draw = ImageDraw.Draw(ph)
line_color = (180, 0, 0, 255)
ph_draw.line([(0, 0), (ph.width - 1, ph.height - 1)], fill=line_color, width=3)
ph_draw.line([(ph.width - 1, 0), (0, ph.height - 1)], fill=line_color, width=3)
return ph
def _load_images_with_placeholders(
input_files: list[str], fail_on_image_error: bool = False
) -> tuple[list[str], list[Image.Image | None]]:
labels = [basename(p) for p in input_files]
images: list[Image.Image | None] = []
for p in input_files:
try:
images.append(Image.open(p))
except Exception as e:
if fail_on_image_error:
raise
print(f'Warning: Failed to load image "{p}": {e}')
images.append(None)
return labels, images
def _natural_key(s: str) -> list:
"""Key function for natural sorting (e.g., Slide2 before Slide10)."""
return [int(part) if part.isdigit() else part for part in re.split(r"(\d+)", s)]
def create_montage(
input_files: list[str],
output_file: str,
num_col: int,
cell_w: int,
cell_h: int,
gap: int,
label_mode: Literal["number", "filename", "none"],
fail_on_image_error: bool = False,
) -> None:
"""Build a montage with a fixed number of columns.
Each cell has size `cell_w` x `cell_h`. Every input image is resized isotropically to fit inside
the cell. `gap` controls spacing around and between cells (outer margin equals gap).
Label behavior is controlled by `label_mode` which can be one of:
- "none": no labels are drawn
- "number": draw a 1-based index beneath each image
- "filename": draw the filename (no directory) beneath each image
"""
if num_col <= 0:
raise ValueError("num_col must be positive")
if cell_w <= 0 or cell_h <= 0:
raise ValueError("cell_w and cell_h must be positive")
labels, images = _load_images_with_placeholders(
input_files=input_files,
fail_on_image_error=fail_on_image_error,
)
num_images = len(images)
num_valid = sum(1 for im in images if im is not None)
if num_valid == 0:
raise ValueError("No valid images to render.")
if num_valid < num_images:
cell_size = round(min(cell_w, cell_h) * 0.6)
placeholder = _make_placeholder(cell_size, cell_size)
else:
placeholder = None
cols = num_col
rows = ceil(num_images / cols)
temp_canvas = Image.new("RGB", (10, 10), (255, 255, 255))
temp_draw = ImageDraw.Draw(temp_canvas)
# Choose a readable default font size relative to cell height
font: ImageFont.FreeTypeFont | ImageFont.ImageFont
try:
# Attempt to use a common system font for clarity; fallback to default
font_size = max(12, min(36, int(cell_h * 0.12)))
font = ImageFont.truetype("arial.ttf", font_size)
except Exception:
font = ImageFont.load_default()
# Adjust default font effect size estimate
font_size = 12
draw_labels = label_mode != "none"
label_height = 0
if draw_labels:
# Height is approximately constant across strings for a given font
# Use 'Ag' to approximate ascent ('A') and descender ('g') for filename text
sample_text = "1" if label_mode == "number" else "Ag"
lbbox = temp_draw.textbbox((0, 0), sample_text, font=font)
label_height = ceil(lbbox[3] - lbbox[1]) + 6
row_h = cell_h + label_height
canvas_w = cols * cell_w + (cols + 1) * gap
canvas_h = rows * row_h + (rows + 1) * gap
# Light grey canvas background as in typical slide sorter view
canvas = Image.new("RGB", (canvas_w, canvas_h), (242, 242, 242))
draw = ImageDraw.Draw(canvas)
for idx, img in enumerate(images):
col = idx % cols
row = idx // cols
# Top-left corner of the cell including outer margin and gaps
x0 = gap + col * (cell_w + gap)
y0 = gap + row * (row_h + gap)
# Fit the image within the cell while preserving aspect ratio
if label_mode == "number":
label = str(idx + 1)
elif label_mode == "filename":
label = labels[idx]
else:
label = ""
if draw_labels:
bbox = draw.textbbox((0, 0), label, font=font)
text_w = bbox[2] - bbox[0]
else:
text_w = 0
if img:
resized = ImageOps.contain(
img.convert("RGBA"),
(cell_w, cell_h),
method=Image.Resampling.LANCZOS,
)
else:
print(f"Warning: Using placeholder for invalid image at row={row + 1}, col={col + 1}")
assert placeholder is not None
resized = placeholder
paste_x = x0 + (cell_w - resized.width) // 2
paste_y = y0 + (cell_h - resized.height) // 2
canvas.paste(
resized,
(paste_x, paste_y),
mask=resized.split()[3] if resized.mode == "RGBA" else None,
)
border_color = (160, 160, 160)
bw = 1
draw.rectangle(
[
paste_x - bw,
paste_y - bw,
paste_x + resized.width,
paste_y + resized.height,
],
outline=border_color,
width=bw,
)
if draw_labels:
tx = x0 + round((cell_w - text_w) / 2)
ty = y0 + cell_h + 3
draw.text((tx, ty), label, font=font, fill=(0, 0, 0))
canvas.save(output_file)
print(f"Montage saved to {output_file}")
def main() -> None:
parser = argparse.ArgumentParser(
description=(
"Create a montage with a fixed number of columns. "
"Each image is resized isotropically to fit inside a cell of size (cell_width x cell_height)."
)
)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--input_files", nargs="+", help="List of input image file paths")
group.add_argument("--input_dir", help="Directory containing input images")
parser.add_argument(
"--output_file",
required=True,
help=(
"Path to save the output montage image. The format is inferred from the file extension."
),
)
parser.add_argument(
"--num_col",
type=int,
default=5,
help="Number of images per row (default: 5)",
)
parser.add_argument(
"--cell_width",
type=int,
default=400,
help="Container width in pixels for each image (default: 400)",
)
parser.add_argument(
"--cell_height",
type=int,
default=225,
help="Container height in pixels for each image (default: 225)",
)
parser.add_argument(
"--gap",
type=int,
default=16,
help="Gap in pixels between images and canvas margins (default: 16)",
)
parser.add_argument(
"--label_mode",
choices=["number", "filename", "none"],
default="number",
help=(
"Label mode: 'number' to draw 1-based indices (default), 'filename' to use the "
"image's filename (no directory), or 'none' for no labels"
),
)
parser.add_argument(
"--fail_on_image_error",
action="store_true",
default=False,
help=(
"If set, fail immediately when any image conversion/loading fails (no placeholders). "
"By default, failures are tolerated and placeholders are used."
),
)
args = parser.parse_args()
output_path = expanduser(args.output_file)
if args.input_files:
input_files = [expanduser(p) for p in args.input_files]
else:
input_dir = expanduser(args.input_dir)
names = sorted(listdir(input_dir), key=_natural_key)
dir_entries = [join(input_dir, f) for f in names]
input_files = [
p for p in dir_entries if isfile(p) and splitext(p)[1].lower() in SUPPORTED_EXTS
]
if not input_files:
raise ValueError(
"No image files with supported extensions were found in the specified directory."
)
create_montage(
input_files=input_files,
output_file=output_path,
num_col=args.num_col,
cell_w=args.cell_width,
cell_h=args.cell_height,
gap=args.gap,
label_mode=args.label_mode,
fail_on_image_error=args.fail_on_image_error,
)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Copyright (c) OpenAI. All rights reserved.
Detect missing fonts for PPTX rendering by converting to ODP and inspecting the resolved font
families per slide.
Overview
========
PowerPoint files (PPTX) declare requested font families in runs and theme defaults, but the actual
font used at render time depends on the renderer (LibreOffice in our pipeline), platform
availability, and style inheritance. To make detection stable and renderer-accurate, this module:
- Extracts requested families from PPTX per slide (reads a:r/a:rPr plus document defaults, grouped
by script: latin/ea/cs/sym). Analysis is done per run: we infer the script from run text and
select the matching a:rPr child (e.g., latin/ea/cs). Fonts declared for other scripts in the same
run are not counted as used.
- Converts the PPTX to ODP using headless LibreOffice and parses ODP content.xml and styles.xml to
discover which families LibreOffice actually resolved for each slide (including master pages and
defaults).
- Classifies each requested family on each slide into two buckets:
- font_missing: the family is not installed on the system (per fontconfig synonyms), so resolution
cannot possibly match the request.
- font_substituted: the family is installed but was resolved to another family in ODP for the
slide (theme/style inheritance or glyph coverage), i.e., installed but substituted.
Key Design
-----------------------
1) Inspect the renderer's decision, not only the author's request. Reading PPTX alone tells you what
was requested, not what LibreOffice will choose after applying styles and availability checks.
Converting to ODP and reading the resolved fo:font-family/style:font-name* values yields a
faithful view of what the renderer actually used for each slide.
2) Robust style resolution across ODP structures. Fonts can be specified under multiple layers. We
parse office:automatic-styles (both content.xml and styles.xml), office:styles and
style:default-style, draw:master-page references used by slides, nested style:text-properties
under paragraph-properties, and parent style chains (style:parent-style-name). A text-based
fallback parser supplements XML namespace lookups when vendor XML variations occur.
3) Scalable aliasing via fontconfig synonyms, not ad hoc maps. PostScript names, full names, and
family names often differ. We build a synonym map from fc-list that unifies those identifiers. We
deliberately do NOT use fc-match -s fallback chains for matching, because fallback families
(e.g., DejaVu Sans) would mask missing/substitution cases and produce false passes.
4) Clear classification: missing vs substituted.
- Missing: no synonym of the requested base family is present in the installed font set (per
fontconfig). These require installation.
- Substituted: the family is installed, but ODP does not reference it on the slide (LibreOffice
chose another family), which is useful for diagnosing style/theme issues or glyph-coverage
driven substitutions.
Not Chosen (and why)
--------------------
- PDF inspection (e.g., pdffonts): PostScript names don't reliably map back to authoring families;
PDFs often reflect subsetted fonts and fallback choices, making robust detection noisy.
- Ad hoc alias tables: unscalable for large-scale fonts and platform variants; the fontconfig
synonym corpus covers family/fullname/PostScript consistently.
- Treating fallback families as matches (fc-match -s): causes false negatives by accepting generic
fallbacks when the requested family is missing.
- Hardcoding checks in the renderer: we keep detection separate from render_slides to avoid
coupling and allow standalone checking.
CLI
---
- JSON output exposes two categories by default (and text mode mirrors them): font_missing_overall/
font_missing_by_slide and font_substituted_overall/font_substituted_by_slide.
- Flags include_missing/include_substituted control which categories are emitted (default True/True).
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import tempfile
import xml.etree.ElementTree as ET
from functools import lru_cache
from os.path import abspath, basename, exists, expanduser, join, splitext
from zipfile import ZipFile
STYLE_TOKENS = [
"regular",
"condensed",
"compressed",
"narrow",
"italic",
"oblique",
"semibold",
"demibold",
"bold",
"black",
"extra light",
"ultra light",
"extralight",
"ultralight",
"light",
"thin",
"medium",
]
def normalize_font_family_name(name: str) -> str:
s = name.casefold()
s = re.sub(r"\([^)]*\)", " ", s)
s = re.sub(r"[\s\-\_\.,/\'\"]+", " ", s)
return s.strip()
def _or_dummy(node: ET.Element | None) -> ET.Element:
"""Return the element if not None, otherwise a harmless dummy element.
Avoids deprecated truthiness checks on Element instances (`elem or dummy`).
"""
return node if node is not None else ET.Element("dummy")
@lru_cache(maxsize=1)
def _build_fc_synonym_map() -> dict[str, set[str]]:
"""Build synonym map from fontconfig; raise on failures; memoized (size=1)."""
proc = subprocess.run(
[
"fc-list",
"--format",
"%{family}\t%{fullname}\t%{postscriptname}\n",
],
capture_output=True,
text=True,
check=True,
)
syn: dict[str, set[str]] = {}
for line in (proc.stdout or "").splitlines():
parts = line.split("\t")
if len(parts) != 3:
continue
fam_field, full_field, ps_field = parts
names: set[str] = set()
for field in (fam_field, full_field, ps_field):
for item in field.split(","):
norm = normalize_font_family_name(item)
if norm:
names.add(norm)
names.add(norm.replace(" ", ""))
for name in list(names):
bucket = syn.setdefault(name, set())
bucket.update(names)
return syn
def _expand_via_fontconfig(family_base_norm: str) -> set[str]:
# Accept only true aliases/synonyms (family/fullname/PostScript) — not fallback replacements
acceptable: set[str] = {family_base_norm, family_base_norm.replace(" ", "")}
syn = _build_fc_synonym_map()
if family_base_norm in syn:
acceptable.update(syn[family_base_norm])
no_space = family_base_norm.replace(" ", "")
if no_space in syn:
acceptable.update(syn[no_space])
return acceptable
def parse_font_family_base_and_styles(name_norm: str) -> tuple[str, set[str]]:
tokens = name_norm.split()
required: set[str] = set()
weight_code_map = {
"25": "ultra light",
"35": "thin",
"45": "light",
"55": "regular",
"65": "medium",
"75": "bold",
"85": "black",
"95": "black",
}
if tokens and tokens[0].isdigit() and tokens[0] in weight_code_map:
required.add(weight_code_map[tokens[0]])
tokens = tokens[1:]
if len(tokens) == 1:
t = tokens[0]
fused_map = [
("extralight", "extra light"),
("ultralight", "ultra light"),
("semibold", "semibold"),
("demibold", "semibold"),
("condensed", "condensed"),
("compressed", "condensed"),
("narrow", "condensed"),
("italic", "italic"),
("oblique", "italic"),
("bold", "bold"),
("black", "black"),
("light", "light"),
("thin", "thin"),
("medium", "medium"),
("regular", "regular"),
]
changed = True
while changed:
changed = False
for suf, tok in fused_map:
if t.endswith(suf) and len(t) > len(suf):
t = t[: -len(suf)]
required.add(tok)
changed = True
break
return (t.strip(), required)
while tokens:
tail = " ".join(tokens[-2:]) if len(tokens) >= 2 else tokens[-1]
matched = None
for style in STYLE_TOKENS:
if tail == style:
matched = style
break
if matched is None and tokens[-1] in STYLE_TOKENS:
matched = tokens[-1]
if matched is None:
break
if matched in ("compressed", "narrow"):
required.add("condensed")
elif matched == "roman":
required.add("regular")
elif matched == "demibold":
required.add("semibold")
else:
required.add(matched)
if " " in matched:
tokens = tokens[:-2]
else:
tokens = tokens[:-1]
return (" ".join(tokens).strip(), required)
def _split_odf_family_list(value: str) -> list[str]:
out: list[str] = []
for part in value.split(","):
p = part.strip().strip("\"' ")
if p:
out.append(normalize_font_family_name(p))
return out
def extract_used_fonts_from_pptx(pptx_path: str) -> dict[int, set[str]]:
by_slide: dict[int, set[str]] = {}
with ZipFile(pptx_path, "r") as zf:
for name in zf.namelist():
if not (name.startswith("ppt/slides/slide") and name.endswith(".xml")):
continue
base = os.path.basename(name)
m = re.search(r"(?i)slide(\d+)\.xml$", base)
slide_num = int(m.group(1)) if m else None
with zf.open(name) as f:
tree = ET.parse(f)
root = tree.getroot()
ns = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"}
defaults = _collect_default_font_faces(root)
for r in root.findall(".//a:r", ns):
parts: list[str] = []
for t in r.findall("a:t", ns):
if t.text:
parts.append(t.text)
text = "".join(parts)
if not text:
continue
script = _detect_script_tag(text)
rpr = r.find("a:rPr", ns)
face_norm: str | None = None
if rpr is not None:
child = rpr.find(f"a:{script}", ns)
if child is not None:
face = child.get("typeface")
if face and not face.startswith("+"):
face_norm = normalize_font_family_name(face)
bucket = by_slide.setdefault(slide_num or -1, set())
if face_norm is None:
for f in defaults.get(script, set()):
bucket.add(f)
else:
bucket.add(face_norm)
return {k: v for k, v in by_slide.items() if k is not None and k != -1}
def _detect_script_tag(text: str) -> str:
for ch in text:
cp = ord(ch)
if (
0x4E00 <= cp <= 0x9FFF
or 0x3400 <= cp <= 0x4DBF
or 0xF900 <= cp <= 0xFAFF
or 0x3040 <= cp <= 0x309F
or 0x30A0 <= cp <= 0x30FF
or 0x31F0 <= cp <= 0x31FF
or 0xAC00 <= cp <= 0xD7AF
or 0x3100 <= cp <= 0x312F
or 0x3000 <= cp <= 0x303F
):
return "ea"
for ch in text:
cp = ord(ch)
if (
0x0590 <= cp <= 0x05FF
or 0x0600 <= cp <= 0x06FF
or 0x0700 <= cp <= 0x077F
or 0x0780 <= cp <= 0x07BF
or 0x0900 <= cp <= 0x0D7F
or 0x0E00 <= cp <= 0x0E7F
or 0x0E80 <= cp <= 0x0EFF
or 0xFB50 <= cp <= 0xFDFF
or 0xFE70 <= cp <= 0xFEFF
):
return "cs"
for ch in text:
cp = ord(ch)
if (
(0x0041 <= cp <= 0x005A)
or (0x0061 <= cp <= 0x007A)
or (0x0030 <= cp <= 0x0039)
or (0x00C0 <= cp <= 0x024F)
or (0x1E00 <= cp <= 0x1EFF)
):
return "latin"
return "latin"
def _collect_default_font_faces(root: ET.Element) -> dict[str, set[str]]:
ns = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"}
defaults: dict[str, set[str]] = {"latin": set(), "ea": set(), "cs": set(), "sym": set()}
for defrpr in root.findall(".//a:defRPr", ns):
for tag in ("latin", "ea", "cs", "sym"):
child = defrpr.find(f"a:{tag}", ns)
if child is not None:
face = child.get("typeface")
if face and not face.startswith("+"):
defaults[tag].add(normalize_font_family_name(face))
return defaults
def _run_soffice_convert(cmd: list[str]) -> None:
subprocess.run(
cmd,
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=os.environ.copy(),
)
def _export_to_odp(pptx_path: str, user_profile: str, out_dir: str, stem: str) -> str:
bin_path = shutil.which("soffice") or shutil.which("libreoffice") or "/usr/bin/libreoffice"
cmd_odp = [
bin_path,
"-env:UserInstallation=file://" + user_profile,
"--invisible",
"--headless",
"--norestore",
"--convert-to",
"odp",
"--outdir",
out_dir,
pptx_path,
]
_run_soffice_convert(cmd_odp)
odp_path = join(out_dir, f"{stem}.odp")
return odp_path if exists(odp_path) else ""
def _collect_face_map(root: ET.Element, ns: dict[str, str]) -> dict[str, str]:
face_map: dict[str, str] = {}
decls = root.find("office:font-face-decls", ns)
if decls is None:
return face_map
for ff in decls.findall("style:font-face", ns):
name_attr = ff.get("{urn:oasis:names:tc:opendocument:xmlns:style:1.0}name") or ff.get(
"style:name"
)
fam_attr = ff.get("{urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0}font-family")
if not name_attr or not fam_attr:
continue
face_map[normalize_font_family_name(name_attr)] = normalize_font_family_name(fam_attr)
return face_map
def _families_from_text_properties(
tp: ET.Element, ns: dict[str, str], face_map: dict[str, str]
) -> set[str]:
fams: set[str] = set()
# Inspect current node for direct font-family
fam_attr = tp.get("{urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0}font-family")
if fam_attr:
fams.update(_split_odf_family_list(fam_attr))
# Inspect font-name aliases on current node
for key in (
"{urn:oasis:names:tc:opendocument:xmlns:style:1.0}font-name",
"style:font-name",
"style:font-name-asian",
"style:font-name-complex",
):
val = tp.get(key)
if val:
norm_val = normalize_font_family_name(val)
mapped = face_map.get(norm_val)
if mapped:
fams.add(normalize_font_family_name(mapped))
else:
fams.add(norm_val)
# Some styles nest text-properties under paragraph-properties or default-style blocks
if not fams:
nested = None
# paragraph-properties/text-properties
pp = tp.find("style:paragraph-properties", ns)
if pp is not None:
nested = pp.find("style:text-properties", ns)
if nested is None:
# When tp is actually the style:style node, try finding child text-properties directly
nested = tp.find("style:text-properties", ns)
if nested is not None and nested is not tp:
fams.update(_families_from_text_properties(nested, ns, face_map))
return fams
def _extract_styles_from_container(
container: ET.Element | None, ns: dict[str, str], face_map: dict[str, str]
) -> tuple[dict[str, set[str]], set[str]]:
styles: dict[str, set[str]] = {}
defaults: set[str] = set()
if container is None:
return styles, defaults
for st in container.findall("style:style", ns):
name = st.get("{urn:oasis:names:tc:opendocument:xmlns:style:1.0}name") or st.get(
"style:name"
)
if not name:
continue
fams = _families_from_text_properties(
_or_dummy(st.find("style:text-properties", ns)), ns, face_map
)
if fams:
styles[name] = fams
for ds in container.findall("style:default-style", ns):
defaults.update(
_families_from_text_properties(
_or_dummy(ds.find("style:text-properties", ns)), ns, face_map
)
)
return styles, defaults
def _build_style_map(
content: ET.Element,
styles_root: ET.Element | None,
ns: dict[str, str],
face_map: dict[str, str],
) -> tuple[dict[str, set[str]], set[str]]:
style_map: dict[str, set[str]] = {}
default_fams: set[str] = set()
auto_styles = content.find("office:automatic-styles", ns)
styles_part, defaults_part = _extract_styles_from_container(auto_styles, ns, face_map)
style_map.update(styles_part)
default_fams.update(defaults_part)
if styles_root is not None:
# Also parse automatic-styles within styles.xml (document-styles)
styles_auto = styles_root.find("office:automatic-styles", ns)
styles_part, defaults_part = _extract_styles_from_container(styles_auto, ns, face_map)
for k, v in styles_part.items():
if k not in style_map:
style_map[k] = v
default_fams.update(defaults_part)
common_styles = styles_root.find("office:styles", ns)
styles_part, defaults_part = _extract_styles_from_container(common_styles, ns, face_map)
for k, v in styles_part.items():
if k not in style_map:
style_map[k] = v
default_fams.update(defaults_part)
# top-level default-style under styles_root
for ds in styles_root.findall("style:default-style", ns):
default_fams.update(
_families_from_text_properties(
_or_dummy(ds.find("style:text-properties", ns)), ns, face_map
)
)
# Fallback: include any remaining style:style definitions anywhere in styles.xml
for st in styles_root.findall(".//style:style", ns):
name = st.get("{urn:oasis:names:tc:opendocument:xmlns:style:1.0}name") or st.get(
"style:name"
)
if not name or name in style_map:
continue
fams = _families_from_text_properties(
_or_dummy(st.find("style:text-properties", ns)), ns, face_map
)
if fams:
style_map[name] = fams
# also check top-level default-style in content root
for ds in content.findall("style:default-style", ns):
default_fams.update(
_families_from_text_properties(
_or_dummy(ds.find("style:text-properties", ns)), ns, face_map
)
)
# Fallback: include any remaining style:style definitions anywhere in content.xml
for st in content.findall(".//style:style", ns):
name = st.get("{urn:oasis:names:tc:opendocument:xmlns:style:1.0}name") or st.get(
"style:name"
)
if not name or name in style_map:
continue
fams = _families_from_text_properties(
_or_dummy(st.find("style:text-properties", ns)), ns, face_map
)
if fams:
style_map[name] = fams
return style_map, default_fams
def _lookup_style_families(
style_name: str, ns: dict[str, str], face_map: dict[str, str], roots: list[ET.Element | None]
) -> set[str]:
fams: set[str] = set()
if not style_name:
return fams
visited: set[str] = set()
def _resolve(name: str) -> None:
if not name or name in visited:
return
visited.add(name)
for root in roots:
if root is None:
continue
node = root.find(f".//style:style[@style:name='{name}']", ns)
if node is None:
node = root.find(f".//style:style[@{{{ns['style']}}}name='{name}']", ns)
if node is None:
continue
fams.update(
_families_from_text_properties(
_or_dummy(node.find("style:text-properties", ns)), ns, face_map
)
)
# Follow parent style chain if present
parent = node.get(
"{urn:oasis:names:tc:opendocument:xmlns:style:1.0}parent-style-name"
) or node.get("style:parent-style-name")
if parent:
_resolve(parent)
_resolve(style_name)
return fams
def _collect_slide_families(
page: ET.Element,
ns: dict[str, str],
style_map: dict[str, set[str]],
face_map: dict[str, str],
roots: list[ET.Element | None],
text_style_map: dict[str, set[str]] | None = None,
) -> set[str]:
slide_fams: set[str] = set()
for el in page.iter():
fam_attr = el.get(
"{urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0}font-family"
)
if fam_attr:
slide_fams.update(_split_odf_family_list(fam_attr))
for attr in (
"{urn:oasis:names:tc:opendocument:xmlns:text:1.0}style-name",
"text:style-name",
"{urn:oasis:names:tc:opendocument:xmlns:drawing:1.0}text-style-name",
"draw:text-style-name",
"draw:style-name",
"presentation:style-name",
):
style_name = el.get(attr)
if not style_name:
continue
resolved_fams: set[str] = set()
if style_name in style_map:
resolved_fams.update(style_map[style_name])
if not resolved_fams:
# Fallback: resolve on the fly from XML if not present in prebuilt style_map
resolved_fams.update(_lookup_style_families(style_name, ns, face_map, roots))
if not resolved_fams and text_style_map and style_name in text_style_map:
resolved_fams.update(text_style_map[style_name])
if resolved_fams:
slide_fams.update(resolved_fams)
return slide_fams
def _build_style_map_text(xml_text: str) -> dict[str, set[str]]:
# Best-effort textual extraction for cases missed by XML namespace lookups
# Finds style:style name="X" blocks and extracts fo:font-family and style:font-name attributes
style_map: dict[str, set[str]] = {}
# Non-greedy match of a style:style block
for m in re.finditer(
r"<style:style[^>]*?\bstyle:name=\"([^\"]+)\"[\s\S]*?(?:</style:style>)",
xml_text,
flags=re.IGNORECASE,
):
name = m.group(1).strip()
block = m.group(0)
fams: set[str] = set()
# fo:font-family may be a comma list
mff = re.search(r"fo:font-family=\"([^\"]+)\"", block, flags=re.IGNORECASE)
if mff:
for f in _split_odf_family_list(mff.group(1)):
fams.add(f)
# style:font-name may be a face alias; treat as family directly if present
mfn = re.search(r"style:font-name=\"([^\"]+)\"", block, flags=re.IGNORECASE)
if mfn:
fams.add(normalize_font_family_name(mfn.group(1)))
if fams:
style_map[name] = fams
return style_map
def _extract_slide_families_from_odp(odp_path: str) -> dict[int, set[str]]:
ns = {
"office": "urn:oasis:names:tc:opendocument:xmlns:office:1.0",
"style": "urn:oasis:names:tc:opendocument:xmlns:style:1.0",
"fo": "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0",
"draw": "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0",
"text": "urn:oasis:names:tc:opendocument:xmlns:text:1.0",
}
by_slide: dict[int, set[str]] = {}
with ZipFile(odp_path, "r") as zf:
content_bytes = zf.read("content.xml")
styles_bytes = zf.read("styles.xml") if "styles.xml" in zf.namelist() else None
content = ET.fromstring(content_bytes)
styles_root = ET.fromstring(styles_bytes) if styles_bytes is not None else None
styles_text = (
styles_bytes.decode("utf-8", errors="ignore") if styles_bytes is not None else ""
)
face_map: dict[str, str] = {}
face_map.update(_collect_face_map(content, ns))
if styles_root is not None:
face_map.update(_collect_face_map(styles_root, ns))
style_map, default_fams = _build_style_map(content, styles_root, ns, face_map)
# Augment style_map with textual parsing fallback (helps with tricky namespace emissions)
text_style_map: dict[str, set[str]] = {}
if styles_text:
text_style_map = _build_style_map_text(styles_text)
for k, v in text_style_map.items():
if k not in style_map:
style_map[k] = v
master_map: dict[str, set[str]] = _build_master_page_map(styles_root, ns, style_map)
pres = content.find("office:body", ns)
if pres is not None:
pres = pres.find("office:presentation", ns)
if pres is None:
return {}
pages = pres.findall("draw:page", ns)
global_fams: set[str] = set()
for idx, page in enumerate(pages, start=1):
slide_fams = _collect_slide_families(
page, ns, style_map, face_map, [content, styles_root], text_style_map
)
mp_name = page.get(
"{urn:oasis:names:tc:opendocument:xmlns:drawing:1.0}master-page-name"
) or page.get("draw:master-page-name")
if mp_name and mp_name in master_map:
slide_fams.update(master_map[mp_name])
# If theme placeholders like +mn lt are present, augment with defaults
if any(f.startswith("+") for f in slide_fams) and default_fams:
slide_fams.update(default_fams)
if not slide_fams and default_fams:
slide_fams.update(default_fams)
expanded: set[str] = set()
for f in slide_fams:
base, _ = parse_font_family_base_and_styles(f)
expanded.add(f)
expanded.add(base)
expanded.add(base.replace(" ", ""))
by_slide[idx] = expanded
global_fams.update(expanded)
# As a last resort, use global families
if global_fams:
for idx in list(by_slide.keys()):
if not by_slide[idx]:
by_slide[idx] = set(global_fams)
elif all(f.startswith("+") for f in by_slide[idx]):
by_slide[idx].update(global_fams)
return by_slide
def _build_master_page_map(
styles_root: ET.Element | None, ns: dict[str, str], style_map: dict[str, set[str]]
) -> dict[str, set[str]]:
master_map: dict[str, set[str]] = {}
if styles_root is None:
return master_map
master_styles = styles_root.find("office:master-styles", ns)
if master_styles is None:
return master_map
for mp in master_styles.findall("draw:master-page", ns):
mname = mp.get("{urn:oasis:names:tc:opendocument:xmlns:drawing:1.0}name") or mp.get(
"draw:name"
)
if not mname:
continue
fams: set[str] = set()
for el in mp.iter():
fam_attr = el.get(
"{urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0}font-family"
)
if fam_attr:
fams.update(_split_odf_family_list(fam_attr))
for attr in (
"{urn:oasis:names:tc:opendocument:xmlns:text:1.0}style-name",
"text:style-name",
"{urn:oasis:names:tc:opendocument:xmlns:drawing:1.0}text-style-name",
"draw:text-style-name",
"draw:style-name",
"presentation:style-name",
):
sname = el.get(attr)
if sname and sname in style_map:
fams.update(style_map[sname])
if fams:
expanded: set[str] = set()
for f in fams:
base, _ = parse_font_family_base_and_styles(f)
expanded.add(f)
expanded.add(base)
expanded.add(base.replace(" ", ""))
master_map[mname] = expanded
return master_map
def detect_missing_fonts_odp(pptx_path: str) -> tuple[set[str], dict[int, list[str]]]:
pptx_path = abspath(pptx_path)
used = extract_used_fonts_from_pptx(pptx_path)
with tempfile.TemporaryDirectory(prefix="soffice_profile_") as prof:
with tempfile.TemporaryDirectory(prefix="soffice_convert_") as out:
stem = splitext(basename(pptx_path))[0]
odp_path = _export_to_odp(pptx_path, prof, out, stem)
if not odp_path:
return set(), {}
slide_fams = _extract_slide_families_from_odp(odp_path)
missing_overall: set[str] = set()
missing_by_slide: dict[int, list[str]] = {}
syn_map = _build_fc_synonym_map()
for slide_num, req_fams in used.items():
odp_fams = slide_fams.get(slide_num, set())
slide_missing: list[str] = []
for req in req_fams:
fam_base, _ = parse_font_family_base_and_styles(req)
# Accept fontconfig-resolved aliases and no-space variants for the requested base family
acceptable: set[str] = _expand_via_fontconfig(fam_base)
# Determine if any acceptable alias is actually installed on system
installed = any(alias in syn_map for alias in acceptable)
# Missing if not installed at all, or if installed but not resolved in ODP families
if (not installed) or ((req not in odp_fams) and not (acceptable & odp_fams)):
slide_missing.append(req)
missing_overall.add(req)
if slide_missing:
missing_by_slide[slide_num] = sorted(slide_missing)
return missing_overall, missing_by_slide
def main() -> None:
parser = argparse.ArgumentParser(
description=(
"Detect missing/substituted fonts for a PPTX by converting to ODP and inspecting resolved families."
)
)
parser.add_argument("pptx_path", help="Path to .pptx file")
parser.add_argument(
"--json", dest="output_json", action="store_true", default=False, help="Emit JSON output"
)
parser.add_argument(
"--include-missing",
dest="include_missing",
action="store_true",
default=True,
help="Include missing category",
)
parser.add_argument(
"--include-substituted",
dest="include_substituted",
action="store_true",
default=True,
help="Include substituted category",
)
args = parser.parse_args()
pptx_path = abspath(expanduser(args.pptx_path))
used = extract_used_fonts_from_pptx(pptx_path)
# Only build ODP families if we need to report substitutions
slide_fams: dict[int, set[str]] = {}
odp_available = False
if args.include_substituted:
with tempfile.TemporaryDirectory(prefix="soffice_profile_") as prof:
with tempfile.TemporaryDirectory(prefix="soffice_convert_") as out:
stem = splitext(basename(pptx_path))[0]
odp_path = _export_to_odp(pptx_path, prof, out, stem)
if odp_path:
slide_fams = _extract_slide_families_from_odp(odp_path)
odp_available = True
syn_map = _build_fc_synonym_map()
font_missing_by_slide: dict[int, list[str]] = {}
font_substituted_by_slide: dict[int, list[str]] = {}
for slide_num, req_fams in used.items():
if args.include_substituted and odp_available:
odp_fams = slide_fams.get(slide_num, set())
else:
odp_fams = set()
miss_missing: list[str] = []
miss_sub: list[str] = []
for req in req_fams:
fam_base, _ = parse_font_family_base_and_styles(req)
acceptable: set[str] = _expand_via_fontconfig(fam_base)
installed = any(alias in syn_map for alias in acceptable)
if args.include_missing and not installed:
miss_missing.append(req)
if (
args.include_substituted
and odp_available
and installed
and (req not in odp_fams)
and not (acceptable & odp_fams)
):
miss_sub.append(req)
if miss_missing:
font_missing_by_slide[slide_num] = sorted(miss_missing)
if miss_sub:
font_substituted_by_slide[slide_num] = sorted(miss_sub)
font_missing_overall: set[str] = (
set().union(*font_missing_by_slide.values()) if font_missing_by_slide else set()
)
font_substituted_overall: set[str] = (
set().union(*font_substituted_by_slide.values()) if font_substituted_by_slide else set()
)
if args.output_json:
payload: dict[str, object] = {}
if args.include_missing:
payload["font_missing_overall"] = sorted(font_missing_overall)
payload["font_missing_by_slide"] = {str(k): v for k, v in font_missing_by_slide.items()}
if args.include_substituted:
payload["font_substituted_overall"] = sorted(font_substituted_overall)
payload["font_substituted_by_slide"] = {
str(k): v for k, v in font_substituted_by_slide.items()
}
print(json.dumps(payload))
else:
any_missing = args.include_missing and bool(font_missing_overall)
any_sub = args.include_substituted and bool(font_substituted_overall)
if any_missing or any_sub:
if any_missing:
print("Fonts missing (not installed):")
print(", ".join(sorted(font_missing_overall)))
for slide_num in sorted(font_missing_by_slide.keys()):
print(f"Slide {slide_num} missing: ", end="")
print(", ".join(font_missing_by_slide[slide_num]))
if any_sub:
print("Fonts substituted (installed but substituted during rendering):")
print(", ".join(sorted(font_substituted_overall)))
for slide_num in sorted(font_substituted_by_slide.keys()):
print(f"Slide {slide_num} substituted: ", end="")
print(", ".join(font_substituted_by_slide[slide_num]))
else:
print("No font issues detected.")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
# Copyright (c) OpenAI. All rights reserved.
import argparse
import os
import re
import subprocess
import tempfile
import xml.etree.ElementTree as ET
from os import makedirs, replace
from os.path import abspath, basename, exists, expanduser, join, splitext
from typing import Sequence, cast
from zipfile import ZipFile
from pdf2image import convert_from_path, pdfinfo_from_path
EMU_PER_INCH: int = 914_400
def calc_dpi_via_ooxml(input_path: str, max_w_px: int, max_h_px: int) -> int:
"""Calculate DPI from OOXML `ppt/presentation.xml` slide size (cx/cy in EMUs)."""
with ZipFile(input_path, "r") as zf:
xml = zf.read("ppt/presentation.xml")
root = ET.fromstring(xml)
ns = {"p": "http://schemas.openxmlformats.org/presentationml/2006/main"}
sld_sz = root.find("p:sldSz", ns)
if sld_sz is None:
raise RuntimeError("Slide size not found in presentation.xml")
cx = int(sld_sz.get("cx") or 0)
cy = int(sld_sz.get("cy") or 0)
if cx <= 0 or cy <= 0:
raise RuntimeError("Invalid slide size values in presentation.xml")
width_in = cx / EMU_PER_INCH
height_in = cy / EMU_PER_INCH
return round(min(max_w_px / width_in, max_h_px / height_in))
def calc_dpi_via_pdf(input_path: str, max_w_px: int, max_h_px: int) -> int:
"""Compute DPI from PDF page size.
For non-PDF inputs, first convert to PDF via LibreOffice to read page size.
For PDFs, use the PDF directly (avoids unnecessary conversion and failures).
"""
is_pdf = input_path.lower().endswith(".pdf")
with tempfile.TemporaryDirectory(prefix="soffice_profile_") as user_profile:
with tempfile.TemporaryDirectory(prefix="soffice_convert_") as convert_tmp_dir:
stem = splitext(basename(input_path))[0]
pdf_path = (
input_path
if is_pdf
else convert_to_pdf(input_path, user_profile, convert_tmp_dir, stem)
)
if not (pdf_path and exists(pdf_path)):
raise RuntimeError("Failed to produce/read PDF for DPI computation.")
info = pdfinfo_from_path(pdf_path)
size_val = info.get("Page size")
if not size_val:
for k, v in info.items():
if isinstance(v, str) and "size" in k.lower() and "pts" in v:
size_val = v
break
if not isinstance(size_val, str):
raise RuntimeError("Failed to read PDF page size for DPI computation.")
def _parse_page_size_to_pts(s: str) -> tuple[float, float]:
# Common formats from poppler/pdfinfo:
# - "612 x 792 pts (letter)"
# - "595.276 x 841.89 pts (A4)"
# - sometimes inches: "8.5 x 11 in"
m_pts = re.search(
r"([0-9]+(?:\.[0-9]+)?)\s*x\s*([0-9]+(?:\.[0-9]+)?)\s*pts\b",
s,
)
if m_pts:
return float(m_pts.group(1)), float(m_pts.group(2))
m_in = re.search(
r"([0-9]+(?:\.[0-9]+)?)\s*x\s*([0-9]+(?:\.[0-9]+)?)\s*in\b",
s,
)
if m_in:
w_in = float(m_in.group(1))
h_in = float(m_in.group(2))
return w_in * 72.0, h_in * 72.0
# Sometimes poppler returns without an explicit unit; treat as points.
m = re.search(r"([0-9]+(?:\.[0-9]+)?)\s*x\s*([0-9]+(?:\.[0-9]+)?)\b", s)
if m:
return float(m.group(1)), float(m.group(2))
raise RuntimeError(f"Unrecognized PDF page size format: {s!r}")
width_pts, height_pts = _parse_page_size_to_pts(size_val)
width_in = width_pts / 72.0
height_in = height_pts / 72.0
if width_in <= 0 or height_in <= 0:
raise RuntimeError("Invalid PDF page size values.")
return round(min(max_w_px / width_in, max_h_px / height_in))
def run_cmd_no_check(cmd: list[str]) -> None:
subprocess.run(
cmd,
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=os.environ.copy(),
)
def convert_to_pdf(
pptx_path: str,
user_profile: str,
convert_tmp_dir: str,
stem: str,
) -> str:
# Try direct PPTX -> PDF
cmd_pdf = [
"soffice",
"-env:UserInstallation=file://" + user_profile,
"--invisible",
"--headless",
"--norestore",
"--convert-to",
"pdf",
"--outdir",
convert_tmp_dir,
pptx_path,
]
run_cmd_no_check(cmd_pdf)
pdf_path = join(convert_tmp_dir, f"{stem}.pdf")
if exists(pdf_path):
return pdf_path
# Fallback: PPTX -> ODP, then ODP -> PDF
# Rationale: Saving as ODP normalizes PPTX-specific constructs via the ODF serializer,
# which often bypasses Impress PDF export issues on problematic decks.
cmd_odp = [
"soffice",
"-env:UserInstallation=file://" + user_profile,
"--invisible",
"--headless",
"--norestore",
"--convert-to",
"odp",
"--outdir",
convert_tmp_dir,
pptx_path,
]
run_cmd_no_check(cmd_odp)
odp_path = join(convert_tmp_dir, f"{stem}.odp")
if exists(odp_path):
# ODP -> PDF
cmd_odp_pdf = [
"soffice",
"-env:UserInstallation=file://" + user_profile,
"--invisible",
"--headless",
"--norestore",
"--convert-to",
"pdf",
"--outdir",
convert_tmp_dir,
odp_path,
]
run_cmd_no_check(cmd_odp_pdf)
if exists(pdf_path):
return pdf_path
return ""
def rasterize(
input_path: str,
out_dir: str,
dpi: int,
) -> Sequence[str]:
"""Rasterise PPTX/PDF to PNG files placed in out_dir and return the image paths."""
makedirs(out_dir, exist_ok=True)
input_path = abspath(input_path)
stem = splitext(basename(input_path))[0]
# Use a unique user profile to avoid LibreOffice profile lock when running concurrently
with tempfile.TemporaryDirectory(prefix="soffice_profile_") as user_profile:
# Write conversion outputs into a temp directory to avoid any IO oddities
with tempfile.TemporaryDirectory(prefix="soffice_convert_") as convert_tmp_dir:
is_pdf = input_path.lower().endswith(".pdf")
pdf_path = (
input_path
if is_pdf
else convert_to_pdf(input_path, user_profile, convert_tmp_dir, stem)
)
if not pdf_path or not exists(pdf_path):
raise RuntimeError(
"Failed to produce PDF for rasterization (direct and ODP fallback)."
)
# Perform rasterization while the temp PDF still exists
paths_raw = cast(
list[str],
convert_from_path(
pdf_path,
dpi=dpi,
fmt="png",
thread_count=8,
output_folder=out_dir,
paths_only=True,
output_file="slide",
),
)
# Rename convert_from_path's output format f'slide{thread_id:04d}-{page_num:02d}.png'
slides = []
for src_path in paths_raw:
base = splitext(basename(src_path))[0]
slide_num_str = base.split("-")[-1]
slide_num = int(slide_num_str)
dst_path = join(out_dir, f"slide-{slide_num}.png")
replace(src_path, dst_path)
slides.append((slide_num, dst_path))
slides.sort(key=lambda t: t[0])
final_paths = [path for _, path in slides]
return final_paths
def main() -> None:
parser = argparse.ArgumentParser(description="Render slides to images.")
parser.add_argument(
"input_path",
type=str,
help="Path to the input PowerPoint or PDF file.",
)
parser.add_argument(
"--output_dir",
type=str,
default=None,
help=(
"Output directory for the rendered images. "
"Defaults to a folder next to the input named after the input file (without extension)."
),
)
parser.add_argument(
"--width",
type=int,
default=1600,
help=(
"Approximate maximum width in pixels after isotropic scaling (default 1600). "
"The actual value may exceed slightly."
),
)
parser.add_argument(
"--height",
type=int,
default=900,
help=(
"Approximate maximum height in pixels after isotropic scaling (default 900). "
"The actual value may exceed slightly."
),
)
args = parser.parse_args()
input_path = abspath(expanduser(args.input_path))
out_dir = abspath(expanduser(args.output_dir)) if args.output_dir else splitext(input_path)[0]
if input_path.lower().endswith((".pptx", ".ppsx", ".potx", ".pptm", ".ppsm", ".potm")):
dpi = calc_dpi_via_ooxml(input_path, args.width, args.height)
else:
dpi = calc_dpi_via_pdf(input_path, args.width, args.height)
rasterize(input_path, out_dir, dpi)
print("Slides rendered to " + out_dir)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
# Copyright (c) OpenAI. All rights reserved.
import argparse
import sys
import tempfile
from os.path import abspath, expanduser, join
from pathlib import Path
from typing import Sequence, cast
import numpy as np
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
import render_slides # type: ignore
from PIL import Image
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE
from pptx.util import Emu
# Configuration specific to overflow checking
PAD_PX: int = 100 # fixed padding on every side in pixels
PAD_RGB = (200, 200, 200)
EMU_PER_INCH: int = 914_400
def px_to_emu(px: int, dpi: int) -> Emu:
return Emu(int(px * EMU_PER_INCH // dpi))
def calc_tol(dpi: int) -> int:
"""Calculate per-channel colour tolerance appropriate for *dpi* (anti-aliasing tolerance)."""
if dpi >= 300:
return 0
# 1 at 250 DPI, 5 at 150 DPI, capped to 10.
tol = round((300 - dpi) / 25)
return min(max(tol, 1), 10)
def enlarge_deck(src: str, dst: str, pad_emu: Emu) -> tuple[int, int]:
"""Enlarge the input PPTX with a fixed grey padding and return the new page size."""
prs = Presentation(src)
w0 = cast(Emu, prs.slide_width)
h0 = cast(Emu, prs.slide_height)
w1 = Emu(w0 + 2 * pad_emu)
h1 = Emu(h0 + 2 * pad_emu)
prs.slide_width = w1
prs.slide_height = h1
for slide in prs.slides:
# Shift all shapes so the original canvas sits centred in the new deck.
for shp in list(slide.shapes):
shp.left = Emu(int(shp.left) + pad_emu)
shp.top = Emu(int(shp.top) + pad_emu)
pads = (
(Emu(0), Emu(0), pad_emu, h1), # left
(Emu(int(w1) - int(pad_emu)), Emu(0), pad_emu, h1), # right
(Emu(0), Emu(0), w1, pad_emu), # top
(Emu(0), Emu(int(h1) - int(pad_emu)), w1, pad_emu), # bottom
)
sp_tree = slide.shapes._spTree # pylint: disable=protected-access
for left, top, width, height in pads:
pad_shape = slide.shapes.add_shape(
MSO_AUTO_SHAPE_TYPE.RECTANGLE, left, top, width, height
)
pad_shape.fill.solid()
pad_shape.fill.fore_color.rgb = RGBColor(*PAD_RGB)
pad_shape.line.fill.background()
# Send pad behind all other shapes (index 2 after mandatory nodes)
sp_tree.remove(pad_shape._element)
sp_tree.insert(2, pad_shape._element)
prs.save(dst)
return int(w1), int(h1)
def inspect_images(
paths: Sequence[str],
pad_ratio_w: float,
pad_ratio_h: float,
dpi: int,
) -> list[int]:
"""Return 1-based indices of slides that contain pixels outside the pad."""
tol = calc_tol(dpi)
failures: list[int] = []
pad_colour = np.array(PAD_RGB, dtype=np.uint8)
for idx, img_path in enumerate(paths, start=1):
with Image.open(img_path) as img:
rgb = img.convert("RGB")
arr = np.asarray(rgb)
h, w, _ = arr.shape
# Exclude the innermost 1-pixel band
pad_x = int(w * pad_ratio_w) - 1
pad_y = int(h * pad_ratio_h) - 1
left_margin = arr[:, :pad_x, :]
right_margin = arr[:, w - pad_x :, :]
top_margin = arr[:pad_y, :, :]
bottom_margin = arr[h - pad_y :, :, :]
def _is_clean(margin: np.ndarray) -> bool:
diff = np.abs(margin.astype(np.int16) - pad_colour)
matches = np.all(diff <= tol, axis=-1)
mismatch_fraction = 1.0 - (np.count_nonzero(matches) / matches.size)
if dpi >= 300:
max_mismatch = 0.01
elif dpi >= 200:
max_mismatch = 0.02
else:
max_mismatch = 0.03
return mismatch_fraction <= max_mismatch
if not (
_is_clean(left_margin)
and _is_clean(right_margin)
and _is_clean(top_margin)
and _is_clean(bottom_margin)
):
failures.append(idx)
return failures
def main() -> None:
parser = argparse.ArgumentParser(
description=(
"Check a PPTX for content overflowing the original canvas by rendering with padding "
"and inspecting the margins."
)
)
parser.add_argument(
"input_path",
type=str,
help="Path to the input PPTX file.",
)
parser.add_argument(
"--width",
type=int,
default=1600,
help=(
"Approximate maximum width in pixels after isotropic scaling (default 1600). "
"The actual value may exceed slightly."
),
)
parser.add_argument(
"--height",
type=int,
default=900,
help=(
"Approximate maximum height in pixels after isotropic scaling (default 900). "
"The actual value may exceed slightly."
),
)
parser.add_argument(
"--pad_px",
type=int,
default=PAD_PX,
help="Padding in pixels to add on each side before rasterization.",
)
args = parser.parse_args()
input_path = abspath(expanduser(args.input_path))
# Width and height refer to the original, unaltered slide dimensions.
dpi = render_slides.calc_dpi_via_ooxml(input_path, args.width, args.height)
# Not using ``tempfile.TemporaryDirectory(delete=False)`` for Python 3.11 compatibility.
tmpdir = tempfile.mkdtemp()
enlarged_pptx = join(tmpdir, "enlarged.pptx")
pad_emu = px_to_emu(args.pad_px, dpi)
w1, h1 = enlarge_deck(input_path, enlarged_pptx, pad_emu=pad_emu)
pad_ratio_w = pad_emu / w1
pad_ratio_h = pad_emu / h1
img_dir = join(tmpdir, "imgs")
img_paths = render_slides.rasterize(enlarged_pptx, img_dir, dpi)
failing = inspect_images(img_paths, pad_ratio_w, pad_ratio_h, dpi)
if failing:
print(
"ERROR: Slides with content overflowing original canvas (1-based indexing): "
+ ", ".join(map(str, failing))
+ "\n"
+ "Rendered images with grey paddings for problematic slides are available at: "
)
for i in failing:
print(img_paths[i - 1])
else:
print("Test passed. No overflow detected.")
if __name__ == "__main__":
main()
Related skills
FAQ
What does slides do?
slides builds PPTX decks with PptxGenJS and validates via render_slides.
When should I use slides?
User asks about slides, create and edit presentation slide decks (`.pptx`) with pptxgenjs and render/val.
Is this skill safe to install?
Review the Security Audits panel on this page before installing in production.