
Slides
- 42 installs
- 946 repo stars
- Updated August 2, 2026
- fcakyon/claude-codex-settings
Helps with ai & agent building tasks.
About
slides is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- slides
- AI & Agent Building
- AI-coding skill
Slides by the numbers
- 42 all-time installs (skills.sh)
- Ranked #7,990 of 16,556 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/fcakyon/claude-codex-settings --skill slidesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 42 |
|---|---|
| repo stars | ★ 946 |
| Last updated | August 2, 2026 |
| Repository | fcakyon/claude-codex-settings ↗ |
What it does
Helps with ai & agent building tasks.
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
assets/pptxgenjs_helpers/: Copy this folder into the deck workspace and import it locally instead of reimplementing helper logic.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.scripts/ensure_raster_image.py: Convert SVG/EMF/HEIC/PDF-like assets into PNGs for quick inspection.references/pptxgenjs-helpers.md: Load only when you need API details or dependency notes.
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. Copy assets/pptxgenjs_helpers/ into the working directory and import the helpers from there. 4. Build the deck in JavaScript with an explicit theme font, stable spacing, and editable PowerPoint-native elements when practical. 5. 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. 6. Run slides_test.py for overflow checks when slide edges are tight or the deck is dense. 7. 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.
- Use
autoFontSize,calcTextBox, and related helpers to size text boxes; do not use PptxGenJSfitorautoFit. - Use bullet options, not literal
•characters. - Use
imageSizingCroporimageSizingContaininstead of PptxGenJS built-in image sizing. - Use
latexToSvgDataUri()for equations andcodeToRuns()for syntax-highlighted code blocks. - 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.
- Include both
warnIfSlideHasOverlaps(slide, pptx)andwarnIfSlideElementsOutOfBounds(slide, pptx)in the submitted JavaScript whenever you generate or substantially edit slides. - Fix all unintentional overlap and out-of-bounds warnings 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.
- If a reference slide uses raster artwork, use
ensure_raster_image.pyto generate debug PNGs from vector or odd image formats before placing them.
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 --jsonLoad references/pptxgenjs-helpers.md if you need the helper API summary or dependency details.
// Copyright (c) OpenAI. All rights reserved.
"use strict";
const fs = require("fs");
const Prism = require("prismjs");
let THEME_MAP;
function loadPrismLanguage(lang) {
const normalized = String(lang || "plaintext").toLowerCase();
const known = new Set([
"markup",
"html",
"xml",
"svg",
"mathml",
"css",
"clike",
"javascript",
"js",
"typescript",
"ts",
"python",
"py",
"bash",
"sh",
"json",
"yaml",
"yml",
]);
const map = {
js: "javascript",
ts: "typescript",
py: "python",
sh: "bash",
yml: "yaml",
html: "markup",
xml: "markup",
};
const id = map[normalized] || normalized;
if (!Prism.languages[id]) {
try {
require(`prismjs/components/prism-${id}`);
} catch (_e) {}
}
return Prism.languages[id] || Prism.languages.plain || {};
}
function buildThemeMap(themeCssModule = "prismjs/themes/prism-okaidia.css") {
try {
const css = fs.readFileSync(require.resolve(themeCssModule), "utf8");
return Object.fromEntries(
[
...css.matchAll(
/\.token\.([\w-]+)[^{]*\{[^}]*color:\s*([^;\s]+)[^}]*\}/g
),
].map(([, t, c]) => [t, c.replace(/#|!important/g, "").trim()])
);
} catch (err) {
return { plain: "FFFFFF", comment: "999999" };
}
}
function getThemeMap() {
if (!THEME_MAP) THEME_MAP = buildThemeMap();
return THEME_MAP;
}
function run(text, type = "plain") {
const theme = getThemeMap();
return {
text,
options: {
fontFace: "Consolas",
color: theme[type] || theme.plain || "FFFFFF",
fontSize: 14,
},
};
}
function tokensToRuns(tokens) {
return tokens.flatMap((t) =>
typeof t === "string"
? [run(t)]
: Array.isArray(t.content)
? tokensToRuns(t.content)
: [run(t.content, t.type)]
);
}
function codeToRuns(code, lang) {
const grammar = loadPrismLanguage(lang);
const lines = String(code || "").split("\n");
const pad = lines.length.toString().length;
return lines.flatMap((line, i) => [
run(`${(i + 1).toString().padStart(pad, " ")} `, "comment"),
...tokensToRuns(Prism.tokenize(line, grammar)),
...(i < lines.length - 1 ? [run("\n")] : []),
]);
}
module.exports = {
codeToRuns,
buildThemeMap,
};
// Copyright (c) OpenAI. All rights reserved.
"use strict";
const fs = require("fs");
// Accept either a filesystem path, a data URI, raw SVG string, or a Buffer
// and normalize to a Buffer for type/size probing.
function readInputAsBuffer(source) {
if (!source) throw new Error("Image source is empty");
if (Buffer.isBuffer(source)) return { buffer: source, type: "buffer" };
if (typeof source === "string") {
// data URI (we primarily emit base64 data URIs for SVG via helpers)
if (source.startsWith("data:")) {
const type = "dataUri";
const comma = source.indexOf(",");
const payload = comma !== -1 ? source.slice(comma + 1) : source;
// Our helpers use base64; if not, try URI decode then treat as raw text
try {
return { buffer: Buffer.from(payload, "base64"), type: type };
} catch (_e) {
try {
return {
buffer: Buffer.from(decodeURIComponent(payload), "utf8"),
type: type,
};
} catch (_e2) {
return { buffer: Buffer.from(payload, "utf8"), type: type };
}
}
}
// Raw inline SVG string
if (source.includes("<svg")) {
return { buffer: Buffer.from(source, "utf8"), type: "rawSvg" };
}
// Treat as filesystem path
return { buffer: fs.readFileSync(source), type: "path" };
}
throw new Error("Unsupported image source type");
}
function isPng(buf) {
return (
buf.length >= 24 &&
buf[0] === 0x89 &&
buf[1] === 0x50 &&
buf[2] === 0x4e &&
buf[3] === 0x47 &&
buf[4] === 0x0d &&
buf[5] === 0x0a &&
buf[6] === 0x1a &&
buf[7] === 0x0a
);
}
function isJpeg(buf) {
return (
buf.length > 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff
);
}
function isGif(buf) {
return (
buf.length >= 10 &&
buf[0] === 0x47 &&
buf[1] === 0x49 &&
buf[2] === 0x46 &&
buf[3] === 0x38 &&
(buf[4] === 0x39 || buf[4] === 0x37) &&
buf[5] === 0x61
);
}
function isWebp(buf) {
return (
buf.length >= 16 &&
buf[0] === 0x52 &&
buf[1] === 0x49 &&
buf[2] === 0x46 &&
buf[3] === 0x46 &&
buf[8] === 0x57 &&
buf[9] === 0x45 &&
buf[10] === 0x42 &&
buf[11] === 0x50
);
}
function isSvg(buf) {
const head = buf.slice(0, 200).toString("utf8");
return head.includes("<svg");
}
function readPngSize(buf) {
// IHDR chunk: width/height at offset 16, big-endian
const width = buf.readUInt32BE(16);
const height = buf.readUInt32BE(20);
return { width, height, type: "png" };
}
function readGifSize(buf) {
const width = buf.readUInt16LE(6);
const height = buf.readUInt16LE(8);
return { width, height, type: "gif" };
}
function readWebpSize(buf) {
// Handle VP8X, VP8L, VP8
// Reference: https://developers.google.com/speed/webp/docs/riff_container
const riffSize = buf.readUInt32LE(4) + 8;
let offset = 12; // start of first chunk
while (offset + 8 <= riffSize && offset + 8 <= buf.length) {
const chunkTag = buf.slice(offset, offset + 4).toString("ascii");
const chunkSize = buf.readUInt32LE(offset + 4);
if (chunkTag === "VP8X") {
// Canvas size stored at bytes 12..17 (6 bytes), 24 bits each minus 1
const wMinus1 = buf.readUIntLE(offset + 12, 3);
const hMinus1 = buf.readUIntLE(offset + 15, 3);
return { width: wMinus1 + 1, height: hMinus1 + 1, type: "webp" };
}
if (chunkTag === "VP8 ") {
// Lossy bitstream: frame header at start of data
// Parse minimally for width/height
const start = offset + 8;
if (start + 10 < buf.length) {
const width = buf.readUInt16LE(start + 6) & 0x3fff;
const height = buf.readUInt16LE(start + 8) & 0x3fff;
return { width, height, type: "webp" };
}
}
if (chunkTag === "VP8L") {
// Lossless bitstream: 14-bit width/height encoded
const start = offset + 8;
if (start + 5 <= buf.length) {
const b0 = buf[start + 1];
const b1 = buf[start + 2];
const b2 = buf[start + 3];
const b3 = buf[start + 4];
const width = 1 + (((b1 & 0x3f) << 8) | b0);
const height =
1 + (((b3 & 0xf) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6));
return { width, height, type: "webp" };
}
}
offset += 8 + ((chunkSize + 1) & ~1); // chunks are padded to even size
}
throw new Error("Unsupported WEBP variant for size detection");
}
function readJpegSize(buf) {
let offset = 2;
while (offset < buf.length) {
if (buf[offset] !== 0xff) {
offset++;
continue;
}
const marker = buf[offset + 1];
// SOF0..SOF3, SOF5..SOF7, SOF9..SOF11, SOF13..SOF15
if (
(marker >= 0xc0 && marker <= 0xc3) ||
(marker >= 0xc5 && marker <= 0xc7) ||
(marker >= 0xc9 && marker <= 0xcb) ||
(marker >= 0xcd && marker <= 0xcf)
) {
const blockLength = buf.readUInt16BE(offset + 2);
const height = buf.readUInt16BE(offset + 5);
const width = buf.readUInt16BE(offset + 7);
return { width, height, type: "jpeg" };
}
const blockLength = buf.readUInt16BE(offset + 2);
if (!Number.isFinite(blockLength) || blockLength < 2) break;
offset += 2 + blockLength;
}
throw new Error("JPEG size not found");
}
function parseSvgSize(buf) {
const text = buf.toString("utf8");
const a = text.indexOf("<svg");
const b = text.indexOf("</svg>");
const inner = a !== -1 && b !== -1 ? text.slice(a, b + 6) : text;
const widthMatch = inner.match(/\bwidth\s*=\s*"([^"]+)"/i);
const heightMatch = inner.match(/\bheight\s*=\s*"([^"]+)"/i);
const viewBoxMatch = inner.match(/\bviewBox\s*=\s*"([^"]+)"/i);
function toPx(v) {
if (!v) return null;
const m = String(v)
.trim()
.match(/([0-9.]+)\s*(px|pt|em|ex|cm|mm|in|%)?/i);
if (!m) return null;
const n = parseFloat(m[1]);
const unit = (m[2] || "px").toLowerCase();
const dpi = 96;
switch (unit) {
case "px":
return n;
case "pt":
return (n * dpi) / 72;
case "in":
return n * dpi;
case "cm":
return (n * dpi) / 2.54;
case "mm":
return (n * dpi) / 25.4;
case "em":
case "ex":
return n * 16; // rough fallback
default:
return null;
}
}
let widthPx = widthMatch ? toPx(widthMatch[1]) : null;
let heightPx = heightMatch ? toPx(heightMatch[1]) : null;
if ((widthPx == null || heightPx == null) && viewBoxMatch) {
const parts = viewBoxMatch[1].trim().split(/\s+/).map(Number);
if (parts.length === 4) {
const vbw = parts[2];
const vbh = parts[3];
if (!widthPx && vbh) widthPx = vbw;
if (!heightPx && vbw) heightPx = vbh;
}
}
if (!widthPx || !heightPx) {
// Fallback if sizes missing
widthPx = widthPx || 100;
heightPx = heightPx || 100;
}
return { width: widthPx, height: heightPx, type: "svg" };
}
function getImageDimensions(pathOrData) {
const { buffer: buf, type } = readInputAsBuffer(pathOrData);
let meta;
if (isPng(buf)) meta = readPngSize(buf);
else if (isJpeg(buf)) meta = readJpegSize(buf);
else if (isGif(buf)) meta = readGifSize(buf);
else if (isWebp(buf)) meta = readWebpSize(buf);
else if (isSvg(buf)) meta = parseSvgSize(buf);
else {
const suffix =
type === "path" && typeof pathOrData === "string"
? ` (path: ${pathOrData})`
: "";
throw new Error("Unsupported image format for provided source" + suffix);
}
const aspectRatio =
meta.width > 0 && meta.height > 0 ? meta.width / meta.height : 1;
return {
width: meta.width,
height: meta.height,
aspectRatio,
type: meta.type,
};
}
function imageSizingCrop(source, x, y, w, h, cx, cy, cw, ch) {
const { aspectRatio } = getImageDimensions(source);
const boxAspect = w / h;
if (
cx === undefined ||
cy === undefined ||
cw === undefined ||
ch === undefined
) {
let cropXFrac, cropYFrac, cropWFrac, cropHFrac;
if (aspectRatio >= boxAspect) {
cropHFrac = 1;
cropWFrac = boxAspect / aspectRatio;
cropXFrac = (1 - cropWFrac) / 2;
cropYFrac = 0;
} else {
cropWFrac = 1;
cropHFrac = aspectRatio / boxAspect;
cropXFrac = 0;
cropYFrac = (1 - cropHFrac) / 2;
}
cx = cropXFrac;
cy = cropYFrac;
cw = cropWFrac;
ch = cropHFrac;
}
let virtualW = w / cw;
let virtualH = virtualW / aspectRatio;
const eps = 1e-6;
if (Math.abs(virtualH * ch - h) > eps) {
virtualH = h / ch;
virtualW = virtualH * aspectRatio;
}
const cropXIn = cx * virtualW;
const cropYIn = cy * virtualH;
return {
x,
y,
w: virtualW,
h: virtualH,
sizing: {
type: "crop",
x: cropXIn,
y: cropYIn,
w: w,
h: h,
},
};
}
function imageSizingContain(source, x, y, w, h) {
const { aspectRatio } = getImageDimensions(source);
let w2, h2;
const boxAspect = w / h;
if (aspectRatio >= boxAspect) {
w2 = w;
h2 = w2 / aspectRatio;
} else {
h2 = h;
w2 = h2 * aspectRatio;
}
return {
x: x + (w - w2) / 2,
y: y + (h - h2) / 2,
w: w2,
h: h2,
};
}
module.exports = {
getImageDimensions,
imageSizingCrop,
imageSizingContain,
};
// Copyright (c) OpenAI. All rights reserved.
"use strict";
const VERSION = "1.2.0";
const text = require("./text");
const image = require("./image");
const svg = require("./svg");
const latex = require("./latex");
const code = require("./code");
const layout = require("./layout");
const layoutBuilders = require("./layout_builders");
const util = require("./util");
module.exports = {
VERSION,
// text layout
...text,
// images
...image,
// svg helpers
...svg,
// LaTeX -> SVG
...latex,
// code block -> pptx text runs
...code,
// slide layout analyzers
...layout,
// slide layout builders
...layoutBuilders,
// text layout helpers and utilities
...util,
};
// Copyright (c) OpenAI. All rights reserved.
"use strict";
let _mathjax;
let _adaptor;
let _doc;
function ensureMathJax() {
if (_mathjax && _adaptor && _doc) return;
try {
const { mathjax } = require("mathjax-full/js/mathjax.js");
const { TeX } = require("mathjax-full/js/input/tex.js");
const { SVG } = require("mathjax-full/js/output/svg.js");
const { liteAdaptor } = require("mathjax-full/js/adaptors/liteAdaptor.js");
const { RegisterHTMLHandler } = require("mathjax-full/js/handlers/html.js");
const { AllPackages } = require("mathjax-full/js/input/tex/AllPackages.js");
_adaptor = liteAdaptor();
RegisterHTMLHandler(_adaptor);
const tex = new TeX({ packages: AllPackages });
const out = new SVG({ fontCache: "local" });
_doc = mathjax.document("", { InputJax: tex, OutputJax: out });
_mathjax = mathjax;
} catch (err) {
throw new Error(
"mathjax-full is not installed. Run `npm i mathjax-full` or avoid latexToSvgDataUri()."
);
}
}
function latexToSvgDataUri(latex, display = true) {
ensureMathJax();
const html = _adaptor.outerHTML(_doc.convert(latex, { display }));
const a = html.indexOf("<svg");
const b = html.indexOf("</svg>");
let svg = a !== -1 && b !== -1 ? html.slice(a, b + 6) : html;
svg = svg.replace(/<\?xml[^>]*>/g, "");
if (!/xmlns=\"http:\/\/www\.w3\.org\/2000\/svg\"/.test(svg)) {
svg = svg.replace(/<svg /, '<svg xmlns="http://www.w3.org/2000/svg" ');
}
svg = svg.replace(/(width|height)=\"([0-9.]+)(ex|em)\"/g, (_m, attr, num) => {
const px = Math.round(parseFloat(num) * 8.5);
return `${attr}="${px}px"`;
});
svg = svg.replace(/currentColor/g, "#000000");
return "data:image/svg+xml;base64," + Buffer.from(svg).toString("base64");
}
module.exports = {
latexToSvgDataUri,
};
// Copyright (c) OpenAI. All rights reserved.
"use strict";
const { calcTextBox, autoFontSize } = require("./text");
const { imageSizingCrop, imageSizingContain } = require("./image");
const { getSlideDimensions } = require("./layout");
module.exports = {
addImageTextCard,
addCardRow,
addThreeLevelTree,
};
function addImageTextCard(slide, opts = {}) {
const x = toNumberOr(opts.x, 0);
const y = toNumberOr(opts.y, 0);
const w = toNumberOr(opts.width, 3.0);
const gap = toNumberOr(opts.gap, 0.15);
const image = opts.image || {};
const text = opts.text || "";
const textBox = opts.textBox || {};
const boxH = toNumberOr(image.boxHeight, 2.2);
const sizing = (image.sizing || "crop").toLowerCase();
let imgPlacement;
if (image.path || image.data) {
const base = image.path ? { path: image.path } : { data: image.data };
if (sizing === "contain") {
imgPlacement = imageSizingContain(
image.path || image.data,
x,
y,
w,
boxH
);
slide.addImage({ ...base, ...imgPlacement });
} else {
const c = image.crop || {};
imgPlacement = imageSizingCrop(
image.path || image.data,
x,
y,
w,
boxH,
c.cx,
c.cy,
c.cw,
c.ch
);
slide.addImage({ ...base, ...imgPlacement });
}
}
const textY = y + boxH + gap;
const fontSize = toNumberOr(textBox.fontSize, 14);
const fontFaceRaw = textBox.fontFace;
const fontFace =
typeof fontFaceRaw === "string" && fontFaceRaw.trim().length > 0
? fontFaceRaw.trim()
: null;
if (!fontFace) {
throw new Error(
"addImageTextCard(): textBox.fontFace is required for text measurement."
);
}
let hText;
let textOptions;
if (textBox.h != null && Number.isFinite(toNumberOr(textBox.h, NaN))) {
// Layout-first: caller fixed the box height, so adjust font size to fit via autoFontSize.
const fixedH = toNumberOr(textBox.h, 0);
const baseOpts = {
x,
y: textY,
w,
h: fixedH,
mode: textBox.mode || "auto",
fontSize,
minFontSize: textBox.minFontSize,
maxFontSize: textBox.maxFontSize,
margin: textBox.margin,
paraSpaceAfter: textBox.paraSpaceAfter,
};
const autoOpts = autoFontSize(text, fontFace, baseOpts);
hText = fixedH;
textOptions = {
...autoOpts,
fontFace,
color: textBox.color,
align: textBox.align,
valign: textBox.valign || "top",
fill: opts.background,
};
} else {
// Content-first: fixed font size, let calcTextBox derive the required height.
const layout = calcTextBox(fontSize, {
text,
w,
fontFace,
margin: textBox.margin,
paraSpaceAfter: textBox.paraSpaceAfter,
});
hText = layout.h;
textOptions = {
x,
y: textY,
w,
h: hText,
fontFace,
fontSize,
color: textBox.color,
align: textBox.align,
valign: textBox.valign || "top",
paraSpaceAfter: textBox.paraSpaceAfter,
margin: textBox.margin,
fill: opts.background,
};
}
slide.addText(text, textOptions);
return {
x,
y,
w,
image: {
x: imgPlacement?.x ?? x,
y,
w: imgPlacement?.w ?? w,
h: imgPlacement?.h ?? boxH,
},
text: { x, y: textY, w, h: hText },
};
}
function addCardRow(slide, region, cards = [], options = {}) {
const rx = toNumberOr(region.x, 0.4);
const ry = toNumberOr(region.y, 1.6);
const slideWidth = getSlideDimensions(slide).width;
const rw = toNumberOr(region.w, slideWidth - rx * 2);
const gap = toNumberOr(options.gap, 0.25);
const count = cards.length;
if (count === 0) return [];
let cardW;
if (options.widthStrategy === "fixed") {
cardW = toNumberOr(
options.cardWidth,
rw / count - (gap * (count - 1)) / count
);
} else {
cardW = (rw - gap * (count - 1)) / count;
}
const totalWidth = cardW * count + gap * (count - 1);
const align = options.align || "left";
const ox =
align === "center"
? (rw - totalWidth) / 2
: align === "right"
? rw - totalWidth
: 0;
const placements = [];
for (let i = 0; i < count; i++) {
const x = rx + ox + i * (cardW + gap);
placements.push(
addImageTextCard(slide, { ...cards[i], x, y: ry, width: cardW })
);
}
return placements;
}
function addThreeLevelTree(slide, opts = {}) {
const slideWidth = getSlideDimensions(slide).width;
const cx = toNumberOr(opts.centerX, slideWidth / 2);
const topY = toNumberOr(opts.topY, 1.6);
const rootW = toNumberOr(opts.root?.w, 3.3333333);
const rootH = toNumberOr(opts.root?.h, 0.93333333);
const rootX = cx - rootW / 2;
const rootFontFaceRaw = opts.root?.fontFace;
const rootFontFace =
typeof rootFontFaceRaw === "string" && rootFontFaceRaw.trim().length > 0
? rootFontFaceRaw.trim()
: null;
if (!rootFontFace) {
throw new Error(
"addThreeLevelTree(): opts.root.fontFace is required for text measurement."
);
}
const rootFontSize = toNumberOr(opts.root?.fontSize, 16);
const rootText = opts.root?.text || "";
const rootTextOpts = autoFontSize(rootText, rootFontFace, {
x: rootX,
y: topY,
w: rootW,
h: rootH,
mode: opts.root?.mode || "shrink",
fontSize: rootFontSize,
minFontSize: opts.root?.minFontSize,
maxFontSize: opts.root?.maxFontSize,
});
slide.addText(rootText, {
...rootTextOpts,
align: "center",
valign: "mid",
fontFace: rootFontFace,
color: opts.root?.color || "FFFFFF",
fill: { color: opts.root?.fill || "0B0F1A" },
line: { color: opts.root?.line || opts.root?.fill || "0B0F1A" },
});
const midLabels = Array.isArray(opts.mid?.labels) ? opts.mid.labels : [];
const midFontFaceRaw = opts.mid?.fontFace;
const midFontFace =
typeof midFontFaceRaw === "string" && midFontFaceRaw.trim().length > 0
? midFontFaceRaw.trim()
: null;
if (!midFontFace) {
throw new Error(
"addThreeLevelTree(): opts.mid.fontFace is required for text measurement."
);
}
let midW = toNumberOr(opts.mid?.w, NaN);
const midH = toNumberOr(opts.mid?.h, rootH);
const midY = toNumberOr(opts.mid?.y, topY + rootH + 1.2);
const requestedSpacing = toNumberOr(opts.mid?.spacing, NaN); // center-to-center distance if provided
const leftRightMargin = toNumberOr(opts.mid?.marginX, 0.6);
const availableRowWidth = slideWidth - leftRightMargin * 2;
const countMid = midLabels.length;
const minGap = 0.4;
if (!Number.isFinite(midW) && Number.isFinite(requestedSpacing)) {
// Derive midW from spacing and available width
const totalSpan = requestedSpacing * (countMid - 1) + 0; // span between first and last centers
const maxW = Math.min(rootW, (availableRowWidth - totalSpan) / countMid);
midW = Math.max(0.8, maxW);
}
if (!Number.isFinite(midW)) {
// Fit equally within available width with minimum gaps
midW = Math.max(
0.8,
(availableRowWidth - minGap * (countMid - 1)) / countMid
);
}
// Compute gap to center-group horizontally without overlap
let gap = Math.max(
minGap,
(availableRowWidth - midW * countMid) / Math.max(1, countMid - 1)
);
const totalWidth = midW * countMid + gap * (countMid - 1);
const startLeft = cx - totalWidth / 2;
for (let i = 0; i < midLabels.length; i++) {
const x = startLeft + i * (midW + gap);
const midText = midLabels[i] || "";
const midFontSize = toNumberOr(opts.mid?.fontSize, 16);
const midTextOpts = autoFontSize(midText, midFontFace, {
x,
y: midY,
w: midW,
h: midH,
mode: opts.mid?.mode || "shrink",
fontSize: midFontSize,
minFontSize: opts.mid?.minFontSize,
maxFontSize: opts.mid?.maxFontSize,
});
slide.addText(midText, {
...midTextOpts,
align: "center",
valign: "mid",
fontFace: midFontFace,
color: opts.mid?.color || "000000",
fill: { color: opts.mid?.fill || "A0BEC2" },
line: { color: opts.mid?.line || opts.mid?.fill || "A0BEC2" },
});
addConnector(slide, cx, topY + rootH, x + midW / 2, midY, opts.line);
}
const leavesPerMid = Array.isArray(opts.leaf?.labelsPerMid)
? opts.leaf.labelsPerMid
: [];
const leafFontFaceRaw = opts.leaf?.fontFace;
const leafFontFace =
typeof leafFontFaceRaw === "string" && leafFontFaceRaw.trim().length > 0
? leafFontFaceRaw.trim()
: null;
if (!leafFontFace) {
throw new Error(
"addThreeLevelTree(): opts.leaf.fontFace is required for text measurement."
);
}
const leafW = toNumberOr(opts.leaf?.w, 1.05);
const leafH = toNumberOr(opts.leaf?.h, 1.0666667);
const leafY = toNumberOr(opts.leaf?.y, midY + midH + 1.0);
const minLeafGap = 0.2;
for (let i = 0; i < midLabels.length; i++) {
const xBase = startLeft + i * (midW + gap);
const childLabels = Array.isArray(leavesPerMid[i]) ? leavesPerMid[i] : [];
const childCount = childLabels.length || 3;
// Compute per-mid gap to fit children within midW without overlap
const leafGap = Math.max(
minLeafGap,
(midW - childCount * leafW) / Math.max(1, childCount - 1)
);
const totalWidth = childCount * leafW + (childCount - 1) * leafGap;
const leftX = xBase + (midW - totalWidth) / 2;
for (let j = 0; j < childCount; j++) {
const x = leftX + j * (leafW + leafGap);
const leafText = childLabels[j] || "";
const leafFontSize = toNumberOr(opts.leaf?.fontSize, 16);
const leafTextOpts = autoFontSize(leafText, leafFontFace, {
x,
y: leafY,
w: leafW,
h: leafH,
mode: opts.leaf?.mode || "shrink",
fontSize: leafFontSize,
minFontSize: opts.leaf?.minFontSize,
maxFontSize: opts.leaf?.maxFontSize,
});
slide.addText(leafText, {
...leafTextOpts,
align: "center",
valign: "mid",
fontFace: leafFontFace,
color: opts.leaf?.color || "000000",
fill: { color: opts.leaf?.fill || "A6C1EE" },
line: { color: opts.leaf?.line || opts.leaf?.fill || "A6C1EE" },
});
addConnector(
slide,
xBase + midW / 2,
midY + midH,
x + leafW / 2,
leafY,
opts.line
);
}
}
}
function addConnector(slide, x1, y1, x2, y2, line = {}) {
const x = Math.min(x1, x2);
const y = Math.min(y1, y2);
slide.addShape("line", {
x,
y,
w: Math.abs(x2 - x1),
h: Math.abs(y2 - y1),
line: { color: line.color || "000000", pt: line.pt || 1 },
flipH: x2 < x1 ? true : undefined,
});
}
function toNumberOr(v, fallback) {
const n = typeof v === "string" ? parseFloat(v) : v;
return Number.isFinite(n) ? n : fallback;
}
// Copyright (c) OpenAI. All rights reserved.
"use strict";
function inferElementType(obj) {
if (!obj) return "unknown";
const data = obj.data || obj.options || {};
// Distinguish lines explicitly via type only. Many objects have a 'line' style; don't misclassify those.
if (obj.type === "line") return "line";
if (obj.type && typeof obj.type === "string") return obj.type;
if (obj.text || typeof data.text === "string") return "text";
if (data.path || obj.image) return "image";
if (data.chartType) return "chart";
if (data.shape || data.line) return "shape";
if (data.mediaType) return "media";
if (data.table || Array.isArray(data.rows)) return "table";
if (data.smartArt) return "smartart";
return "unknown";
}
const TEXT_OVERLAP_ERROR_THRESHOLD = 0.1;
const RECTIFY_DIRECTION_EQUALITY_TOLERANCE = 0.15;
function warnIfSlideHasOverlaps(slide, pptx, options = {}) {
if (!slide || !Array.isArray(slide._slideObjects)) {
console.warn("Invalid slide object passed to warnIfSlideOverlaps()");
return;
}
const opts = {
// By default, containment cases are very common (e.g., full-slide backgrounds)
// and usually not actionable. Mute them unless explicitly requested.
muteContainment:
options.muteContainment !== undefined ? options.muteContainment : true,
// Do NOT ignore lines or decorative shapes by default; users want true overlaps.
ignoreLines:
options.ignoreLines !== undefined ? options.ignoreLines : false,
ignoreDecorativeShapes:
options.ignoreDecorativeShapes !== undefined
? options.ignoreDecorativeShapes
: false,
};
const slideIndex =
pptx && Array.isArray(pptx._slides) ? pptx._slides.indexOf(slide) : -1;
const slideLabel =
slideIndex >= 0 ? `Slide ${slideIndex + 1}` : "(Unknown slide index)";
const formatElement = (el) => {
const cx = (el.x + el.w / 2).toFixed(3);
const cy = (el.y + el.h / 2).toFixed(3);
return `element ${el.index} (${el.type}, center_x=${cx}, center_y=${cy})`;
};
const elements = slide._slideObjects.map((obj, i) => {
const {
x = 0,
y = 0,
w = 0,
h = 0,
fill,
line,
} = obj.data || obj.options || {};
const type = inferElementType(obj);
const isDecorative = (() => {
if (!opts.ignoreDecorativeShapes) return false;
// Border rectangles used as frames: transparent fill (or fully transparent) with a stroke
const transparency =
typeof fill?.transparency === "number" ? fill.transparency : null;
const hasOnlyBorder = !!line && (!fill || transparency !== null);
const fullyTransparent = transparency !== null && transparency >= 99;
return type === "shape" && hasOnlyBorder && fullyTransparent;
})();
const ignorable = (opts.ignoreLines && type === "line") || isDecorative;
return { index: i, type, x, y, w, h, ignorable };
});
let overlapCount = 0;
let containmentCount = 0;
for (let i = 0; i < elements.length; i++) {
const a = elements[i];
if (a.ignorable) continue;
for (let j = i + 1; j < elements.length; j++) {
const b = elements[j];
if (b.ignorable) continue;
const comparison = compareElementPosition(slide, a.index, b.index);
if (comparison.relation === "overlapping") {
// Special-case: diagonal line's bounding box overlapping a rectangle is often a false positive.
const EPS = 1e-6;
const getBounds = (e) => ({
x: e.x,
y: e.y,
x2: e.x + e.w,
y2: e.y + e.h,
});
const lineRectFalsePositive = (() => {
const oneIsLine = (a.type === "line") ^ (b.type === "line");
if (!oneIsLine) return false;
const line = a.type === "line" ? a : b;
const rect = a.type === "line" ? b : a;
// If line is diagonal, verify actual segment intersects rect; if not, ignore.
const isDiagonal = line.w > EPS && line.h > EPS;
const lineSeg = {
x1: line.x,
y1: line.y,
x2: line.x + line.w,
y2: line.y + line.h,
};
const rectB = getBounds(rect);
const pointInRect = (px, py, rb) =>
px >= rb.x - EPS &&
px <= rb.x2 + EPS &&
py >= rb.y - EPS &&
py <= rb.y2 + EPS;
const segsIntersect = (p1, p2, q1, q2) => {
const cross = (ax, ay, bx, by) => ax * by - ay * bx;
const d1x = p2.x - p1.x,
d1y = p2.y - p1.y;
const d2x = q2.x - q1.x,
d2y = q2.y - q1.y;
const denom = cross(d1x, d1y, d2x, d2y);
if (Math.abs(denom) < EPS) {
// Parallel: check colinearity and overlapping projections
const crossCol = cross(q1.x - p1.x, q1.y - p1.y, d1x, d1y);
if (Math.abs(crossCol) > EPS) return false;
const proj = (a, b, c) =>
Math.min(Math.max(a, b), Math.max(Math.min(a, b), c));
const overlapX = !(
Math.max(p1.x, p2.x) < Math.min(q1.x, q2.x) - EPS ||
Math.max(q1.x, q2.x) < Math.min(p1.x, p2.x) - EPS
);
const overlapY = !(
Math.max(p1.y, p2.y) < Math.min(q1.y, q2.y) - EPS ||
Math.max(q1.y, q2.y) < Math.min(p1.y, p2.y) - EPS
);
return overlapX && overlapY;
}
const t = cross(q1.x - p1.x, q1.y - p1.y, d2x, d2y) / denom;
const u = cross(q1.x - p1.x, q1.y - p1.y, d1x, d1y) / denom;
return t >= -EPS && t <= 1 + EPS && u >= -EPS && u <= 1 + EPS;
};
const intersectsRect = (seg, rb) => {
if (
pointInRect(seg.x1, seg.y1, rb) ||
pointInRect(seg.x2, seg.y2, rb)
)
return true;
const r1 = { x: rb.x, y: rb.y },
r2 = { x: rb.x2, y: rb.y },
r3 = { x: rb.x2, y: rb.y2 },
r4 = { x: rb.x, y: rb.y2 };
const p1 = { x: seg.x1, y: seg.y1 },
p2 = { x: seg.x2, y: seg.y2 };
return (
segsIntersect(p1, p2, r1, r2) ||
segsIntersect(p1, p2, r2, r3) ||
segsIntersect(p1, p2, r3, r4) ||
segsIntersect(p1, p2, r4, r1)
);
};
return isDiagonal && !intersectsRect(lineSeg, rectB);
})();
if (!lineRectFalsePositive) {
overlapCount++;
const severeTextOverlap = (() => {
if (!comparison.intersection) return false;
const exceedsThreshold = (element) =>
element.type === "text" &&
comparison.intersection.w >= TEXT_OVERLAP_ERROR_THRESHOLD &&
comparison.intersection.h >= TEXT_OVERLAP_ERROR_THRESHOLD;
return exceedsThreshold(a) || exceedsThreshold(b);
})();
if (severeTextOverlap) {
const overlapW = comparison.intersection.w;
const overlapH = comparison.intersection.h;
let rectificationSuggestion = "";
if (overlapW > EPS && overlapH > EPS) {
const maxOverlap = Math.max(overlapW, overlapH);
const diffRatio = Math.abs(overlapW - overlapH) / maxOverlap;
const directions = [];
// Attempt to determine the primary direction of the overlap. This is the direction
// in which the overlap is smaller (and so requires the smallest adjustment to rectify).
if (diffRatio <= RECTIFY_DIRECTION_EQUALITY_TOLERANCE) {
directions.push("horizontally", "vertically");
} else if (overlapW < overlapH) {
directions.push("horizontally");
} else {
directions.push("vertically");
}
rectificationSuggestion = `Suggestion: reposition elements ${directions.join(
" and "
)}.`;
}
console.error(
`❌ ${slideLabel}: Severe text overlap detected between ${formatElement(
a
)} and ${formatElement(
b
)} (overlap_horizontal=${comparison.intersection.w.toFixed(
3
)}, overlap_vertical=${comparison.intersection.h.toFixed(
3
)}). THIS MUST BE FIXED. ${rectificationSuggestion}`
);
} else {
console.warn(
`⚠️ ${slideLabel}: Overlap detected between ${formatElement(
a
)} and ${formatElement(b)}.`
);
}
}
} else if (comparison.relation === "contained") {
if (!opts.muteContainment) {
containmentCount++;
const container = elements[comparison.containerIndex];
const contained = elements[comparison.containedIndex];
console.warn(
`⚠️ ${slideLabel}: ${formatElement(
contained
)} is fully contained within ${formatElement(container)}`
);
} else {
// Still count internally when muted? We keep for summary only when un-muted
}
}
}
}
if (!(overlapCount === 0 && (!containmentCount || opts.muteContainment))) {
const issues = [];
if (overlapCount > 0) issues.push(`${overlapCount} overlapping pair(s)`);
if (!opts.muteContainment && containmentCount > 0)
issues.push(`${containmentCount} containment case(s)`);
console.log(`⚠️ ${slideLabel}: Found ${issues.join(" and ")}.`);
}
}
function compareElementPosition(slide, firstIndex, secondIndex) {
if (!slide || !Array.isArray(slide._slideObjects)) {
throw new Error("Invalid slide object passed to compareElementPosition()");
}
if (
typeof firstIndex !== "number" ||
typeof secondIndex !== "number" ||
!Number.isInteger(firstIndex) ||
!Number.isInteger(secondIndex)
) {
throw new Error("Element indices must be integer values.");
}
const elements = slide._slideObjects;
if (
firstIndex < 0 ||
firstIndex >= elements.length ||
secondIndex < 0 ||
secondIndex >= elements.length
) {
throw new Error(
"Element index out of bounds for compareElementPosition()."
);
}
const EPS = 1e-4;
const getBounds = (obj) => {
const source = obj?.data || obj?.options || {};
let x = typeof source.x === "number" ? source.x : 0;
let y = typeof source.y === "number" ? source.y : 0;
let w = typeof source.w === "number" ? source.w : 0;
let h = typeof source.h === "number" ? source.h : 0;
if (source.sizing && source.sizing.type === "crop") {
if (typeof source.sizing.w === "number") w = source.sizing.w;
if (typeof source.sizing.h === "number") h = source.sizing.h;
}
return { x, y, w, h, x2: x + w, y2: y + h };
};
const boundsA = getBounds(elements[firstIndex]);
const boundsB = getBounds(elements[secondIndex]);
const separated =
boundsA.x2 < boundsB.x - EPS ||
boundsB.x2 < boundsA.x - EPS ||
boundsA.y2 < boundsB.y - EPS ||
boundsB.y2 < boundsA.y - EPS;
if (separated) {
return {
relation: "disjoint",
containerIndex: null,
containedIndex: null,
aBounds: boundsA,
bBounds: boundsB,
intersection: null,
};
}
const aContainsB =
boundsA.x <= boundsB.x + EPS &&
boundsA.y <= boundsB.y + EPS &&
boundsA.x2 >= boundsB.x2 - EPS &&
boundsA.y2 >= boundsB.y2 - EPS;
const bContainsA =
boundsB.x <= boundsA.x + EPS &&
boundsB.y <= boundsA.y + EPS &&
boundsB.x2 >= boundsA.x2 - EPS &&
boundsB.y2 >= boundsA.y2 - EPS;
const ix1 = Math.max(boundsA.x, boundsB.x);
const iy1 = Math.max(boundsA.y, boundsB.y);
const ix2 = Math.min(boundsA.x2, boundsB.x2);
const iy2 = Math.min(boundsA.y2, boundsB.y2);
const intersectionWidth = Math.max(0, ix2 - ix1);
const intersectionHeight = Math.max(0, iy2 - iy1);
const intersection =
intersectionWidth > EPS && intersectionHeight > EPS
? { x: ix1, y: iy1, w: intersectionWidth, h: intersectionHeight }
: null;
if (aContainsB && !bContainsA) {
return {
relation: "contained",
containerIndex: firstIndex,
containedIndex: secondIndex,
aBounds: boundsA,
bBounds: boundsB,
intersection,
};
}
if (bContainsA && !aContainsB) {
return {
relation: "contained",
containerIndex: secondIndex,
containedIndex: firstIndex,
aBounds: boundsA,
bBounds: boundsB,
intersection,
};
}
if (intersection) {
return {
relation: "overlapping",
containerIndex: null,
containedIndex: null,
aBounds: boundsA,
bBounds: boundsB,
intersection,
};
}
return {
relation: "touching",
containerIndex: null,
containedIndex: null,
aBounds: boundsA,
bBounds: boundsB,
intersection: null,
};
}
const VALID_ALIGNMENTS = new Set([
"left",
"right",
"top",
"bottom",
"verticallyCenter",
"horizontallyCenter",
]);
const getElementBounds = (obj) => {
const source = obj?.data || obj?.options || {};
let x = typeof source.x === "number" ? source.x : 0;
let y = typeof source.y === "number" ? source.y : 0;
let w = typeof source.w === "number" ? source.w : 0;
let h = typeof source.h === "number" ? source.h : 0;
// If an image is placed with crop sizing, pptxgenjs stores a larger virtual image w/h
// and a viewport in source.sizing.{w,h}. For visual overlap purposes, use the viewport.
if (source.sizing && source.sizing.type === "crop") {
if (typeof source.sizing.w === "number") w = source.sizing.w;
if (typeof source.sizing.h === "number") h = source.sizing.h;
}
return { x, y, w, h, x2: x + w, y2: y + h };
};
const setElementPosition = (obj, coords) => {
const ensureTarget = (targetObj) => {
if (!targetObj || typeof targetObj !== "object") return null;
return targetObj;
};
const targets = [];
const dataTarget = ensureTarget(obj.data);
if (dataTarget) targets.push(dataTarget);
const optionsTarget =
obj.options && obj.options !== obj.data ? ensureTarget(obj.options) : null;
if (optionsTarget) targets.push(optionsTarget);
if (targets.length === 0) {
obj.data = obj.data && typeof obj.data === "object" ? obj.data : {};
targets.push(obj.data);
}
targets.forEach((target) => {
if (coords.x !== undefined) target.x = coords.x;
if (coords.y !== undefined) target.y = coords.y;
});
};
const dimensionKeyPairs = [
["width", "height"],
["w", "h"],
["cx", "cy"],
["slideWidth", "slideHeight"],
["slideWidthInches", "slideHeightInches"],
["widthInches", "heightInches"],
];
const toNumber = (value) => {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = parseFloat(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
};
const readDimensionsFromObject = (candidate, seen = new Set()) => {
if (!candidate || typeof candidate !== "object") return null;
if (seen.has(candidate)) return null;
seen.add(candidate);
for (const [wKey, hKey] of dimensionKeyPairs) {
const width = toNumber(candidate[wKey]);
const height = toNumber(candidate[hKey]);
if (width !== null && height !== null && width > 0 && height > 0) {
return { width, height };
}
}
const nestedKeys = ["size", "slideSize", "layout", "slideLayout"];
for (const key of nestedKeys) {
const nested = readDimensionsFromObject(candidate[key], seen);
if (nested) return nested;
}
return null;
};
const getSlideDimensions = (slide, pptx) => {
const candidates = [
slide?._presLayout,
slide?._slideLayout,
slide?._pres?.layout,
slide?._parent?.layout,
slide?._layout,
pptx?._presLayout,
pptx?._layout,
pptx?.layout,
pptx?.presLayout,
];
for (const candidate of candidates) {
const dims = readDimensionsFromObject(candidate);
if (dims) {
// Some internals are in EMUs; convert if values look too large for inches
const EMU_PER_IN = 914400;
const looksEmu = dims.width > 1000 || dims.height > 1000;
if (looksEmu) {
return {
width: dims.width / EMU_PER_IN,
height: dims.height / EMU_PER_IN,
source: "emu_converted",
};
}
return { ...dims, source: "detected" };
}
}
throw new Error(
"getSlideDimensions(): Unable to determine slide dimensions from pptxgenjs internals."
);
};
function alignSlideElements(slide, indices, alignment) {
if (!slide || !Array.isArray(slide._slideObjects)) {
throw new Error("Invalid slide object passed to alignSlideElements()");
}
if (!Array.isArray(indices) || indices.length === 0) {
throw new Error("indices must be a non-empty array.");
}
if (!VALID_ALIGNMENTS.has(alignment)) {
throw new Error(`Unsupported alignment option: ${alignment}`);
}
const uniqueIndices = [...new Set(indices)];
const elements = slide._slideObjects;
const selected = uniqueIndices.map((idx) => {
if (typeof idx !== "number" || !Number.isInteger(idx)) {
throw new Error("Element indices must be integers.");
}
if (idx < 0 || idx >= elements.length) {
throw new Error("Element index out of bounds for alignSlideElements().");
}
const obj = elements[idx];
const bounds = getElementBounds(obj);
return { index: idx, obj, bounds };
});
if (selected.length < 2) return;
const minX = Math.min(...selected.map((item) => item.bounds.x));
const maxX2 = Math.max(...selected.map((item) => item.bounds.x2));
const minY = Math.min(...selected.map((item) => item.bounds.y));
const maxY2 = Math.max(...selected.map((item) => item.bounds.y2));
const centerX = (minX + maxX2) / 2;
const centerY = (minY + maxY2) / 2;
selected.forEach(({ obj, bounds }) => {
const { w, h } = bounds;
switch (alignment) {
case "left":
setElementPosition(obj, { x: minX });
break;
case "right":
setElementPosition(obj, { x: maxX2 - w });
break;
case "top":
setElementPosition(obj, { y: minY });
break;
case "bottom":
setElementPosition(obj, { y: maxY2 - h });
break;
case "horizontallyCenter":
setElementPosition(obj, { x: centerX - w / 2 });
break;
case "verticallyCenter":
setElementPosition(obj, { y: centerY - h / 2 });
break;
default:
throw new Error(`Unhandled alignment option: ${alignment}`);
}
});
}
function distributeSlideElements(slide, indices, direction) {
if (!slide || !Array.isArray(slide._slideObjects)) {
throw new Error("Invalid slide object passed to distributeSlideElements()");
}
if (!Array.isArray(indices) || indices.length === 0) {
throw new Error("indices must be a non-empty array.");
}
if (direction !== "horizontal" && direction !== "vertical") {
throw new Error(`Unsupported distribution direction: ${direction}`);
}
const uniqueIndices = [...new Set(indices)];
if (uniqueIndices.length < 2) return;
const elements = slide._slideObjects;
const selected = uniqueIndices.map((idx) => {
if (typeof idx !== "number" || !Number.isInteger(idx)) {
throw new Error("Element indices must be integers.");
}
if (idx < 0 || idx >= elements.length) {
throw new Error(
"Element index out of bounds for distributeSlideElements()."
);
}
const obj = elements[idx];
const bounds = getElementBounds(obj);
return { index: idx, obj, bounds };
});
const axisStartKey = direction === "horizontal" ? "x" : "y";
const axisEndKey = direction === "horizontal" ? "x2" : "y2";
const sizeKey = direction === "horizontal" ? "w" : "h";
selected.sort((a, b) => {
const delta = a.bounds[axisStartKey] - b.bounds[axisStartKey];
return Math.abs(delta) > 1e-6 ? delta : a.index - b.index;
});
const minCoord = Math.min(
...selected.map((item) => item.bounds[axisStartKey])
);
const maxCoord = Math.max(...selected.map((item) => item.bounds[axisEndKey]));
const totalSpan = maxCoord - minCoord;
const gaps = selected.length - 1;
const totalSize = selected.reduce(
(sum, item) => sum + item.bounds[sizeKey],
0
);
const gapSize = gaps > 0 ? (totalSpan - totalSize) / gaps : 0;
let cursor = minCoord;
selected.forEach(({ obj, bounds }) => {
if (direction === "horizontal") {
setElementPosition(obj, { x: cursor });
cursor += bounds.w + gapSize;
} else {
setElementPosition(obj, { y: cursor });
cursor += bounds.h + gapSize;
}
});
}
function warnIfSlideElementsOutOfBounds(slide, pptx) {
if (!slide || !Array.isArray(slide._slideObjects)) {
console.warn(
"Invalid slide object passed to warnIfSlideElementsOutOfBounds()"
);
return;
}
const {
width: slideWidth,
height: slideHeight,
source,
} = getSlideDimensions(slide, pptx);
const slideIndex =
pptx && Array.isArray(pptx._slides) ? pptx._slides.indexOf(slide) : -1;
const slideLabel =
slideIndex >= 0 ? `Slide ${slideIndex + 1}` : "(Unknown slide index)";
if (source === "default") {
console.warn(
`⚠️ ${slideLabel}: Unable to determine slide dimensions from pptxgenjs internals; assuming width=${slideWidth}, height=${slideHeight}.`
);
}
const EPS = 1e-4;
let outOfBoundsCount = 0;
const formatElement = (idx, type, bounds) => {
const cx = (bounds.x + bounds.w / 2).toFixed(3);
const cy = (bounds.y + bounds.h / 2).toFixed(3);
return `Element ${idx} (${type}, center_x=${cx}, center_y=${cy})`;
};
slide._slideObjects.forEach((obj, index) => {
const bounds = getElementBounds(obj);
const type = inferElementType(obj);
const violations = [];
if (bounds.x < -EPS) violations.push(`left=${bounds.x.toFixed(3)} < 0`);
if (bounds.y < -EPS) violations.push(`top=${bounds.y.toFixed(3)} < 0`);
if (bounds.x2 > slideWidth + EPS)
violations.push(
`right=${bounds.x2.toFixed(3)} > width=${slideWidth.toFixed(3)}`
);
if (bounds.y2 > slideHeight + EPS)
violations.push(
`bottom=${bounds.y2.toFixed(3)} > height=${slideHeight.toFixed(3)}`
);
if (violations.length > 0) {
outOfBoundsCount++;
console.warn(
`⚠️ ${slideLabel}: ${formatElement(
index,
type,
bounds
)} exceeds slide bounds (${violations.join(", ")}).`
);
}
});
if (outOfBoundsCount > 0) {
console.log(
`⚠️ ${slideLabel}: Found ${outOfBoundsCount} element(s) extending beyond the slide bounds.`
);
}
}
module.exports = {
inferElementType,
compareElementPosition,
warnIfSlideHasOverlaps,
alignSlideElements,
distributeSlideElements,
warnIfSlideElementsOutOfBounds,
getSlideDimensions,
};
// Copyright (c) OpenAI. All rights reserved.
"use strict";
function toDataUri(svg) {
return "data:image/svg+xml;base64," + Buffer.from(svg).toString("base64");
}
function sanitizeSvg(svg) {
let inner = svg;
const a = inner.indexOf("<svg");
const b = inner.indexOf("</svg>");
if (a !== -1 && b !== -1) inner = inner.slice(a, b + 6);
inner = inner.replace(/<\?xml[^>]*>/g, "");
if (!/xmlns=\"http:\/\/www\.w3\.org\/2000\/svg\"/.test(inner)) {
inner = inner.replace(/<svg /, '<svg xmlns="http://www.w3.org/2000/svg" ');
}
inner = inner.replace(
/(width|height)=\"([0-9.]+)(ex|em)\"/g,
(_m, attr, num) => {
const px = Math.round(parseFloat(num) * 8.5);
return `${attr}="${px}px"`;
}
);
inner = inner.replace(/currentColor/g, "#000000");
return inner;
}
function svgToDataUri(svg) {
return toDataUri(sanitizeSvg(svg));
}
module.exports = {
toDataUri,
sanitizeSvg,
svgToDataUri,
};
// Copyright (c) OpenAI. All rights reserved.
"use strict";
const { spawnSync } = require("child_process");
const { Canvas } = require("skia-canvas");
// Unicode line-break iterator (UAX #14) so we mimic PPT/LibreOffice wrapping rules.
const LineBreaker = require("linebreak");
const fontkit = require("fontkit");
const TEXT_MEASURER = getTextMeasurer();
const registeredFontVariants = new Set();
const fontPathCache = new Map();
const fontKitCache = new Map();
// Estimate the text box height for a given font size and line count.
// NOTE: This is an analytical approximation, not an exact reproduction of
// PowerPoint/LibreOffice layout. Always verify visually and adjust based on
// actual rendering if precise fit is required.
function calcTextBoxHeightSimple(
fontSize,
lines = 1,
leading = 1.15,
padding = 0.3
) {
const lineHeightIn = (fontSize / 72) * leading;
return lines * lineHeightIn + padding;
}
// Compute font size that fits given text within a fixed box.
// NOTE: autoFontSize uses skia-canvas measurement stack to approximate the font size
// that will fit in a given box. Rendering engines may differ slightly, so
// treat the result as an estimate and tweak as needed after visual inspection.
// Signature:
// autoFontSize(textOrRuns, fontFace, opts?)
// - fontFace must be provided as the 2nd positional argument and cannot be in opts.
// - All modes always respect [minFontSize, maxFontSize] as a CLOSED interval when provided.
// Modes:
// - mode: "shrink" => shrink only (search [minFontSize, min(maxFontSize, fontSize)])
// - mode: "enlarge" => enlarge only (search [max(minFontSize, fontSize), maxFontSize])
// - mode: "auto" => shrink + enlarge (search [minFontSize, maxFontSize]); fontSize optional.
// In "auto" mode fontSize is not required; when omitted we simply search the whole [minFontSize, maxFontSize] range.
// Returns a cloned options object with computed fontSize. fit: "shrink" is appended only when mode === "shrink".
function autoFontSize(textOrRuns, fontFace, opts = {}) {
const x = toNumber(opts.x, 0);
const y = toNumber(opts.y, 0);
const w = toNumber(opts.w, 0);
const h = toNumber(opts.h, 0);
if (!(w > 0 && h > 0)) throw new Error("autoFontSize(): non-positive w or h");
const face = typeof fontFace === "string" ? fontFace.trim() : "";
if (face.length === 0) {
throw new Error(
"autoFontSize(): fontFace is required as the 2nd positional argument."
);
}
// Fast-path: if there is no visible text content, just return the
// (optionally clamped) reference fontSize; there is nothing to fit.
const hasAnyText =
normalizeText(textOrRuns).trim().length > 0 ||
(Array.isArray(textOrRuns) &&
textOrRuns.some(
(run) => run && typeof run.text === "string" && run.text.trim().length
));
const fontStyle =
opts.italic === true || opts.fontStyle === "italic" ? "italic" : "normal";
const fontWeight =
opts.bold === true || String(opts.fontWeight || "").toLowerCase() === "bold"
? "bold"
: "normal";
const leading = toNumber(opts.leading, 1.15) || 1.15;
const modeRaw = typeof opts.mode === "string" ? opts.mode : "auto"; // 'auto' (default) | 'shrink' | 'enlarge'
const mode = modeRaw.toLowerCase();
const isShrink = mode === "shrink";
const isEnlarge = mode === "enlarge";
const isAuto = mode === "auto";
const refPtRaw = toNumber(opts.fontSize, NaN);
const hasRefPt = Number.isFinite(refPtRaw);
const refPt = hasRefPt ? refPtRaw : NaN;
// Base bounds (closed interval). Defaults:
// - minFontSize: 1pt
// - maxFontSize: 1000pt (unless the caller provided a tighter bound)
let minPt = toNumber(opts.minFontSize, NaN);
let maxPt = toNumber(opts.maxFontSize, NaN);
const userProvidedMax = Number.isFinite(maxPt);
if (!Number.isFinite(minPt)) {
minPt = 1;
}
if (!Number.isFinite(maxPt)) {
maxPt = 1000;
}
if (isShrink || isEnlarge) {
if (!hasRefPt) {
throw new Error(
"autoFontSize(): mode 'shrink' or 'enlarge' requires fontSize"
);
}
}
if (isShrink) {
// Shrink only: never exceed the requested size (and respect maxFontSize).
maxPt = Math.min(maxPt, refPt);
} else if (isEnlarge) {
// Enlarge only: never go below the requested size (and respect minFontSize).
minPt = Math.max(minPt, refPt);
} else if (isAuto && hasRefPt && userProvidedMax) {
// Auto mode with an explicit maxFontSize: honor [minFontSize, maxFontSize]
// as the search band while allowing both shrink and enlarge within it.
} else if (!isAuto) {
throw new Error(
`autoFontSize(): unsupported mode "${modeRaw}", expected "auto" | "shrink" | "enlarge"`
);
}
if (!(maxPt > 0 && maxPt >= minPt)) {
throw new Error(
"autoFontSize(): invalid minFontSize/maxFontSize bounds after normalization"
);
}
// If there is no actual text, we can skip measurement entirely and just
// clamp the reference size to [minPt, maxPt].
if (!hasAnyText) {
const chosen =
(hasRefPt && Math.max(minPt, Math.min(maxPt, refPt))) || minPt;
const out = { ...opts, x, y, w, h, fontSize: chosen };
if (isShrink) out.fit = "shrink";
return out;
}
// Search the space of candidate font sizes with a small step and a safety
// bias baked into the fit test:
// - precision: 0.05pt (~1/20pt) so we land very close to the true max-fit.
// - safetyFactor: we require that the calcTextBox()-measured height is
// within a small margin of the caller-provided box height, so that the
// same layout engine used by calcTextBox drives autoFontSize decisions.
const precision = 0.05; // point precision for search (~1/20pt)
const safetyFactor = 0.97;
let lo = minPt;
let hi = maxPt;
let best = lo;
while (hi - lo > precision) {
const mid = (lo + hi) / 2;
// Delegate measurement to calcTextBox so that autoFontSize and
// calcTextBox share the exact same layout pipeline (paragraph modeling,
// bullet handling, margins, padding, width scaling, etc.).
const layout = calcTextBox(mid, {
text: textOrRuns,
w,
fontFace: face,
fontStyle,
fontWeight,
leading,
margin: opts.margin,
padding: opts.padding,
paraSpaceAfter: opts.paraSpaceAfter,
});
const fits = layout.h <= h * safetyFactor + 1e-6;
if (fits) {
best = mid;
lo = mid; // try larger
} else {
hi = mid; // shrink
}
}
// Closed interval: clamp to [minPt, maxPt].
const finalPt = Math.max(minPt, Math.min(maxPt, best));
// Pass through all original options, override fontSize and append fit: "shrink"
const out = { ...opts, x, y, w, h, fontSize: finalPt };
if (isShrink) out.fit = "shrink";
return out;
}
// Calculate text box metrics using skia-canvas measurement (lines, height,
// width) for a given font size and text payload.
// NOTE: calcTextBox approximates how many lines and how much space text will
// occupy using our JS measurement pipeline. It is designed to be close to
// PowerPoint/LibreOffice but is not guaranteed pixel-perfect—always adjust
// based on actual slide rendering when precision matters.
// Signature:
// calcTextBox(fontSizePt, opts)
// - fontSizePt: number (points)
// - opts (keywords): {
// text?: string | runs[],
// w?: number (inches),
// h?: number (inches),
// lines?: number,
// fontFace?: string, // required when measuring by width/height with text
// fontStyle?: 'normal' | 'italic', italic?: boolean,
// fontWeight?: 'normal' | 'bold', bold?: boolean,
// leading?: number (line height multiplier, default 1.15),
// padding?: number (inches, default 0.3),
// paraSpaceAfter?: number (points, default 0)
// }
// Modes (auto-detected):
// a) Given lines -> compute height
// b) Given width + text -> compute height and lines
// c) Given height + text -> compute width and lines
// Throws when insufficient info is provided.
function calcTextBox(fontSizePt, opts = {}) {
const textInput = opts.text ?? "";
const text = normalizeText(textInput || "");
const face =
typeof opts.fontFace === "string" && opts.fontFace.trim().length > 0
? opts.fontFace.trim()
: "";
const fontStyle =
opts.italic === true || opts.fontStyle === "italic" ? "italic" : "normal";
const fontWeight =
opts.bold === true || String(opts.fontWeight || "").toLowerCase() === "bold"
? "bold"
: "normal";
const leading = toNumber(opts.leading, 1.15) || 1.15;
const padding = toNumber(opts.padding, 0.3); // inches (allow 0)
const paraSpaceAfterPt = toNumber(opts.paraSpaceAfter, 0) || 0; // points
const lineHeightIn = (fontSizePt / 72) * leading;
const margins = normalizeMargins(opts.margin);
const measurer = TEXT_MEASURER;
const hasLines = Number.isFinite(toNumber(opts.lines, NaN));
const hasWidth = Number.isFinite(toNumber(opts.w, NaN));
const hasHeight = Number.isFinite(toNumber(opts.h, NaN));
const paragraphs = buildParagraphModels(textInput, {
fontSizePt,
// Do not silently substitute a default font here; callers measuring by
// width/height are required to pass an explicit fontFace so that our
// metrics match the actual slide theme.
fontFace: face,
fontStyle,
fontWeight,
leading,
paraSpaceAfterPt,
});
const hasAnyText = paragraphs.some((p) => p.text.length > 0);
// Empirical top inset: PPT text frames render a small gutter above the first line
// even with zero margins. Model it as a fraction of the font size so callers can
// visually trim by shifting y up and growing h by the same amount.
const topInsetIn = (fontSizePt / 72) * 0.2; // ~20% of font size (inches)
if (hasLines) {
// Mode (a): Given lines -> compute height only
const lines = toNumber(opts.lines, 1);
const contentH = Math.max(0, lines * lineHeightIn + padding);
const h = contentH + margins.top + margins.bottom;
const passthrough = buildPassthroughOptions(opts, fontSizePt, margins);
return {
...passthrough,
w: toNumber(opts.w, NaN) || null,
h,
lines,
contentH,
margins,
topInset: topInsetIn,
};
}
if (hasWidth && hasAnyText) {
// Mode (b): Given width + text -> compute height and lines
if (face.length === 0) {
throw new Error(
"calcTextBox(): opts.fontFace is required when measuring by width."
);
}
const boxW = toNumber(opts.w, 0);
if (!(boxW > 0))
throw new Error("calcTextBox(): width must be > 0 in mode 'width'");
const innerW = Math.max(0, boxW - margins.left - margins.right);
const { lines, heightIn } = layoutGivenWidth(paragraphs, innerW);
const contentH = Math.max(0, heightIn + padding);
const h = contentH + margins.top + margins.bottom;
const passthrough = buildPassthroughOptions(opts, fontSizePt, margins);
return {
...passthrough,
w: boxW,
h,
lines,
contentH,
margins,
topInset: topInsetIn,
};
}
if (hasHeight && hasAnyText) {
// Mode (c): Given height + text -> compute minimal width and lines to fit
if (face.length === 0) {
throw new Error(
"calcTextBox(): opts.fontFace is required when measuring by height."
);
}
const boxH = toNumber(opts.h, 0);
if (!(boxH > 0))
throw new Error("calcTextBox(): height must be > 0 in mode 'height'");
const innerH = Math.max(0, boxH - margins.top - margins.bottom);
// Upper bound: single-line width across paragraphs
const singleLineWidth = paragraphs.reduce((mx, p) => {
const width = measureRunWidth(p, p.text) + p.textIndentIn;
return Math.max(mx, width);
}, 0);
const minHeightOneLine = Math.max(
0,
paragraphs.reduce((sum, p, idx) => {
const lineHeight = (p.fontSizePt / 72) * p.leading;
sum += lineHeight;
if (idx !== paragraphs.length - 1) sum += p.paraSpaceAfterIn;
return sum;
}, 0)
);
if (minHeightOneLine + padding - innerH > 1e-6) {
throw new Error(
"calcTextBox(): height too small for one-line layout at this font size"
);
}
// Lower bound: longest token width
const longestTokenWidth = paragraphs.reduce((mx, p) => {
const tokens = splitTextIntoTokens(p.text);
for (const tk of tokens) {
if (tk.length === 0) continue;
const wIn = measureRunWidth(p, tk) + p.textIndentIn;
if (wIn > mx) mx = wIn;
}
return mx;
}, 0);
let lo = Math.max(0.01, longestTokenWidth);
let hi = Math.max(lo, singleLineWidth);
let best = hi;
for (let iter = 0; iter < 32; iter++) {
const mid = (lo + hi) / 2;
const { lines, heightIn } = layoutGivenWidth(paragraphs, mid);
const totalH = heightIn + padding;
if (totalH <= innerH + 1e-6) {
best = mid;
hi = mid;
} else {
lo = mid;
}
}
const { lines, heightIn } = layoutGivenWidth(paragraphs, best);
const contentH = heightIn + padding;
const passthrough = buildPassthroughOptions(opts, fontSizePt, margins);
return {
...passthrough,
w: best + margins.left + margins.right,
h: contentH + margins.top + margins.bottom,
lines,
contentH,
margins,
topInset: topInsetIn,
};
}
throw new Error(
"calcTextBox(): insufficient information. Provide {lines} or ({w,text}) or ({h,text})."
);
}
function layoutGivenWidth(paragraphs, boxW) {
let totalLines = 0;
let heightIn = 0;
for (let i = 0; i < paragraphs.length; i++) {
const para = paragraphs[i];
const widthScale = getWidthScaleForParagraph(para);
const usableWidth = Math.max(0.01, boxW - para.textIndentIn) * widthScale;
const lines = greedyWrap(para, usableWidth);
const count = Math.max(1, lines.length);
totalLines += count;
const lineHeightIn = (para.fontSizePt / 72) * para.leading;
heightIn += count * lineHeightIn;
if (i !== paragraphs.length - 1) heightIn += para.paraSpaceAfterIn;
}
return { lines: totalLines, heightIn };
}
function greedyWrap(paragraph, maxWidthIn) {
const text = paragraph.text || "";
if (text.length === 0) return [""];
const breaker = new LineBreaker(text);
const breakpoints = [];
let bk;
while ((bk = breaker.nextBreak())) {
breakpoints.push({ pos: bk.position, required: bk.required });
}
const lines = [];
let start = skipTextWhitespace(text, 0);
let idx = 0;
while (start < text.length) {
while (idx < breakpoints.length && breakpoints[idx].pos <= start) idx++;
let chosen = null;
let probe = idx;
while (probe < breakpoints.length) {
const br = breakpoints[probe];
const slice = text.slice(start, br.pos);
const width = measureRunWidth(paragraph, trimLineEnd(slice));
if (width <= maxWidthIn + 1e-6) {
chosen = br;
probe++;
if (br.required) break;
} else {
break;
}
}
if (!chosen) {
const forced = forceBreakSegment(text, start, maxWidthIn, paragraph);
if (forced.segment.length === 0) break;
lines.push(trimLineEnd(forced.segment));
start = skipTextWhitespace(text, forced.nextIndex);
continue;
}
const lineText = text.slice(start, chosen.pos);
lines.push(trimLineEnd(lineText));
start = skipTextWhitespace(text, chosen.pos);
}
if (!lines.length) lines.push("");
return lines;
}
function splitTextIntoTokens(text) {
if (typeof text !== "string") return [""];
const tokens = text.split(/(\s+)/);
return tokens.length ? tokens : [""];
}
function trimLineEnd(value) {
return typeof value === "string" ? value.replace(/\s+$/u, "") : "";
}
function measureRunWidth(paragraph, text) {
if (!text || text.length === 0) return 0;
const fontData = getFontData(
paragraph.fontFace,
paragraph.fontStyle,
paragraph.fontWeight
);
if (fontData && fontData.font) {
const layout = fontData.font.layout(text);
const widthPts =
(layout.advanceWidth / fontData.font.unitsPerEm) * paragraph.fontSizePt;
return Math.max(0, widthPts / 72);
}
return TEXT_MEASURER(
text,
paragraph.fontSizePt,
paragraph.fontFace,
paragraph.fontStyle,
paragraph.fontWeight
);
}
function forceBreakSegment(text, start, maxWidthIn, paragraph) {
const chars = Array.from(text.slice(start));
if (chars.length === 0) return { segment: "", nextIndex: text.length };
let buffer = "";
let consumedUnits = 0;
for (let i = 0; i < chars.length; i++) {
const candidate = buffer + chars[i];
const width = measureRunWidth(paragraph, trimLineEnd(candidate));
if (width <= maxWidthIn + 1e-6) {
buffer = candidate;
consumedUnits += chars[i].length;
continue;
}
if (buffer.length === 0) {
buffer = chars[i];
consumedUnits += chars[i].length;
}
break;
}
if (buffer.length === 0) {
buffer = chars[0] || "";
consumedUnits = buffer.length;
}
return { segment: buffer, nextIndex: start + consumedUnits };
}
function skipTextWhitespace(text, index) {
let idx = index;
while (idx < text.length && /\s/.test(text[idx])) idx++;
return idx;
}
function buildParagraphModels(textOrRuns, baseStyle) {
const entries = collectParagraphEntries(textOrRuns);
if (entries.length === 0) {
return [resolveParagraphStyle({ text: "" }, baseStyle)];
}
return entries.map((entry) => resolveParagraphStyle(entry, baseStyle));
}
function collectParagraphEntries(textOrRuns) {
const result = [];
if (Array.isArray(textOrRuns)) {
for (const entry of textOrRuns) {
if (typeof entry === "string") {
pushParagraphSegments(entry, undefined, result);
} else if (entry && typeof entry === "object") {
pushParagraphSegments(entry.text ?? "", entry.options || {}, result);
}
}
return result;
}
pushParagraphSegments(textOrRuns ?? "", undefined, result);
return result;
}
function pushParagraphSegments(text, options, target) {
const normalized = String(text ?? "");
const parts = normalized.split(/\r?\n/);
if (parts.length === 0) {
target.push({ text: "", options });
return;
}
for (const part of parts) {
target.push({ text: part, options });
}
}
function resolveParagraphStyle(entry, baseStyle) {
const opts = entry.options || {};
const fontFace =
(opts.fontFace && String(opts.fontFace).trim()) ||
baseStyle.fontFace ||
"Arial";
const fontStyle =
opts.italic === true || opts.fontStyle === "italic"
? "italic"
: baseStyle.fontStyle || "normal";
const fontWeight =
opts.bold === true || String(opts.fontWeight || "").toLowerCase() === "bold"
? "bold"
: baseStyle.fontWeight || "normal";
const fontSizePt =
toNumber(opts.fontSize, baseStyle.fontSizePt) || baseStyle.fontSizePt;
const leading =
toNumber(opts.leading, baseStyle.leading) || baseStyle.leading || 1.15;
const paraSpaceAfterPt =
toNumber(opts.paraSpaceAfter, baseStyle.paraSpaceAfterPt) ||
baseStyle.paraSpaceAfterPt ||
0;
const hasBullet = !!opts.bullet;
let indentPt = toNumber(opts.indent, NaN);
if (!Number.isFinite(indentPt) && hasBullet) {
indentPt = toNumber(opts.bullet.indent, NaN);
}
if (!Number.isFinite(indentPt)) indentPt = 0;
const hangingPt = toNumber(opts.hanging, 0) || 0;
let textIndentIn = 0;
if (indentPt > 0) {
if (hasBullet) {
// PowerPoint-style bullets: "indent" is the distance from the left edge
// of the text box to the start of the text (the bullet itself is hung
// using the hanging value). This means the available width for the text
// is boxWidth - indent, not boxWidth - (indent - hanging). Modeling it
// this way matches the manual line counts from PowerPoint/LibreOffice.
textIndentIn = indentPt / 72;
} else {
// Non-bullet paragraphs keep the prior behavior where hanging reduces
// the effective indent (similar to CSS text-indent).
textIndentIn = Math.max(0, (indentPt - hangingPt) / 72);
}
}
return {
text: entry.text || "",
fontFace,
fontStyle,
fontWeight,
fontSizePt,
leading,
paraSpaceAfterIn: paraSpaceAfterPt / 72,
textIndentIn,
};
}
function getFontData(face, fontStyle, fontWeight) {
const key = makeFontCacheKey(face, fontStyle, fontWeight);
if (fontKitCache.has(key)) return fontKitCache.get(key);
const fontPath = findFontPath(face, fontStyle, fontWeight);
if (!fontPath) {
fontKitCache.set(key, null);
return null;
}
try {
let font = fontkit.openSync(fontPath);
if (font && typeof font.fonts === "object") {
font = selectCollectionFont(font, fontStyle, fontWeight);
}
if (!font || typeof font.layout !== "function") {
fontKitCache.set(key, null);
return null;
}
registerCanvasFontVariant(fontPath, face, fontStyle, fontWeight, key);
const payload = { font, path: fontPath };
fontKitCache.set(key, payload);
return payload;
} catch (err) {
fontKitCache.set(key, null);
return null;
}
}
function makeFontCacheKey(face, fontStyle, fontWeight) {
const family = (face || "Arial").trim();
const style = (fontStyle || "normal").toLowerCase();
const weight = (fontWeight || "normal").toLowerCase();
return `${family}::${style}::${weight}`;
}
function registerCanvasFontVariant(
fontPath,
face,
fontStyle,
fontWeight,
cacheKey
) {
if (registeredFontVariants.has(cacheKey)) return;
try {
Canvas.registerFont(fontPath, {
family: face,
style: fontStyle || "normal",
weight: fontWeight || "normal",
});
registeredFontVariants.add(cacheKey);
} catch (err) {
// ignore registration failure; measurement will fall back to Skia default
}
}
function findFontPath(face, fontStyle, fontWeight) {
const family = (face || "").trim();
if (family.length === 0) return null;
const key = makeFontCacheKey(family, fontStyle, fontWeight);
if (fontPathCache.has(key)) return fontPathCache.get(key);
const styleParts = [];
if ((fontWeight || "").toLowerCase() === "bold") styleParts.push("Bold");
if ((fontStyle || "").toLowerCase() === "italic") styleParts.push("Italic");
const styleQuery =
styleParts.length > 0 ? `:style=${styleParts.join(" ")}` : "";
const query = `${family}${styleQuery}`;
const result = spawnSync("fc-match", ["-f", "%{file}", query], {
encoding: "utf8",
});
if (result.status === 0) {
const output = String(result.stdout || "").trim();
if (output.length > 0) {
fontPathCache.set(key, output);
return output;
}
}
fontPathCache.set(key, null);
return null;
}
function selectCollectionFont(collection, fontStyle, fontWeight) {
const fonts = collection.fonts || [];
if (fonts.length === 0) return null;
const wantItalic = (fontStyle || "").toLowerCase() === "italic";
const wantBold = (fontWeight || "").toLowerCase() === "bold";
let best = fonts[0];
let bestScore = scoreFontVariant(best, wantItalic, wantBold);
for (let i = 1; i < fonts.length; i++) {
const candidate = fonts[i];
const score = scoreFontVariant(candidate, wantItalic, wantBold);
if (score > bestScore) {
best = candidate;
bestScore = score;
}
}
return best;
}
function scoreFontVariant(font, wantItalic, wantBold) {
if (!font) return -1;
const name = String(font.fullName || font.postscriptName || "").toLowerCase();
const isItalic = /italic|oblique/.test(name);
const isBold = /bold|black|heavy|semibold|extrabold/.test(name);
let score = 0;
if (isItalic === wantItalic) score += 1;
if (isBold === wantBold) score += 1;
return score;
}
// Empirical width scaling to better match PowerPoint/LibreOffice line breaks.
// A tiny global shrink (about -1.5%) nudges borderline words to wrap the same
// way Office does, with per-script tweaks for cases where our measurer
// systematically under- or over-estimates glyph widths. We intentionally avoid
// per-font calibration so this helper generalizes beyond the regression deck.
function getWidthScaleForParagraph(paragraph) {
if (!paragraph || typeof paragraph.text !== "string") return 1;
const text = paragraph.text;
// Thai script: our measurer tends to slightly over-estimate, which can cause
// extra wraps. Give it a bit more room horizontally.
if (/[ก-๛]/u.test(text)) {
return 1.2;
}
// Arabic: we usually underestimate, so shrink available width a bit more to
// encourage earlier breaks.
if (/[\u0600-\u06FF]/u.test(text)) {
return 0.97;
}
// Base shrink for most Latin and other scripts.
return 0.985;
}
// Build options to pass directly to pptx.addText. We exclude measurement-only
// fields and fill sensible defaults (e.g., fontSize) so callers can spread
// the result into addText just like the image sizing helpers.
function buildPassthroughOptions(opts, fontSizePt, margins) {
const exclude = new Set([
"text",
"lines",
"w", // will be set by calcTextBox
"h", // will be set by calcTextBox
// fontFace/style/weight are useful for addText; allow passthrough
"leading",
"padding",
]);
const out = {};
for (const k of Object.keys(opts)) {
if (!exclude.has(k)) out[k] = opts[k];
}
if (out.fontSize == null) out.fontSize = fontSizePt;
if (opts.margin != null) out.margin = margins;
return out;
}
function getTextMeasurer() {
// Skia-canvas only for accurate shaping and Fontconfig-based resolution.
// Throws if skia-canvas is not available.
const canvas = new Canvas(2, 2);
const ctx = canvas.getContext("2d");
const PX_PER_IN = 96;
return (text, fontSizePt, fontFace, fontStyle, fontWeight) => {
const px = (fontSizePt / 72) * PX_PER_IN;
const style = fontStyle || "normal";
const weight = fontWeight || "normal";
// CSS shorthand: style weight size family
ctx.font = `${style} ${weight} ${px}px ${fontFace || "Arial"}`;
const metrics = ctx.measureText(text);
return (metrics.width || 0) / PX_PER_IN;
};
}
function normalizeMargins(m) {
const toInches = (value) =>
typeof value === "number" && Number.isFinite(value) ? value / 72 : 0;
if (m && typeof m === "object") {
if (Number.isFinite(m.left) || Number.isFinite(m.top)) {
return {
left: toInches(m.left),
right: toInches(m.right),
top: toInches(m.top),
bottom: toInches(m.bottom),
};
}
}
const all = toInches(m);
return { left: all, right: all, top: all, bottom: all };
}
function normalizeText(textOrRuns) {
if (Array.isArray(textOrRuns)) {
return textOrRuns
.map((item) => {
if (typeof item === "string") return item;
if (item && typeof item.text === "string") return item.text;
return "";
})
.join("");
}
return typeof textOrRuns === "string" ? textOrRuns : String(textOrRuns ?? "");
}
function toNumber(v, fallback) {
const n = typeof v === "string" ? parseFloat(v) : v;
return Number.isFinite(n) ? n : fallback;
}
module.exports = {
calcTextBoxHeightSimple,
calcTextBox,
autoFontSize,
};
// Copyright (c) OpenAI. All rights reserved.
"use strict";
// Safe outer shadow helper (avoid inner/outer mix and XML pitfalls)
function safeOuterShadow(
color = "000000",
opacity = 0.25,
angle = 45,
blur = 3,
offset = 2
) {
return {
type: "outer",
color,
opacity,
angle,
blur,
offset,
};
}
module.exports = {
safeOuterShadow,
};
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.
PptxGenJS Helpers
When To Read This
Read this file when you need helper API details, command examples for the bundled Python scripts, or dependency notes for a slide-generation task.
Helper Modules
autoFontSize(textOrRuns, fontFace, opts): Pick a font size that fits a fixed box.calcTextBox(fontSizePt, opts): Estimate text-box geometry from font size and content.calcTextBoxHeightSimple(fontSizePt, numLines, leading?, padding?): Quick text height estimate.imageSizingCrop(pathOrData, x, y, w, h): Center-crop an image into a target box.imageSizingContain(pathOrData, x, y, w, h): Fit an image fully inside a target box.svgToDataUri(svgString): Convert an SVG string into an embeddable data URI.latexToSvgDataUri(texString): Render LaTeX to SVG for crisp equations.getImageDimensions(pathOrData): Read image width, height, type, and aspect ratio.safeOuterShadow(...): Build a safe outer-shadow config for PowerPoint output.codeToRuns(source, language): Convert source code into rich-text runs foraddText.warnIfSlideHasOverlaps(slide, pptx): Emit overlap warnings for diagnostics.warnIfSlideElementsOutOfBounds(slide, pptx): Emit boundary warnings for diagnostics.alignSlideElements(slide, indices, alignment): Align selected elements precisely.distributeSlideElements(slide, indices, direction): Evenly space selected elements.
Dependency Notes
JavaScript helpers expect these packages when you use the corresponding features:
- Core authoring:
pptxgenjs - Text measurement:
skia-canvas,linebreak,fontkit - Syntax highlighting:
prismjs - LaTeX rendering:
mathjax-full
Python scripts expect these packages:
Pillowpdf2imagepython-pptxnumpy
System tools used by the Python scripts:
soffice/ LibreOffice for PPTX to PDF conversion- Poppler tools for PDF size/raster support used by
pdf2image fc-listfor font inspection- Optional rasterization tools for
ensure_raster_image.py: Inkscape, ImageMagick, Ghostscript,heif-convert,JxrDecApp
Script Notes
render_slides.py: Convert a deck to PNGs. Good for visual review and diffing.slides_test.py: Add a gray border outside the original canvas, render, and check whether any content leaks into the border.create_montage.py: Combine multiple rendered slide images into a single overview image.detect_font.py: Distinguish between fonts that are missing entirely and fonts that are installed but substituted during rendering.ensure_raster_image.py: Produce a PNG from common vector or unusual raster formats so you can inspect or place the asset easily.
Practical Rules
- Default to
LAYOUT_WIDEunless the source material says otherwise. - Set font families explicitly before measuring text.
- Use
valign: "top"for content boxes that may grow. - Prefer native PowerPoint charts over rendered images when the chart is simple and likely to be edited later.
- Use SVG instead of PNG for diagrams whenever possible.
#!/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()
#!/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()
#!/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()