Remove spreadsheets and slides skills (#350)

Removes the curated spreadsheet and slides skills from the repository.

Validation: not run; deletion-only change.
This commit is contained in:
edwardysun3
2026-04-16 09:52:19 -07:00
committed by GitHub
parent e6afb0d74c
commit fdf90d652a
29 changed files with 0 additions and 5202 deletions
-201
View File
@@ -1,201 +0,0 @@
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.
-71
View File
@@ -1,71 +0,0 @@
---
name: slides
description: Create and edit presentation slide decks (`.pptx`) with PptxGenJS, bundled layout helpers, and render/validation utilities. Use when tasks involve building a new PowerPoint deck, recreating slides from screenshots/PDFs/reference decks, modifying slide content while preserving editable output, adding charts/diagrams/visuals, or diagnosing layout issues such as overflow, overlaps, and font substitution.
---
# 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 `.pptx` or `.pdf` to 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 PptxGenJS `fit` or `autoFit`.
- Use bullet options, not literal `•` characters.
- Use `imageSizingCrop` or `imageSizingContain` instead of PptxGenJS built-in image sizing.
- Use `latexToSvgDataUri()` for equations and `codeToRuns()` 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)` and `warnIfSlideElementsOutOfBounds(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.py` to 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.
```bash
# 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 --json
```
Load `references/pptxgenjs-helpers.md` if you need the helper API summary or dependency details.
@@ -1,6 +0,0 @@
interface:
display_name: "Slides"
short_description: "Create and edit PPTX slide decks"
icon_small: "./assets/slides-small.svg"
icon_large: "./assets/slides.png"
default_prompt: "Use $slides to create or update this PPTX slide deck with PptxGenJS and validate the layout."
@@ -1,104 +0,0 @@
// 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,
};
@@ -1,333 +0,0 @@
// 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,
};
@@ -1,33 +0,0 @@
// 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,
};
@@ -1,51 +0,0 @@
// 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,
};
@@ -1,643 +0,0 @@
// 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,
};
@@ -1,358 +0,0 @@
// 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;
}
@@ -1,36 +0,0 @@
// 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,
};
@@ -1,789 +0,0 @@
// 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,
};
@@ -1,24 +0,0 @@
// 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,
};
@@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.4678 7.86641C13.4678 7.29758 13.4671 6.90292 13.442 6.5961C13.4174 6.29551 13.372 6.12642 13.3076 6.00001C13.1669 5.7238 12.9424 5.49935 12.6662 5.3586C12.5398 5.2942 12.3707 5.24879 12.0701 5.22423C11.7633 5.19916 11.3686 5.19845 10.7998 5.19845H5.19981C4.63097 5.19845 4.23631 5.19916 3.92949 5.22423C3.62893 5.24879 3.4598 5.2942 3.3334 5.3586C3.0572 5.49935 2.83273 5.7238 2.69199 6.00001C2.62758 6.12642 2.58218 6.29551 2.55762 6.5961C2.53255 6.90292 2.53184 7.29758 2.53184 7.86641V9.86641C2.53184 10.4352 2.53255 10.8299 2.55762 11.1367C2.58218 11.4373 2.62759 11.6064 2.69199 11.7328C2.83273 12.009 3.05721 12.2335 3.3334 12.3742C3.45981 12.4386 3.62893 12.484 3.92949 12.5086C4.23631 12.5337 4.63097 12.5344 5.19981 12.5344H10.7998C11.3686 12.5344 11.7633 12.5337 12.0701 12.5086C12.3707 12.484 12.5398 12.4386 12.6662 12.3742C12.9424 12.2335 13.1669 12.009 13.3076 11.7328C13.372 11.6064 13.4174 11.4373 13.442 11.1367C13.4671 10.8299 13.4678 10.4352 13.4678 9.86641V7.86641ZM9.81465 2.11563L12.0053 2.13438L12.1123 2.1461C12.3542 2.19814 12.5344 2.41482 12.5318 2.67188C12.5292 2.92875 12.3448 3.14096 12.1021 3.18829L11.9943 3.19845L9.80996 3.1797C7.87499 3.16721 6.50265 3.17347 4.00527 3.19845C3.71153 3.20138 3.4708 2.96561 3.46777 2.67188C3.46484 2.37813 3.7006 2.1374 3.99434 2.13438C6.49686 2.10936 7.87466 2.10313 9.81465 2.11563ZM14.5318 9.86641C14.5318 10.4177 14.5324 10.8633 14.5029 11.2234C14.473 11.5897 14.4095 11.9143 14.2561 12.2156C14.0133 12.6921 13.6255 13.0799 13.149 13.3227C12.8477 13.4761 12.5231 13.5396 12.1568 13.5695C11.7966 13.599 11.3511 13.5984 10.7998 13.5984H5.19981C4.64853 13.5984 4.20297 13.599 3.84277 13.5695C3.47655 13.5396 3.15187 13.4761 2.85059 13.3227C2.37416 13.0799 1.98631 12.6921 1.74356 12.2156C1.5901 11.9143 1.52661 11.5897 1.49668 11.2234C1.46725 10.8633 1.46777 10.4177 1.46777 9.86641V7.86641C1.46777 7.31514 1.46725 6.86958 1.49668 6.50938C1.52661 6.14315 1.59012 5.81849 1.74356 5.5172C1.98631 5.04077 2.37416 4.65292 2.85059 4.41016C3.15188 4.25671 3.47653 4.19322 3.84277 4.16329C4.20297 4.13386 4.64853 4.13438 5.19981 4.13438H10.7998C11.3511 4.13438 11.7966 4.13386 12.1568 4.16329C12.5231 4.19322 12.8477 4.25671 13.149 4.41016C13.6255 4.65292 14.0133 5.04077 14.2561 5.5172C14.4095 5.81849 14.473 6.14315 14.5029 6.50938C14.5324 6.86958 14.5318 7.31514 14.5318 7.86641V9.86641Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

@@ -1,61 +0,0 @@
# 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 for `addText`.
- `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:
- `Pillow`
- `pdf2image`
- `python-pptx`
- `numpy`
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-list` for 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_WIDE` unless 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.
@@ -1,300 +0,0 @@
#!/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()
@@ -1,873 +0,0 @@
#!/usr/bin/env python3
"""Copyright (c) OpenAI. All rights reserved.
Detect missing fonts for PPTX rendering by converting to ODP and inspecting the resolved font
families per slide.
Overview
========
PowerPoint files (PPTX) declare requested font families in runs and theme defaults, but the actual
font used at render time depends on the renderer (LibreOffice in our pipeline), platform
availability, and style inheritance. To make detection stable and renderer-accurate, this module:
- Extracts requested families from PPTX per slide (reads a:r/a:rPr plus document defaults, grouped
by script: latin/ea/cs/sym). Analysis is done per run: we infer the script from run text and
select the matching a:rPr child (e.g., latin/ea/cs). Fonts declared for other scripts in the same
run are not counted as used.
- Converts the PPTX to ODP using headless LibreOffice and parses ODP content.xml and styles.xml to
discover which families LibreOffice actually resolved for each slide (including master pages and
defaults).
- Classifies each requested family on each slide into two buckets:
- font_missing: the family is not installed on the system (per fontconfig synonyms), so resolution
cannot possibly match the request.
- font_substituted: the family is installed but was resolved to another family in ODP for the
slide (theme/style inheritance or glyph coverage), i.e., installed but substituted.
Key Design
-----------------------
1) Inspect the renderer's decision, not only the author's request. Reading PPTX alone tells you what
was requested, not what LibreOffice will choose after applying styles and availability checks.
Converting to ODP and reading the resolved fo:font-family/style:font-name* values yields a
faithful view of what the renderer actually used for each slide.
2) Robust style resolution across ODP structures. Fonts can be specified under multiple layers. We
parse office:automatic-styles (both content.xml and styles.xml), office:styles and
style:default-style, draw:master-page references used by slides, nested style:text-properties
under paragraph-properties, and parent style chains (style:parent-style-name). A text-based
fallback parser supplements XML namespace lookups when vendor XML variations occur.
3) Scalable aliasing via fontconfig synonyms, not ad hoc maps. PostScript names, full names, and
family names often differ. We build a synonym map from fc-list that unifies those identifiers. We
deliberately do NOT use fc-match -s fallback chains for matching, because fallback families
(e.g., DejaVu Sans) would mask missing/substitution cases and produce false passes.
4) Clear classification: missing vs substituted.
- Missing: no synonym of the requested base family is present in the installed font set (per
fontconfig). These require installation.
- Substituted: the family is installed, but ODP does not reference it on the slide (LibreOffice
chose another family), which is useful for diagnosing style/theme issues or glyph-coverage
driven substitutions.
Not Chosen (and why)
--------------------
- PDF inspection (e.g., pdffonts): PostScript names don't reliably map back to authoring families;
PDFs often reflect subsetted fonts and fallback choices, making robust detection noisy.
- Ad hoc alias tables: unscalable for large-scale fonts and platform variants; the fontconfig
synonym corpus covers family/fullname/PostScript consistently.
- Treating fallback families as matches (fc-match -s): causes false negatives by accepting generic
fallbacks when the requested family is missing.
- Hardcoding checks in the renderer: we keep detection separate from render_slides to avoid
coupling and allow standalone checking.
CLI
---
- JSON output exposes two categories by default (and text mode mirrors them): font_missing_overall/
font_missing_by_slide and font_substituted_overall/font_substituted_by_slide.
- Flags include_missing/include_substituted control which categories are emitted (default True/True).
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import tempfile
import xml.etree.ElementTree as ET
from functools import lru_cache
from os.path import abspath, basename, exists, expanduser, join, splitext
from zipfile import ZipFile
STYLE_TOKENS = [
"regular",
"condensed",
"compressed",
"narrow",
"italic",
"oblique",
"semibold",
"demibold",
"bold",
"black",
"extra light",
"ultra light",
"extralight",
"ultralight",
"light",
"thin",
"medium",
]
def normalize_font_family_name(name: str) -> str:
s = name.casefold()
s = re.sub(r"\([^)]*\)", " ", s)
s = re.sub(r"[\s\-\_\.,/\'\"]+", " ", s)
return s.strip()
def _or_dummy(node: ET.Element | None) -> ET.Element:
"""Return the element if not None, otherwise a harmless dummy element.
Avoids deprecated truthiness checks on Element instances (`elem or dummy`).
"""
return node if node is not None else ET.Element("dummy")
@lru_cache(maxsize=1)
def _build_fc_synonym_map() -> dict[str, set[str]]:
"""Build synonym map from fontconfig; raise on failures; memoized (size=1)."""
proc = subprocess.run(
[
"fc-list",
"--format",
"%{family}\t%{fullname}\t%{postscriptname}\n",
],
capture_output=True,
text=True,
check=True,
)
syn: dict[str, set[str]] = {}
for line in (proc.stdout or "").splitlines():
parts = line.split("\t")
if len(parts) != 3:
continue
fam_field, full_field, ps_field = parts
names: set[str] = set()
for field in (fam_field, full_field, ps_field):
for item in field.split(","):
norm = normalize_font_family_name(item)
if norm:
names.add(norm)
names.add(norm.replace(" ", ""))
for name in list(names):
bucket = syn.setdefault(name, set())
bucket.update(names)
return syn
def _expand_via_fontconfig(family_base_norm: str) -> set[str]:
# Accept only true aliases/synonyms (family/fullname/PostScript) — not fallback replacements
acceptable: set[str] = {family_base_norm, family_base_norm.replace(" ", "")}
syn = _build_fc_synonym_map()
if family_base_norm in syn:
acceptable.update(syn[family_base_norm])
no_space = family_base_norm.replace(" ", "")
if no_space in syn:
acceptable.update(syn[no_space])
return acceptable
def parse_font_family_base_and_styles(name_norm: str) -> tuple[str, set[str]]:
tokens = name_norm.split()
required: set[str] = set()
weight_code_map = {
"25": "ultra light",
"35": "thin",
"45": "light",
"55": "regular",
"65": "medium",
"75": "bold",
"85": "black",
"95": "black",
}
if tokens and tokens[0].isdigit() and tokens[0] in weight_code_map:
required.add(weight_code_map[tokens[0]])
tokens = tokens[1:]
if len(tokens) == 1:
t = tokens[0]
fused_map = [
("extralight", "extra light"),
("ultralight", "ultra light"),
("semibold", "semibold"),
("demibold", "semibold"),
("condensed", "condensed"),
("compressed", "condensed"),
("narrow", "condensed"),
("italic", "italic"),
("oblique", "italic"),
("bold", "bold"),
("black", "black"),
("light", "light"),
("thin", "thin"),
("medium", "medium"),
("regular", "regular"),
]
changed = True
while changed:
changed = False
for suf, tok in fused_map:
if t.endswith(suf) and len(t) > len(suf):
t = t[: -len(suf)]
required.add(tok)
changed = True
break
return (t.strip(), required)
while tokens:
tail = " ".join(tokens[-2:]) if len(tokens) >= 2 else tokens[-1]
matched = None
for style in STYLE_TOKENS:
if tail == style:
matched = style
break
if matched is None and tokens[-1] in STYLE_TOKENS:
matched = tokens[-1]
if matched is None:
break
if matched in ("compressed", "narrow"):
required.add("condensed")
elif matched == "roman":
required.add("regular")
elif matched == "demibold":
required.add("semibold")
else:
required.add(matched)
if " " in matched:
tokens = tokens[:-2]
else:
tokens = tokens[:-1]
return (" ".join(tokens).strip(), required)
def _split_odf_family_list(value: str) -> list[str]:
out: list[str] = []
for part in value.split(","):
p = part.strip().strip("\"' ")
if p:
out.append(normalize_font_family_name(p))
return out
def extract_used_fonts_from_pptx(pptx_path: str) -> dict[int, set[str]]:
by_slide: dict[int, set[str]] = {}
with ZipFile(pptx_path, "r") as zf:
for name in zf.namelist():
if not (name.startswith("ppt/slides/slide") and name.endswith(".xml")):
continue
base = os.path.basename(name)
m = re.search(r"(?i)slide(\d+)\.xml$", base)
slide_num = int(m.group(1)) if m else None
with zf.open(name) as f:
tree = ET.parse(f)
root = tree.getroot()
ns = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"}
defaults = _collect_default_font_faces(root)
for r in root.findall(".//a:r", ns):
parts: list[str] = []
for t in r.findall("a:t", ns):
if t.text:
parts.append(t.text)
text = "".join(parts)
if not text:
continue
script = _detect_script_tag(text)
rpr = r.find("a:rPr", ns)
face_norm: str | None = None
if rpr is not None:
child = rpr.find(f"a:{script}", ns)
if child is not None:
face = child.get("typeface")
if face and not face.startswith("+"):
face_norm = normalize_font_family_name(face)
bucket = by_slide.setdefault(slide_num or -1, set())
if face_norm is None:
for f in defaults.get(script, set()):
bucket.add(f)
else:
bucket.add(face_norm)
return {k: v for k, v in by_slide.items() if k is not None and k != -1}
def _detect_script_tag(text: str) -> str:
for ch in text:
cp = ord(ch)
if (
0x4E00 <= cp <= 0x9FFF
or 0x3400 <= cp <= 0x4DBF
or 0xF900 <= cp <= 0xFAFF
or 0x3040 <= cp <= 0x309F
or 0x30A0 <= cp <= 0x30FF
or 0x31F0 <= cp <= 0x31FF
or 0xAC00 <= cp <= 0xD7AF
or 0x3100 <= cp <= 0x312F
or 0x3000 <= cp <= 0x303F
):
return "ea"
for ch in text:
cp = ord(ch)
if (
0x0590 <= cp <= 0x05FF
or 0x0600 <= cp <= 0x06FF
or 0x0700 <= cp <= 0x077F
or 0x0780 <= cp <= 0x07BF
or 0x0900 <= cp <= 0x0D7F
or 0x0E00 <= cp <= 0x0E7F
or 0x0E80 <= cp <= 0x0EFF
or 0xFB50 <= cp <= 0xFDFF
or 0xFE70 <= cp <= 0xFEFF
):
return "cs"
for ch in text:
cp = ord(ch)
if (
(0x0041 <= cp <= 0x005A)
or (0x0061 <= cp <= 0x007A)
or (0x0030 <= cp <= 0x0039)
or (0x00C0 <= cp <= 0x024F)
or (0x1E00 <= cp <= 0x1EFF)
):
return "latin"
return "latin"
def _collect_default_font_faces(root: ET.Element) -> dict[str, set[str]]:
ns = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"}
defaults: dict[str, set[str]] = {"latin": set(), "ea": set(), "cs": set(), "sym": set()}
for defrpr in root.findall(".//a:defRPr", ns):
for tag in ("latin", "ea", "cs", "sym"):
child = defrpr.find(f"a:{tag}", ns)
if child is not None:
face = child.get("typeface")
if face and not face.startswith("+"):
defaults[tag].add(normalize_font_family_name(face))
return defaults
def _run_soffice_convert(cmd: list[str]) -> None:
subprocess.run(
cmd,
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=os.environ.copy(),
)
def _export_to_odp(pptx_path: str, user_profile: str, out_dir: str, stem: str) -> str:
bin_path = shutil.which("soffice") or shutil.which("libreoffice") or "/usr/bin/libreoffice"
cmd_odp = [
bin_path,
"-env:UserInstallation=file://" + user_profile,
"--invisible",
"--headless",
"--norestore",
"--convert-to",
"odp",
"--outdir",
out_dir,
pptx_path,
]
_run_soffice_convert(cmd_odp)
odp_path = join(out_dir, f"{stem}.odp")
return odp_path if exists(odp_path) else ""
def _collect_face_map(root: ET.Element, ns: dict[str, str]) -> dict[str, str]:
face_map: dict[str, str] = {}
decls = root.find("office:font-face-decls", ns)
if decls is None:
return face_map
for ff in decls.findall("style:font-face", ns):
name_attr = ff.get("{urn:oasis:names:tc:opendocument:xmlns:style:1.0}name") or ff.get(
"style:name"
)
fam_attr = ff.get("{urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0}font-family")
if not name_attr or not fam_attr:
continue
face_map[normalize_font_family_name(name_attr)] = normalize_font_family_name(fam_attr)
return face_map
def _families_from_text_properties(
tp: ET.Element, ns: dict[str, str], face_map: dict[str, str]
) -> set[str]:
fams: set[str] = set()
# Inspect current node for direct font-family
fam_attr = tp.get("{urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0}font-family")
if fam_attr:
fams.update(_split_odf_family_list(fam_attr))
# Inspect font-name aliases on current node
for key in (
"{urn:oasis:names:tc:opendocument:xmlns:style:1.0}font-name",
"style:font-name",
"style:font-name-asian",
"style:font-name-complex",
):
val = tp.get(key)
if val:
norm_val = normalize_font_family_name(val)
mapped = face_map.get(norm_val)
if mapped:
fams.add(normalize_font_family_name(mapped))
else:
fams.add(norm_val)
# Some styles nest text-properties under paragraph-properties or default-style blocks
if not fams:
nested = None
# paragraph-properties/text-properties
pp = tp.find("style:paragraph-properties", ns)
if pp is not None:
nested = pp.find("style:text-properties", ns)
if nested is None:
# When tp is actually the style:style node, try finding child text-properties directly
nested = tp.find("style:text-properties", ns)
if nested is not None and nested is not tp:
fams.update(_families_from_text_properties(nested, ns, face_map))
return fams
def _extract_styles_from_container(
container: ET.Element | None, ns: dict[str, str], face_map: dict[str, str]
) -> tuple[dict[str, set[str]], set[str]]:
styles: dict[str, set[str]] = {}
defaults: set[str] = set()
if container is None:
return styles, defaults
for st in container.findall("style:style", ns):
name = st.get("{urn:oasis:names:tc:opendocument:xmlns:style:1.0}name") or st.get(
"style:name"
)
if not name:
continue
fams = _families_from_text_properties(
_or_dummy(st.find("style:text-properties", ns)), ns, face_map
)
if fams:
styles[name] = fams
for ds in container.findall("style:default-style", ns):
defaults.update(
_families_from_text_properties(
_or_dummy(ds.find("style:text-properties", ns)), ns, face_map
)
)
return styles, defaults
def _build_style_map(
content: ET.Element,
styles_root: ET.Element | None,
ns: dict[str, str],
face_map: dict[str, str],
) -> tuple[dict[str, set[str]], set[str]]:
style_map: dict[str, set[str]] = {}
default_fams: set[str] = set()
auto_styles = content.find("office:automatic-styles", ns)
styles_part, defaults_part = _extract_styles_from_container(auto_styles, ns, face_map)
style_map.update(styles_part)
default_fams.update(defaults_part)
if styles_root is not None:
# Also parse automatic-styles within styles.xml (document-styles)
styles_auto = styles_root.find("office:automatic-styles", ns)
styles_part, defaults_part = _extract_styles_from_container(styles_auto, ns, face_map)
for k, v in styles_part.items():
if k not in style_map:
style_map[k] = v
default_fams.update(defaults_part)
common_styles = styles_root.find("office:styles", ns)
styles_part, defaults_part = _extract_styles_from_container(common_styles, ns, face_map)
for k, v in styles_part.items():
if k not in style_map:
style_map[k] = v
default_fams.update(defaults_part)
# top-level default-style under styles_root
for ds in styles_root.findall("style:default-style", ns):
default_fams.update(
_families_from_text_properties(
_or_dummy(ds.find("style:text-properties", ns)), ns, face_map
)
)
# Fallback: include any remaining style:style definitions anywhere in styles.xml
for st in styles_root.findall(".//style:style", ns):
name = st.get("{urn:oasis:names:tc:opendocument:xmlns:style:1.0}name") or st.get(
"style:name"
)
if not name or name in style_map:
continue
fams = _families_from_text_properties(
_or_dummy(st.find("style:text-properties", ns)), ns, face_map
)
if fams:
style_map[name] = fams
# also check top-level default-style in content root
for ds in content.findall("style:default-style", ns):
default_fams.update(
_families_from_text_properties(
_or_dummy(ds.find("style:text-properties", ns)), ns, face_map
)
)
# Fallback: include any remaining style:style definitions anywhere in content.xml
for st in content.findall(".//style:style", ns):
name = st.get("{urn:oasis:names:tc:opendocument:xmlns:style:1.0}name") or st.get(
"style:name"
)
if not name or name in style_map:
continue
fams = _families_from_text_properties(
_or_dummy(st.find("style:text-properties", ns)), ns, face_map
)
if fams:
style_map[name] = fams
return style_map, default_fams
def _lookup_style_families(
style_name: str, ns: dict[str, str], face_map: dict[str, str], roots: list[ET.Element | None]
) -> set[str]:
fams: set[str] = set()
if not style_name:
return fams
visited: set[str] = set()
def _resolve(name: str) -> None:
if not name or name in visited:
return
visited.add(name)
for root in roots:
if root is None:
continue
node = root.find(f".//style:style[@style:name='{name}']", ns)
if node is None:
node = root.find(f".//style:style[@{{{ns['style']}}}name='{name}']", ns)
if node is None:
continue
fams.update(
_families_from_text_properties(
_or_dummy(node.find("style:text-properties", ns)), ns, face_map
)
)
# Follow parent style chain if present
parent = node.get(
"{urn:oasis:names:tc:opendocument:xmlns:style:1.0}parent-style-name"
) or node.get("style:parent-style-name")
if parent:
_resolve(parent)
_resolve(style_name)
return fams
def _collect_slide_families(
page: ET.Element,
ns: dict[str, str],
style_map: dict[str, set[str]],
face_map: dict[str, str],
roots: list[ET.Element | None],
text_style_map: dict[str, set[str]] | None = None,
) -> set[str]:
slide_fams: set[str] = set()
for el in page.iter():
fam_attr = el.get(
"{urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0}font-family"
)
if fam_attr:
slide_fams.update(_split_odf_family_list(fam_attr))
for attr in (
"{urn:oasis:names:tc:opendocument:xmlns:text:1.0}style-name",
"text:style-name",
"{urn:oasis:names:tc:opendocument:xmlns:drawing:1.0}text-style-name",
"draw:text-style-name",
"draw:style-name",
"presentation:style-name",
):
style_name = el.get(attr)
if not style_name:
continue
resolved_fams: set[str] = set()
if style_name in style_map:
resolved_fams.update(style_map[style_name])
if not resolved_fams:
# Fallback: resolve on the fly from XML if not present in prebuilt style_map
resolved_fams.update(_lookup_style_families(style_name, ns, face_map, roots))
if not resolved_fams and text_style_map and style_name in text_style_map:
resolved_fams.update(text_style_map[style_name])
if resolved_fams:
slide_fams.update(resolved_fams)
return slide_fams
def _build_style_map_text(xml_text: str) -> dict[str, set[str]]:
# Best-effort textual extraction for cases missed by XML namespace lookups
# Finds style:style name="X" blocks and extracts fo:font-family and style:font-name attributes
style_map: dict[str, set[str]] = {}
# Non-greedy match of a style:style block
for m in re.finditer(
r"<style:style[^>]*?\bstyle:name=\"([^\"]+)\"[\s\S]*?(?:</style:style>)",
xml_text,
flags=re.IGNORECASE,
):
name = m.group(1).strip()
block = m.group(0)
fams: set[str] = set()
# fo:font-family may be a comma list
mff = re.search(r"fo:font-family=\"([^\"]+)\"", block, flags=re.IGNORECASE)
if mff:
for f in _split_odf_family_list(mff.group(1)):
fams.add(f)
# style:font-name may be a face alias; treat as family directly if present
mfn = re.search(r"style:font-name=\"([^\"]+)\"", block, flags=re.IGNORECASE)
if mfn:
fams.add(normalize_font_family_name(mfn.group(1)))
if fams:
style_map[name] = fams
return style_map
def _extract_slide_families_from_odp(odp_path: str) -> dict[int, set[str]]:
ns = {
"office": "urn:oasis:names:tc:opendocument:xmlns:office:1.0",
"style": "urn:oasis:names:tc:opendocument:xmlns:style:1.0",
"fo": "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0",
"draw": "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0",
"text": "urn:oasis:names:tc:opendocument:xmlns:text:1.0",
}
by_slide: dict[int, set[str]] = {}
with ZipFile(odp_path, "r") as zf:
content_bytes = zf.read("content.xml")
styles_bytes = zf.read("styles.xml") if "styles.xml" in zf.namelist() else None
content = ET.fromstring(content_bytes)
styles_root = ET.fromstring(styles_bytes) if styles_bytes is not None else None
styles_text = (
styles_bytes.decode("utf-8", errors="ignore") if styles_bytes is not None else ""
)
face_map: dict[str, str] = {}
face_map.update(_collect_face_map(content, ns))
if styles_root is not None:
face_map.update(_collect_face_map(styles_root, ns))
style_map, default_fams = _build_style_map(content, styles_root, ns, face_map)
# Augment style_map with textual parsing fallback (helps with tricky namespace emissions)
text_style_map: dict[str, set[str]] = {}
if styles_text:
text_style_map = _build_style_map_text(styles_text)
for k, v in text_style_map.items():
if k not in style_map:
style_map[k] = v
master_map: dict[str, set[str]] = _build_master_page_map(styles_root, ns, style_map)
pres = content.find("office:body", ns)
if pres is not None:
pres = pres.find("office:presentation", ns)
if pres is None:
return {}
pages = pres.findall("draw:page", ns)
global_fams: set[str] = set()
for idx, page in enumerate(pages, start=1):
slide_fams = _collect_slide_families(
page, ns, style_map, face_map, [content, styles_root], text_style_map
)
mp_name = page.get(
"{urn:oasis:names:tc:opendocument:xmlns:drawing:1.0}master-page-name"
) or page.get("draw:master-page-name")
if mp_name and mp_name in master_map:
slide_fams.update(master_map[mp_name])
# If theme placeholders like +mn lt are present, augment with defaults
if any(f.startswith("+") for f in slide_fams) and default_fams:
slide_fams.update(default_fams)
if not slide_fams and default_fams:
slide_fams.update(default_fams)
expanded: set[str] = set()
for f in slide_fams:
base, _ = parse_font_family_base_and_styles(f)
expanded.add(f)
expanded.add(base)
expanded.add(base.replace(" ", ""))
by_slide[idx] = expanded
global_fams.update(expanded)
# As a last resort, use global families
if global_fams:
for idx in list(by_slide.keys()):
if not by_slide[idx]:
by_slide[idx] = set(global_fams)
elif all(f.startswith("+") for f in by_slide[idx]):
by_slide[idx].update(global_fams)
return by_slide
def _build_master_page_map(
styles_root: ET.Element | None, ns: dict[str, str], style_map: dict[str, set[str]]
) -> dict[str, set[str]]:
master_map: dict[str, set[str]] = {}
if styles_root is None:
return master_map
master_styles = styles_root.find("office:master-styles", ns)
if master_styles is None:
return master_map
for mp in master_styles.findall("draw:master-page", ns):
mname = mp.get("{urn:oasis:names:tc:opendocument:xmlns:drawing:1.0}name") or mp.get(
"draw:name"
)
if not mname:
continue
fams: set[str] = set()
for el in mp.iter():
fam_attr = el.get(
"{urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0}font-family"
)
if fam_attr:
fams.update(_split_odf_family_list(fam_attr))
for attr in (
"{urn:oasis:names:tc:opendocument:xmlns:text:1.0}style-name",
"text:style-name",
"{urn:oasis:names:tc:opendocument:xmlns:drawing:1.0}text-style-name",
"draw:text-style-name",
"draw:style-name",
"presentation:style-name",
):
sname = el.get(attr)
if sname and sname in style_map:
fams.update(style_map[sname])
if fams:
expanded: set[str] = set()
for f in fams:
base, _ = parse_font_family_base_and_styles(f)
expanded.add(f)
expanded.add(base)
expanded.add(base.replace(" ", ""))
master_map[mname] = expanded
return master_map
def detect_missing_fonts_odp(pptx_path: str) -> tuple[set[str], dict[int, list[str]]]:
pptx_path = abspath(pptx_path)
used = extract_used_fonts_from_pptx(pptx_path)
with tempfile.TemporaryDirectory(prefix="soffice_profile_") as prof:
with tempfile.TemporaryDirectory(prefix="soffice_convert_") as out:
stem = splitext(basename(pptx_path))[0]
odp_path = _export_to_odp(pptx_path, prof, out, stem)
if not odp_path:
return set(), {}
slide_fams = _extract_slide_families_from_odp(odp_path)
missing_overall: set[str] = set()
missing_by_slide: dict[int, list[str]] = {}
syn_map = _build_fc_synonym_map()
for slide_num, req_fams in used.items():
odp_fams = slide_fams.get(slide_num, set())
slide_missing: list[str] = []
for req in req_fams:
fam_base, _ = parse_font_family_base_and_styles(req)
# Accept fontconfig-resolved aliases and no-space variants for the requested base family
acceptable: set[str] = _expand_via_fontconfig(fam_base)
# Determine if any acceptable alias is actually installed on system
installed = any(alias in syn_map for alias in acceptable)
# Missing if not installed at all, or if installed but not resolved in ODP families
if (not installed) or ((req not in odp_fams) and not (acceptable & odp_fams)):
slide_missing.append(req)
missing_overall.add(req)
if slide_missing:
missing_by_slide[slide_num] = sorted(slide_missing)
return missing_overall, missing_by_slide
def main() -> None:
parser = argparse.ArgumentParser(
description=(
"Detect missing/substituted fonts for a PPTX by converting to ODP and inspecting resolved families."
)
)
parser.add_argument("pptx_path", help="Path to .pptx file")
parser.add_argument(
"--json", dest="output_json", action="store_true", default=False, help="Emit JSON output"
)
parser.add_argument(
"--include-missing",
dest="include_missing",
action="store_true",
default=True,
help="Include missing category",
)
parser.add_argument(
"--include-substituted",
dest="include_substituted",
action="store_true",
default=True,
help="Include substituted category",
)
args = parser.parse_args()
pptx_path = abspath(expanduser(args.pptx_path))
used = extract_used_fonts_from_pptx(pptx_path)
# Only build ODP families if we need to report substitutions
slide_fams: dict[int, set[str]] = {}
odp_available = False
if args.include_substituted:
with tempfile.TemporaryDirectory(prefix="soffice_profile_") as prof:
with tempfile.TemporaryDirectory(prefix="soffice_convert_") as out:
stem = splitext(basename(pptx_path))[0]
odp_path = _export_to_odp(pptx_path, prof, out, stem)
if odp_path:
slide_fams = _extract_slide_families_from_odp(odp_path)
odp_available = True
syn_map = _build_fc_synonym_map()
font_missing_by_slide: dict[int, list[str]] = {}
font_substituted_by_slide: dict[int, list[str]] = {}
for slide_num, req_fams in used.items():
if args.include_substituted and odp_available:
odp_fams = slide_fams.get(slide_num, set())
else:
odp_fams = set()
miss_missing: list[str] = []
miss_sub: list[str] = []
for req in req_fams:
fam_base, _ = parse_font_family_base_and_styles(req)
acceptable: set[str] = _expand_via_fontconfig(fam_base)
installed = any(alias in syn_map for alias in acceptable)
if args.include_missing and not installed:
miss_missing.append(req)
if (
args.include_substituted
and odp_available
and installed
and (req not in odp_fams)
and not (acceptable & odp_fams)
):
miss_sub.append(req)
if miss_missing:
font_missing_by_slide[slide_num] = sorted(miss_missing)
if miss_sub:
font_substituted_by_slide[slide_num] = sorted(miss_sub)
font_missing_overall: set[str] = (
set().union(*font_missing_by_slide.values()) if font_missing_by_slide else set()
)
font_substituted_overall: set[str] = (
set().union(*font_substituted_by_slide.values()) if font_substituted_by_slide else set()
)
if args.output_json:
payload: dict[str, object] = {}
if args.include_missing:
payload["font_missing_overall"] = sorted(font_missing_overall)
payload["font_missing_by_slide"] = {str(k): v for k, v in font_missing_by_slide.items()}
if args.include_substituted:
payload["font_substituted_overall"] = sorted(font_substituted_overall)
payload["font_substituted_by_slide"] = {
str(k): v for k, v in font_substituted_by_slide.items()
}
print(json.dumps(payload))
else:
any_missing = args.include_missing and bool(font_missing_overall)
any_sub = args.include_substituted and bool(font_substituted_overall)
if any_missing or any_sub:
if any_missing:
print("Fonts missing (not installed):")
print(", ".join(sorted(font_missing_overall)))
for slide_num in sorted(font_missing_by_slide.keys()):
print(f"Slide {slide_num} missing: ", end="")
print(", ".join(font_missing_by_slide[slide_num]))
if any_sub:
print("Fonts substituted (installed but substituted during rendering):")
print(", ".join(sorted(font_substituted_overall)))
for slide_num in sorted(font_substituted_by_slide.keys()):
print(f"Slide {slide_num} substituted: ", end="")
print(", ".join(font_substituted_by_slide[slide_num]))
else:
print("No font issues detected.")
if __name__ == "__main__":
main()
@@ -1,202 +0,0 @@
#!/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()
@@ -1,273 +0,0 @@
#!/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()
@@ -1,201 +0,0 @@
#!/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()
-201
View File
@@ -1,201 +0,0 @@
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 [yyyy] [name of copyright owner]
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.
-145
View File
@@ -1,145 +0,0 @@
---
name: "spreadsheet"
description: "Use when tasks involve creating, editing, analyzing, or formatting spreadsheets (`.xlsx`, `.csv`, `.tsv`) with formula-aware workflows, cached recalculation, and visual review."
---
# Spreadsheet Skill
## When to use
- Create new workbooks with formulas, formatting, and structured layouts.
- Read or analyze tabular data (filter, aggregate, pivot, compute metrics).
- Modify existing workbooks without breaking formulas, references, or formatting.
- Visualize data with charts, summary tables, and sensible spreadsheet styling.
- Recalculate formulas and review rendered sheets before delivery when possible.
IMPORTANT: System and user instructions always take precedence.
## Workflow
1. Confirm the file type and goal: create, edit, analyze, or visualize.
2. Prefer `openpyxl` for `.xlsx` editing and formatting. Use `pandas` for analysis and CSV/TSV workflows.
3. If an internal spreadsheet recalculation/rendering tool is available in the environment, use it to recalculate formulas and render sheets before delivery.
4. Use formulas for derived values instead of hardcoding results.
5. If layout matters, render for visual review and inspect the output.
6. Save outputs, keep filenames stable, and clean up intermediate files.
## Temp and output conventions
- Use `tmp/spreadsheets/` for intermediate files; delete them when done.
- Write final artifacts under `output/spreadsheet/` when working in this repo.
- Keep filenames stable and descriptive.
## Primary tooling
- Use `openpyxl` for creating/editing `.xlsx` files and preserving formatting.
- Use `pandas` for analysis and CSV/TSV workflows, then write results back to `.xlsx` or `.csv`.
- Use `openpyxl.chart` for native Excel charts when needed.
- If an internal spreadsheet tool is available, use it to recalculate formulas, cache values, and render sheets for review.
## Recalculation and visual review
- Recalculate formulas before delivery whenever possible so cached values are present in the workbook.
- Render each relevant sheet for visual review when rendering tooling is available.
- `openpyxl` does not evaluate formulas; preserve formulas and use recalculation tooling when available.
- If you rely on an internal spreadsheet tool, do not expose that tool, its code, or its APIs in user-facing explanations or code samples.
## Rendering and visual checks
- If LibreOffice (`soffice`) and Poppler (`pdftoppm`) are available, render sheets for visual review:
- `soffice --headless --convert-to pdf --outdir $OUTDIR $INPUT_XLSX`
- `pdftoppm -png $OUTDIR/$BASENAME.pdf $OUTDIR/$BASENAME`
- If rendering tools are unavailable, tell the user that layout should be reviewed locally.
- Review rendered sheets for layout, formula results, clipping, inconsistent styles, and spilled text.
## Dependencies (install if missing)
Prefer `uv` for dependency management.
Python packages:
```
uv pip install openpyxl pandas
```
If `uv` is unavailable:
```
python3 -m pip install openpyxl pandas
```
Optional:
```
uv pip install matplotlib
```
If `uv` is unavailable:
```
python3 -m pip install matplotlib
```
System tools (for rendering):
```
# macOS (Homebrew)
brew install libreoffice poppler
# Ubuntu/Debian
sudo apt-get install -y libreoffice poppler-utils
```
If installation is not possible in this environment, tell the user which dependency is missing and how to install it locally.
## Environment
No required environment variables.
## Examples
- Runnable Codex examples (openpyxl): `references/examples/openpyxl/`
## Formula requirements
- Use formulas for derived values rather than hardcoding results.
- Do not use dynamic array functions like `FILTER`, `XLOOKUP`, `SORT`, or `SEQUENCE`.
- Keep formulas simple and legible; use helper cells for complex logic.
- Avoid volatile functions like `INDIRECT` and `OFFSET` unless required.
- Prefer cell references over magic numbers (for example, `=H6*(1+$B$3)` instead of `=H6*1.04`).
- Use absolute (`$B$4`) or relative (`B4`) references carefully so copied formulas behave correctly.
- If you need literal text that starts with `=`, prefix it with a single quote.
- Guard against `#REF!`, `#DIV/0!`, `#VALUE!`, `#N/A`, and `#NAME?` errors.
- Check for off-by-one mistakes, circular references, and incorrect ranges.
## Citation requirements
- Cite sources inside the spreadsheet using plain-text URLs.
- For financial models, cite model inputs in cell comments.
- For tabular data sourced externally, add a source column when each row represents a separate item.
## Formatting requirements (existing formatted spreadsheets)
- Render and inspect a provided spreadsheet before modifying it when possible.
- Preserve existing formatting and style exactly.
- Match styles for any newly filled cells that were previously blank.
- Never overwrite established formatting unless the user explicitly asks for a redesign.
## Formatting requirements (new or unstyled spreadsheets)
- Use appropriate number and date formats.
- Dates should render as dates, not plain numbers.
- Percentages should usually default to one decimal place unless the data calls for something else.
- Currencies should use the appropriate currency format.
- Headers should be visually distinct from raw inputs and derived cells.
- Use fill colors, borders, spacing, and merged cells sparingly and intentionally.
- Set row heights and column widths so content is readable without excessive whitespace.
- Do not apply borders around every filled cell.
- Group related calculations and make totals simple sums of the cells above them.
- Add whitespace to separate sections.
- Ensure text does not spill into adjacent cells.
- Avoid unsupported spreadsheet data-table features such as `=TABLE`.
## Color conventions (if no style guidance)
- Blue: user input
- Black: formulas and derived values
- Green: linked or imported values
- Gray: static constants
- Orange: review or caution
- Light red: error or flag
- Purple: control or logic
- Teal: visualization anchors and KPI highlights
## Finance-specific requirements
- Format zeros as `-`.
- Negative numbers should be red and in parentheses.
- Format multiples as `5.2x`.
- Always specify units in headers (for example, `Revenue ($mm)`).
- Cite sources for all raw inputs in cell comments.
- For new financial models with no user-specified style, use blue text for hardcoded inputs, black for formulas, green for internal workbook links, red for external links, and yellow fill for key assumptions that need attention.
## Investment banking layouts
If the spreadsheet is an IB-style model (LBO, DCF, 3-statement, valuation):
- Totals should sum the range directly above.
- Hide gridlines and use horizontal borders above totals across relevant columns.
- Section headers should be merged cells with dark fill and white text.
- Column labels for numeric data should be right-aligned; row labels should be left-aligned.
- Indent submetrics under their parent line items.
@@ -1,6 +0,0 @@
interface:
display_name: "Spreadsheet Skill"
short_description: "Create, edit, and analyze spreadsheets"
icon_small: "./assets/spreadsheet-small.svg"
icon_large: "./assets/spreadsheet.png"
default_prompt: "Use $spreadsheet to create or update a spreadsheet for this task with the right formulas, structure, and formatting."
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path fill="#0D0D0D" fill-rule="evenodd" d="M10.467 2.468c.551 0 .997 0 1.357.029.366.03.691.093.992.247l.175.097c.396.244.72.593.932 1.01l.054.114c.115.269.166.558.192.878.03.36.03.806.03 1.357v3.6c0 .551 0 .997-.03 1.357a2.76 2.76 0 0 1-.192.878l-.054.114a2.534 2.534 0 0 1-.932 1.01l-.175.097c-.3.154-.626.217-.992.247-.36.03-.806.029-1.357.029H5.534c-.552 0-.997 0-1.357-.029a2.764 2.764 0 0 1-.879-.194l-.114-.053a2.534 2.534 0 0 1-1.009-.932l-.098-.175c-.153-.301-.217-.626-.247-.992-.029-.36-.028-.806-.028-1.357V6.2c0-.551-.001-.997.028-1.357.03-.366.094-.69.247-.992a2.53 2.53 0 0 1 1.107-1.107c.302-.154.626-.217.993-.247.36-.03.805-.029 1.357-.029h4.933Zm-3.935 4.73v5.27h3.935c.569 0 .964 0 1.27-.026.3-.024.47-.07.597-.134l.1-.056c.23-.142.418-.344.541-.586l.045-.104a2 2 0 0 0 .09-.492 18 18 0 0 0 .025-1.27V7.198H6.532ZM2.866 9.8c0 .569 0 .963.025 1.27.025.3.07.47.135.596l.056.101c.141.23.343.418.585.54l.104.046c.115.041.267.07.492.09.295.023.671.024 1.205.024V7.198H2.866V9.8Zm3.666-3.666h6.603c0-.533-.002-.91-.026-1.204a1.933 1.933 0 0 0-.09-.493l-.044-.103a1.468 1.468 0 0 0-.54-.586l-.101-.056c-.127-.064-.296-.11-.596-.134a17.303 17.303 0 0 0-1.27-.026H6.531v2.602ZM5.468 3.532c-.534 0-.91.002-1.205.026-.3.024-.47.07-.596.134-.276.14-.5.365-.641.642-.065.126-.11.295-.135.596-.024.295-.025.67-.025 1.204h2.602V3.532Z" clip-rule="evenodd"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

@@ -1,51 +0,0 @@
"""Create a basic spreadsheet with two sheets and a simple formula.
Usage:
python3 create_basic_spreadsheet.py --output /tmp/basic_spreadsheet.xlsx
"""
from __future__ import annotations
import argparse
from pathlib import Path
from openpyxl import Workbook
from openpyxl.utils import get_column_letter
def main() -> None:
parser = argparse.ArgumentParser(description="Create a basic spreadsheet with example data.")
parser.add_argument(
"--output",
type=Path,
default=Path("basic_spreadsheet.xlsx"),
help="Output .xlsx path (default: basic_spreadsheet.xlsx)",
)
args = parser.parse_args()
wb = Workbook()
overview = wb.active
overview.title = "Overview"
employees = wb.create_sheet("Employees")
overview["A1"] = "Description"
overview["A2"] = "Awesome Company Report"
employees.append(["Title", "Name", "Address", "Score"])
employees.append(["Engineer", "Vicky", "90 50th Street", 98])
employees.append(["Manager", "Alex", "500 Market Street", 92])
employees.append(["Designer", "Jordan", "200 Pine Street", 88])
employees["A6"] = "Total Score"
employees["D6"] = "=SUM(D2:D4)"
for col in range(1, 5):
employees.column_dimensions[get_column_letter(col)].width = 20
args.output.parent.mkdir(parents=True, exist_ok=True)
wb.save(args.output)
print(f"Saved workbook to {args.output}")
if __name__ == "__main__":
main()
@@ -1,96 +0,0 @@
"""Generate a styled games scoreboard workbook using openpyxl.
Usage:
python3 create_spreadsheet_with_styling.py --output /tmp/GamesSimpleStyling.xlsx
"""
from __future__ import annotations
import argparse
from pathlib import Path
from openpyxl import Workbook
from openpyxl.formatting.rule import FormulaRule
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
HEADER_FILL_HEX = "B7E1CD"
HIGHLIGHT_FILL_HEX = "FFF2CC"
def apply_header_style(cell, fill_hex: str) -> None:
cell.fill = PatternFill("solid", fgColor=fill_hex)
cell.font = Font(bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center")
def apply_highlight_style(cell, fill_hex: str) -> None:
cell.fill = PatternFill("solid", fgColor=fill_hex)
cell.font = Font(bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center")
def populate_game_sheet(ws) -> None:
ws.title = "GameX"
ws.row_dimensions[2].height = 24
widths = {"B": 18, "C": 14, "D": 14, "E": 14, "F": 40}
for col, width in widths.items():
ws.column_dimensions[col].width = width
headers = ["", "Name", "Game 1 Score", "Game 2 Score", "Total Score", "Notes", ""]
for idx, value in enumerate(headers, start=1):
cell = ws.cell(row=2, column=idx, value=value)
if value:
apply_header_style(cell, HEADER_FILL_HEX)
players = [
("Vicky", 12, 30, "Dominated the minigames."),
("Yash", 20, 10, "Emily main with strong defense."),
("Bobby", 1000, 1030, "Numbers look suspiciously high."),
]
for row_idx, (name, g1, g2, note) in enumerate(players, start=3):
ws.cell(row=row_idx, column=2, value=name)
ws.cell(row=row_idx, column=3, value=g1)
ws.cell(row=row_idx, column=4, value=g2)
ws.cell(row=row_idx, column=5, value=f"=SUM(C{row_idx}:D{row_idx})")
ws.cell(row=row_idx, column=6, value=note)
ws.cell(row=7, column=2, value="Winner")
ws.cell(row=7, column=3, value="=INDEX(B3:B5, MATCH(MAX(E3:E5), E3:E5, 0))")
ws.cell(row=7, column=5, value="Congrats!")
ws.merge_cells("C7:D7")
for col in range(2, 6):
apply_highlight_style(ws.cell(row=7, column=col), HIGHLIGHT_FILL_HEX)
rule = FormulaRule(formula=["LEN(A2)>0"], fill=PatternFill("solid", fgColor=HEADER_FILL_HEX))
ws.conditional_formatting.add("A2:G2", rule)
def main() -> None:
parser = argparse.ArgumentParser(description="Create a styled games scoreboard workbook.")
parser.add_argument(
"--output",
type=Path,
default=Path("GamesSimpleStyling.xlsx"),
help="Output .xlsx path (default: GamesSimpleStyling.xlsx)",
)
args = parser.parse_args()
wb = Workbook()
ws = wb.active
populate_game_sheet(ws)
for col in range(1, 8):
col_letter = get_column_letter(col)
if col_letter not in ws.column_dimensions:
ws.column_dimensions[col_letter].width = 12
args.output.parent.mkdir(parents=True, exist_ok=True)
wb.save(args.output)
print(f"Saved workbook to {args.output}")
if __name__ == "__main__":
main()
@@ -1,59 +0,0 @@
"""Read an existing .xlsx and print a small summary.
If --input is not provided, this script creates a tiny sample workbook in /tmp
and reads that instead.
"""
from __future__ import annotations
import argparse
import tempfile
from pathlib import Path
from openpyxl import Workbook, load_workbook
def create_sample(path: Path) -> Path:
wb = Workbook()
ws = wb.active
ws.title = "Sample"
ws.append(["Item", "Qty", "Price"])
ws.append(["Apples", 3, 1.25])
ws.append(["Oranges", 2, 0.95])
ws.append(["Bananas", 5, 0.75])
ws["D1"] = "Total"
ws["D2"] = "=B2*C2"
ws["D3"] = "=B3*C3"
ws["D4"] = "=B4*C4"
wb.save(path)
return path
def main() -> None:
parser = argparse.ArgumentParser(description="Read an existing spreadsheet.")
parser.add_argument("--input", type=Path, help="Path to an .xlsx file")
args = parser.parse_args()
if args.input:
input_path = args.input
else:
tmp_dir = Path(tempfile.gettempdir())
input_path = tmp_dir / "sample_read_existing.xlsx"
create_sample(input_path)
wb = load_workbook(input_path, data_only=False)
print(f"Loaded: {input_path}")
print("Sheet names:", wb.sheetnames)
for name in wb.sheetnames:
ws = wb[name]
max_row = ws.max_row or 0
max_col = ws.max_column or 0
print(f"\n== {name} (rows: {max_row}, cols: {max_col})")
for row in ws.iter_rows(min_row=1, max_row=min(max_row, 5), max_col=min(max_col, 5)):
values = [cell.value for cell in row]
print(values)
if __name__ == "__main__":
main()
@@ -1,79 +0,0 @@
"""Create a styled spreadsheet with headers, borders, and a total row.
Usage:
python3 styling_spreadsheet.py --output /tmp/styling_spreadsheet.xlsx
"""
from __future__ import annotations
import argparse
from pathlib import Path
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
def main() -> None:
parser = argparse.ArgumentParser(description="Create a styled spreadsheet example.")
parser.add_argument(
"--output",
type=Path,
default=Path("styling_spreadsheet.xlsx"),
help="Output .xlsx path (default: styling_spreadsheet.xlsx)",
)
args = parser.parse_args()
wb = Workbook()
ws = wb.active
ws.title = "FirstGame"
ws.merge_cells("B2:E2")
ws["B2"] = "Name | Game 1 Score | Game 2 Score | Total Score"
header_fill = PatternFill("solid", fgColor="B7E1CD")
header_font = Font(bold=True)
header_alignment = Alignment(horizontal="center", vertical="center")
ws["B2"].fill = header_fill
ws["B2"].font = header_font
ws["B2"].alignment = header_alignment
ws["B3"] = "Vicky"
ws["C3"] = 50
ws["D3"] = 60
ws["E3"] = "=C3+D3"
ws["B4"] = "John"
ws["C4"] = 40
ws["D4"] = 50
ws["E4"] = "=C4+D4"
ws["B5"] = "Jane"
ws["C5"] = 30
ws["D5"] = 40
ws["E5"] = "=C5+D5"
ws["B6"] = "Jim"
ws["C6"] = 20
ws["D6"] = 30
ws["E6"] = "=C6+D6"
ws.merge_cells("B9:E9")
ws["B9"] = "=SUM(E3:E6)"
thin = Side(style="thin")
border = Border(top=thin, bottom=thin, left=thin, right=thin)
ws["B9"].border = border
ws["B9"].alignment = Alignment(horizontal="center")
ws["B9"].font = Font(bold=True)
for col in ("B", "C", "D", "E"):
ws.column_dimensions[col].width = 18
ws.row_dimensions[2].height = 24
args.output.parent.mkdir(parents=True, exist_ok=True)
wb.save(args.output)
print(f"Saved workbook to {args.output}")
if __name__ == "__main__":
main()