feat(agents): добавлены skills для PDF, docs, slides, jupyter и spreadsheet

- Зачем:
  - расширены возможности AI-ассистентов для работы с документами и презентациями
- Что:
  - добавлен skill pdf для работы с PDF файлами
  - добавлен skill doc для работы с Word документами
  - добавлен skill slides для создания презентаций PowerPoint
  - добавлен skill jupyter-notebook для работы с Jupyter ноутбуками
  - добавлен skill spreadsheet для работы с таблицами (Excel/CSV)
  - изменена модель Claude с sonnet на opus
- Проверка:
  - проверка доступности skills в AI-ассистентах
This commit is contained in:
ddadmin committed 2026-03-13 23:35:45 +03:00
1 parent bd06d5734c
commit 0009270c6e
53 files changed
+6773 -1

No files matched your search

@@ -0,0 +1,300 @@
#!/usr/bin/env python3
# Copyright (c) OpenAI. All rights reserved.
import argparse
import re
import sys
import tempfile
from math import ceil
from os import listdir
from os.path import basename, expanduser, isfile, join, splitext
from pathlib import Path
from typing import Literal
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from ensure_raster_image import SUPPORTED_EXTS, ensure_raster_image # type: ignore
from PIL import Image, ImageDraw, ImageFont, ImageOps
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], retain_converted_files: bool, 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] = []
if retain_converted_files:
for p in input_files:
try:
images.append(Image.open(ensure_raster_image(p)))
except Exception as e:
if fail_on_image_error:
raise
print(f'Warning: Failed to convert or load image "{p}": {e}')
images.append(None)
else:
with tempfile.TemporaryDirectory(prefix="montage_convert_") as tmp_conv:
for p in input_files:
try:
images.append(Image.open(ensure_raster_image(p, tmp_conv)))
except Exception as e:
if fail_on_image_error:
raise
print(f'Warning: Failed to convert or 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"],
retain_converted_files: bool = False,
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,
retain_converted_files=retain_converted_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(
"--retain_converted_files",
action="store_true",
default=False,
help=(
"If set, write converted images (e.g., SVG->PNG, WDP->PNG) next to the original files "
"instead of a temporary directory."
),
)
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,
retain_converted_files=args.retain_converted_files,
fail_on_image_error=args.fail_on_image_error,
)
if __name__ == "__main__":
main()
@@ -0,0 +1,873 @@
#!/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()
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
"""Copyright (c) OpenAI. All rights reserved.
Ensures input images are rasterized, converting to PNG when needed. Primarily used to
preview image assets extracted from PowerPoint files.
Dependencies used by this tool:
- Inkscape: SVG/EMF/WMF rasterization
- ImageMagick: format bridging (TIFF→PNG, generic convert)
- Ghostscript: PDF/EPS/PS rasterization (first page)
- libheif-examples: heif-convert for HEIC/HEIF → PNG
- jxr-tools (or libjxr-tools on older distros): JxrDecApp for JPEG XR (JXR/WDP)
Install (Ubuntu/Debian):
sudo apt-get update
sudo apt-get install -y inkscape imagemagick ghostscript libheif-examples jxr-tools
# If jxr-tools not found on your distro, try:
# sudo apt-get install -y libjxr-tools
Verify:
inkscape --version
convert -version | grep -i "ImageMagick"
gs -v
heif-convert -h
JxrDecApp -h
"""
import argparse
import gzip
import shutil
from os import listdir
from os.path import basename, dirname, expanduser, isfile, join, splitext
from subprocess import run
RASTER_EXTS = {
".png",
".jpg",
".jpeg",
".bmp",
".gif",
".tif",
".tiff",
".webp",
}
CONVERTIBLE_EXTS = {
# Windows metafiles (and compressed variants)
".emf",
".wmf",
".emz",
".wmz",
# SVG
".svg",
".svgz",
# JPEG XR / HD Photo
".wdp",
".jxr",
# HEIF family
".heic",
".heif",
# Page-description formats (rasterize first page)
".pdf",
".eps",
".ps",
}
SUPPORTED_EXTS = RASTER_EXTS | CONVERTIBLE_EXTS
def _imagemagick_convert(src_path: str, dst_path: str) -> None:
binary = shutil.which("magick") or "convert"
run([binary, src_path, dst_path], check=True)
def ensure_raster_image(path: str, out_dir: str | None = None) -> str:
"""Return a raster image path for the given input, converting when needed.
- EMF/WMF/EMZ/WMZ are rasterized via Inkscape (EMZ/WMZ are decompressed first)
- SVG/SVGZ are rasterized via Inkscape
- WDP/JXR are converted via ImageMagick (if codec available)
- Known raster formats are returned as-is
Raises ValueError if the extension is not supported.
"""
base, ext = splitext(path)
ext_lower = ext.lower()
out_dir = out_dir or dirname(path)
out_path = join(out_dir, basename(base) + ".png")
# Convertible formats
if ext_lower in (".emf", ".wmf"):
run(["inkscape", path, "-o", out_path], check=True)
if isfile(out_path):
return out_path
raise RuntimeError("inkscape reported success but output file not found: " + out_path)
if ext_lower in (".emz", ".wmz"):
# Decompress into EMF/WMF then rasterize with Inkscape
decompressed = join(out_dir, basename(base) + (".emf" if ext_lower == ".emz" else ".wmf"))
with gzip.open(path, "rb") as zin, open(decompressed, "wb") as zout:
zout.write(zin.read())
run(
["inkscape", decompressed, "-o", out_path],
check=True,
)
if isfile(out_path):
return out_path
raise RuntimeError("inkscape reported success but output file not found: " + out_path)
if ext_lower in (".svg", ".svgz"):
run(["inkscape", path, "-o", out_path], check=True)
if isfile(out_path):
return out_path
raise RuntimeError("inkscape reported success but output file not found: " + out_path)
if ext_lower in (".wdp", ".jxr"):
tmp_tiff = join(out_dir, basename(base) + ".tiff")
run(["JxrDecApp", "-i", path, "-o", tmp_tiff], check=True)
_imagemagick_convert(tmp_tiff, out_path)
if isfile(out_path):
return out_path
raise RuntimeError("JPEG XR decode succeeded but PNG not found: " + out_path)
if ext_lower in (".heic", ".heif"):
# Use libheif's CLI for robust conversion
heif_convert = shutil.which("heif-convert") or "heif-convert"
run([heif_convert, path, out_path], check=True)
if isfile(out_path):
return out_path
raise RuntimeError("heif-convert reported success but output file not found: " + out_path)
if ext_lower in (".pdf", ".eps", ".ps"):
# Rasterize first page via Ghostscript
gs = shutil.which("gs") or "gs"
run(
[
gs,
"-dSAFER",
"-dBATCH",
"-dNOPAUSE",
"-sDEVICE=pngalpha",
"-dFirstPage=1",
"-dLastPage=1",
"-r200",
"-o",
out_path,
path,
],
check=True,
)
if isfile(out_path):
return out_path
raise RuntimeError("Ghostscript reported success but output file not found: " + out_path)
if ext_lower in RASTER_EXTS:
return path
raise ValueError(f"Unsupported image format for montage: {path}")
def main() -> None:
parser = argparse.ArgumentParser(
description=("Ensure input images are rasterized; convert to PNG if needed.")
)
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_dir",
default=None,
help=(
"Directory to write converted PNGs. If omitted, converted files are written next to inputs."
),
)
args = parser.parse_args()
if args.input_files:
paths = [expanduser(p) for p in args.input_files]
else:
input_dir = expanduser(args.input_dir)
names = listdir(input_dir)
paths = [
join(input_dir, f)
for f in names
if isfile(join(input_dir, f)) and splitext(f)[1].lower() in SUPPORTED_EXTS
]
if not paths:
raise SystemExit("No files with supported extensions in input_dir")
out_dir = expanduser(args.output_dir) if args.output_dir else None
converted_paths = []
for p in paths:
if ensure_raster_image(p, out_dir) != p:
converted_paths.append(p)
if converted_paths:
print("Converted the following files to PNG:\n" + "\n".join(converted_paths))
if __name__ == "__main__":
main()
@@ -0,0 +1,273 @@
#!/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()
@@ -0,0 +1,201 @@
#!/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()