[codex] Add curated slides and playwright-interactive skills (#215)

This commit is contained in:
Vaibhav Srivastav
2026-03-05 08:30:19 -08:00
committed by GitHub
parent c207989386
commit 7b54889398
27 changed files with 5241 additions and 39 deletions
@@ -0,0 +1,201 @@
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.
@@ -0,0 +1,13 @@
This skill reuses the Playwright icon assets from `.codex/skills/playwright/assets/`.
The local `playwright` skill attributes those assets to the Microsoft
`playwright-cli` repository.
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0.
See LICENSE.txt in this directory.
Modifications:
- Repackaged the existing repository Playwright assets for this `js_repl`-focused skill.
- Wrote new skill instructions for persistent browser debugging.
@@ -0,0 +1,399 @@
---
name: "playwright-interactive"
description: "Persistent browser and Electron interaction through `js_repl` for fast iterative UI debugging."
---
# Playwright Interactive Skill
Use this skill when a task needs interactive browser or Electron work in a persistent `js_repl` session. Keep the Playwright handles alive across code edits, reloads, and repeated checks so iteration stays fast.
## Preconditions
- `js_repl` must be enabled for this skill.
- If `js_repl` is missing, enable it in `~/.codex/config.toml`:
```toml
[features]
js_repl = true
```
- You can also start a new session with `--enable js_repl` (equivalent to `-c features.js_repl=true`).
- After enabling `js_repl`, start a new Codex session so the tool list refreshes.
- For now, run this workflow with sandboxing disabled: start Codex with `--sandbox danger-full-access` (or the equivalent config for `sandbox_mode=danger-full-access`). This is a temporary requirement while `js_repl` + Playwright support inside the sandbox is still being completed.
- Run setup from the same project directory you need to debug.
- Treat `js_repl_reset` as a recovery tool, not routine cleanup. Resetting the kernel destroys your Playwright handles.
## One-time setup
```bash
test -f package.json || npm init -y
npm install playwright
# Web-only, for headed Chromium or mobile emulation:
# npx playwright install chromium
# Electron-only, and only if the target workspace is the app itself:
# npm install --save-dev electron
node -e "import('playwright').then(() => console.log('playwright import ok')).catch((error) => { console.error(error); process.exit(1); })"
```
If you switch to a different workspace later, repeat setup there.
## Core Workflow
1. Write a brief QA inventory before testing:
- Build the inventory from three sources: the user's requested requirements, the user-visible features or behaviors you actually implemented, and the claims you expect to make in the final response.
- Anything that appears in any of those three sources must map to at least one QA check before signoff.
- List the user-visible claims you intend to sign off on.
- List every meaningful user-facing control, mode switch, or implemented interactive behavior.
- List the state changes or view changes each control or implemented behavior can cause.
- Use this as the shared coverage list for both functional QA and visual QA.
- For each claim or control-state pair, note the intended functional check, the specific state where the visual check must happen, and the evidence you expect to capture.
- If a requirement is visually central but subjective, convert it into an observable QA check instead of leaving it implicit.
- Add at least 2 exploratory or off-happy-path scenarios that could expose fragile behavior.
2. Run the bootstrap cell once.
3. Start or confirm any required dev server in a persistent TTY session.
4. Launch the correct runtime and keep reusing the same Playwright handles.
5. After each code change, reload for renderer-only changes or relaunch for main-process/startup changes.
6. Run functional QA with normal user input.
7. Run a separate visual QA pass.
8. Verify viewport fit and capture the screenshots needed to support your claims.
9. Clean up the Playwright session only when the task is actually finished.
## Bootstrap (Run Once)
```javascript
var chromium;
var electronLauncher;
var browser;
var context;
var page;
var mobileContext;
var mobilePage;
var electronApp;
var appWindow;
try {
({ chromium, _electron: electronLauncher } = await import("playwright"));
console.log("Playwright loaded");
} catch (error) {
throw new Error(
`Could not load playwright from the current js_repl cwd. Run the setup commands from this workspace first. Original error: ${error}`
);
}
```
## Start or Reuse Web Session
Set `TARGET_URL` to the app you are debugging. For local servers, prefer `127.0.0.1` over `localhost`.
```javascript
const TARGET_URL = "http://127.0.0.1:3000";
if (!browser) {
browser = await chromium.launch({ headless: false });
}
if (!context) {
context = await browser.newContext({
viewport: { width: 1600, height: 900 },
});
}
if (!page) {
page = await context.newPage();
}
await page.goto(TARGET_URL, { waitUntil: "domcontentloaded" });
console.log("Loaded:", await page.title());
```
## Start or Reuse Electron Session
Set `ELECTRON_ENTRY` to `.` when the current workspace is the Electron app and `package.json` points `main` to the right entry file. If you need to target a specific main-process file directly, use a path such as `./main.js` instead.
```javascript
const ELECTRON_ENTRY = ".";
if (electronApp) {
await electronApp.close().catch(() => {});
}
electronApp = await electronLauncher.launch({
args: [ELECTRON_ENTRY],
cwd: process.cwd(),
});
appWindow = await electronApp.firstWindow();
console.log("Loaded Electron window:", await appWindow.title());
```
## Reuse Sessions During Iteration
Keep the same session alive whenever you can.
Web renderer reload:
```javascript
for (const p of context.pages()) {
await p.reload({ waitUntil: "domcontentloaded" });
}
console.log("Reloaded existing tabs");
```
Electron renderer-only reload:
```javascript
await appWindow.reload({ waitUntil: "domcontentloaded" });
console.log("Reloaded Electron window");
```
Electron restart after main-process, preload, or startup changes:
```javascript
await electronApp.close().catch(() => {});
electronApp = await electronLauncher.launch({
args: ["."],
cwd: process.cwd(),
});
appWindow = await electronApp.firstWindow();
console.log("Relaunched Electron window:", await appWindow.title());
```
Default posture:
- Keep each `js_repl` cell short and focused on one interaction burst.
- Reuse the existing top-level bindings (`browser`, `context`, `page`, `electronApp`, `appWindow`) instead of redeclaring them.
- If you need isolation, create a new page or a new context inside the same browser.
- For Electron, use `electronApp.evaluate(...)` only for main-process inspection or purpose-built diagnostics.
- Fix helper mistakes in place; do not reset the REPL unless the kernel is actually broken.
## Checklists
### Session Loop
- Bootstrap `js_repl` once, then keep the same Playwright handles alive across iterations.
- Launch the target runtime from the current workspace.
- Make the code change.
- Reload or relaunch using the correct path for that change.
- Update the shared QA inventory if exploration reveals an additional control, state, or visible claim.
- Re-run functional QA.
- Re-run visual QA.
- Capture final artifacts only after the current state is the one you are evaluating.
- Execute cleanup before ending the task or leaving the session.
### Reload Decision
- Renderer-only change: reload the existing page or Electron window.
- Main-process, preload, or startup change: relaunch Electron.
- New uncertainty about process ownership or startup code: relaunch instead of guessing.
### Functional QA
- Use real user controls for signoff: keyboard, mouse, click, touch, or equivalent Playwright input APIs.
- Verify at least one end-to-end critical flow.
- Confirm the visible result of that flow, not just internal state.
- For realtime or animation-heavy apps, verify behavior under actual interaction timing.
- Work through the shared QA inventory rather than ad hoc spot checks.
- Cover every obvious visible control at least once before signoff, not only the main happy path.
- For reversible controls or stateful toggles in the inventory, test the full cycle: initial state, changed state, and return to the initial state.
- After the scripted checks pass, do a short exploratory pass using normal input for 30-90 seconds instead of following only the intended path.
- If the exploratory pass reveals a new state, control, or claim, add it to the shared QA inventory and cover it before signoff.
- `page.evaluate(...)` and `electronApp.evaluate(...)` may inspect or stage state, but they do not count as signoff input.
### Visual QA
- Treat visual QA as separate from functional QA.
- Use the same shared QA inventory defined before testing and updated during QA; do not start visual coverage from a different implicit list.
- Restate the user-visible claims and verify each one explicitly; do not assume a functional pass proves a visual claim.
- A user-visible claim is not signed off until it has been inspected in the specific state where it is meant to be perceived.
- Inspect the initial viewport before scrolling.
- Confirm that the initial view visibly supports the interface's primary claims; if a core promised element is not clearly perceptible there, treat that as a bug.
- Inspect all required visible regions, not just the main interaction surface.
- Inspect the states and modes already enumerated in the shared QA inventory, including at least one meaningful post-interaction state when the task is interactive.
- If motion or transitions are part of the experience, inspect at least one in-transition state in addition to the settled endpoints.
- If labels, overlays, annotations, guides, or highlights are meant to track changing content, verify that relationship after the relevant state change.
- For dynamic or interaction-dependent visuals, inspect long enough to judge stability, layering, and readability; do not rely on a single screenshot for signoff.
- For interfaces that can become denser after loading or interaction, inspect the densest realistic state you can reach during QA, not only the empty, loading, or collapsed state.
- If the product has a defined minimum supported viewport or window size, run a separate visual QA pass there; otherwise, choose a smaller but still realistic size and inspect it explicitly.
- Distinguish presence from implementation: if an intended affordance is technically there but not clearly perceptible because of weak contrast, occlusion, clipping, or instability, treat that as a visual failure.
- If any required visible region is clipped, cut off, obscured, or pushed outside the viewport in the state you are evaluating, treat that as a bug even if page-level scroll metrics appear acceptable.
- Look for clipping, overflow, distortion, layout imbalance, inconsistent spacing, alignment problems, illegible text, weak contrast, broken layering, and awkward motion states.
- Judge aesthetic quality as well as correctness. The UI should feel intentional, coherent, and visually pleasing for the task.
- Prefer viewport screenshots for signoff. Use full-page captures only as secondary debugging artifacts.
- If the full-window screenshot is not enough to judge a region confidently, capture a focused screenshot for that region.
- If motion makes a screenshot ambiguous, wait briefly for the UI to settle, then capture the image you are actually evaluating.
- Before signoff, explicitly ask: what visible part of this interface have I not yet inspected closely?
- Before signoff, explicitly ask: what visible defect would most likely embarrass this result if the user looked closely?
### Signoff
- The functional path passed with normal user input.
- Coverage is explicit against the shared QA inventory: note which requirements, implemented features, controls, states, and claims were exercised, and call out any intentional exclusions.
- The visual QA pass covered the whole relevant interface.
- Each user-visible claim has a matching visual check and artifact from the state where that claim matters.
- The viewport-fit checks passed for the intended initial view and any required minimum supported viewport or window size.
- If the product launches in a window, the as-launched size, placement, and initial layout were checked before any manual resize or repositioning.
- The screenshots directly support the claims you are making.
- The required screenshots were reviewed for the relevant states and viewport or window sizes established during QA.
- The UI is not just functional; it is visually coherent and not aesthetically weak for the task.
- Functional correctness, viewport fit, and visual quality must each pass on their own; one does not imply the others.
- A short exploratory pass was completed for interactive products, and the response mentions what that pass covered.
- If screenshot review and numeric checks disagreed at any point, the discrepancy was investigated before signoff; visible clipping in screenshots is a failure to resolve, not something metrics can overrule.
- Include a brief negative confirmation of the main defect classes you checked for and did not find.
- Cleanup was executed, or you intentionally kept the session alive for further work.
## Screenshot Examples
Prefer JPEG at `quality: 85` for `view_image` artifacts unless lossless inspection is specifically required.
Desktop example:
```javascript
const { unlink } = await import("node:fs/promises");
const desktopPath = `${codex.tmpDir}/desktop.jpg`;
await page.screenshot({ path: desktopPath, type: "jpeg", quality: 85 });
await codex.tool("view_image", { path: desktopPath });
await unlink(desktopPath).catch(() => {});
```
Electron example:
```javascript
const { unlink } = await import("node:fs/promises");
const electronPath = `${codex.tmpDir}/electron-window.jpg`;
await appWindow.screenshot({ path: electronPath, type: "jpeg", quality: 85 });
await codex.tool("view_image", { path: electronPath });
await unlink(electronPath).catch(() => {});
```
Mobile example:
```javascript
const { unlink } = await import("node:fs/promises");
if (!mobileContext) {
mobileContext = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
});
mobilePage = await mobileContext.newPage();
}
await mobilePage.goto(TARGET_URL, { waitUntil: "domcontentloaded" });
const mobilePath = `${codex.tmpDir}/mobile.jpg`;
await mobilePage.screenshot({ path: mobilePath, type: "jpeg", quality: 85 });
await codex.tool("view_image", { path: mobilePath });
await unlink(mobilePath).catch(() => {});
```
## Viewport Fit Checks (Required)
Do not assume a screenshot is acceptable just because the main widget is visible. Before signoff, explicitly verify that the intended initial view matches the product requirement, using both screenshot review and numeric checks.
- Define the intended initial view before signoff. For scrollable pages, this is the above-the-fold experience. For app-like shells, games, editors, dashboards, or tools, this is the full interactive surface plus the controls and status needed to use it.
- Use screenshots as the primary evidence for fit. Numeric checks support the screenshots; they do not overrule visible clipping.
- Signoff fails if any required visible region is clipped, cut off, obscured, or pushed outside the viewport in the intended initial view, even if page-level scroll metrics appear acceptable.
- Scrolling is acceptable when the product is designed to scroll and the initial view still communicates the core experience and exposes the primary call to action or required starting context.
- For fixed-shell interfaces, scrolling is not an acceptable workaround if it is needed to reach part of the primary interactive surface or essential controls.
- Do not rely on document scroll metrics alone. Fixed-height shells, internal panes, and hidden-overflow containers can clip required UI while page-level scroll checks still look clean.
- Check region bounds, not just document bounds. Verify that each required visible region fits within the viewport in the startup state.
- For Electron or desktop apps, verify both the launched window size and placement and the renderer's initial visible layout before any manual resize or repositioning.
- Passing viewport-fit checks only proves that the intended initial view is visible without unintended clipping or scrolling. It does not prove that the UI is visually correct or aesthetically successful.
Web or renderer check:
```javascript
console.log(await page.evaluate(() => ({
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
clientWidth: document.documentElement.clientWidth,
clientHeight: document.documentElement.clientHeight,
scrollWidth: document.documentElement.scrollWidth,
scrollHeight: document.documentElement.scrollHeight,
canScrollX: document.documentElement.scrollWidth > document.documentElement.clientWidth,
canScrollY: document.documentElement.scrollHeight > document.documentElement.clientHeight,
})));
```
Electron check:
```javascript
console.log(await appWindow.evaluate(() => ({
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
clientWidth: document.documentElement.clientWidth,
clientHeight: document.documentElement.clientHeight,
scrollWidth: document.documentElement.scrollWidth,
scrollHeight: document.documentElement.scrollHeight,
canScrollX: document.documentElement.scrollWidth > document.documentElement.clientWidth,
canScrollY: document.documentElement.scrollHeight > document.documentElement.clientHeight,
})));
```
Augment the numeric check with `getBoundingClientRect()` checks for the required visible regions in your specific UI when clipping is a realistic failure mode; document-level metrics alone are not sufficient for fixed shells.
## Dev Server
For local web debugging, keep the app running in a persistent TTY session. Do not rely on one-shot background commands from a short-lived shell.
Use the project's normal start command, for example:
```bash
npm start
```
Before `page.goto(...)`, verify the chosen port is listening and the app responds.
For Electron debugging, launch the app from `js_repl` through `_electron.launch(...)` so the same session owns the process. If the Electron renderer depends on a separate dev server (for example Vite or Next), keep that server running in a persistent TTY session and then relaunch or reload the Electron app from `js_repl`.
## Cleanup
Only run cleanup when the task is actually finished:
- This cleanup is manual. Exiting Codex, closing the terminal, or losing the `js_repl` session does not implicitly run `electronApp.close()`, `context.close()`, or `browser.close()`.
- For Electron specifically, assume the app may keep running if you leave the session without executing the cleanup cell first.
```javascript
if (electronApp) {
await electronApp.close().catch(() => {});
}
if (mobileContext) {
await mobileContext.close().catch(() => {});
}
if (context) {
await context.close().catch(() => {});
}
if (browser) {
await browser.close().catch(() => {});
}
browser = undefined;
context = undefined;
page = undefined;
mobileContext = undefined;
mobilePage = undefined;
electronApp = undefined;
appWindow = undefined;
console.log("Playwright session closed");
```
If you plan to exit Codex immediately after debugging, run the cleanup cell first and wait for the `"Playwright session closed"` log before quitting.
## Common Failure Modes
- `Cannot find module 'playwright'`: run the one-time setup in the current workspace and verify the import before using `js_repl`.
- Playwright package is installed but the browser executable is missing: run `npx playwright install chromium`.
- `page.goto: net::ERR_CONNECTION_REFUSED`: make sure the dev server is still running in a persistent TTY session, recheck the port, and prefer `http://127.0.0.1:<port>`.
- `electron.launch` hangs, times out, or exits immediately: verify the local `electron` dependency, confirm the `args` target, and make sure any renderer dev server is already running before launch.
- `Identifier has already been declared`: reuse the existing top-level bindings, choose a new name, or wrap the code in `{ ... }`. Use `js_repl_reset` only when the kernel is genuinely stuck.
- `js_repl` timed out or reset: rerun the bootstrap cell and recreate the session with shorter, more focused cells.
- Browser launch or network operations fail immediately: confirm the session was started with `--sandbox danger-full-access` and restart that way if needed.
@@ -0,0 +1,6 @@
interface:
display_name: "Playwright Interactive"
short_description: "Persistent browser and Electron QA"
icon_small: "./assets/playwright-small.svg"
icon_large: "./assets/playwright.png"
default_prompt: "Use $playwright-interactive to debug a local web or Electron app in a persistent Playwright session and capture the QA evidence."
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path fill="#0D0D0D" d="m8.55 7.568.124.028 5.16 1.548.137.054c.606.294.645 1.165.068 1.512l-.133.066-2.236.894-.894 2.236c-.285.713-1.263.714-1.578.065l-.054-.138-1.548-5.16a.866.866 0 0 1 .954-1.105ZM10 12.983l.715-1.787.037-.08a.865.865 0 0 1 .445-.402L12.983 10 8.721 8.72l1.278 4.262ZM4.723 10.38a.532.532 0 0 1 .752.752l-1.414 1.414a.532.532 0 1 1-.752-.752l1.414-1.414ZM2.27 5.86l1.932.517.1.039a.533.533 0 0 1-.269 1.007l-.106-.018-1.932-.517-.101-.039a.532.532 0 0 1 .27-1.006l.106.017Zm9.608-2.62a.532.532 0 0 1 .668.82l-1.414 1.414a.532.532 0 1 1-.752-.752l1.414-1.414.084-.068ZM6.237 1.618a.532.532 0 0 1 .652.377l.518 1.932.017.106a.533.533 0 0 1-1.007.27l-.039-.101-.517-1.932-.017-.106a.532.532 0 0 1 .393-.546Z"/>
</svg>

After

Width:  |  Height:  |  Size: 843 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

+201
View File
@@ -0,0 +1,201 @@
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
@@ -0,0 +1,71 @@
---
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.
@@ -0,0 +1,4 @@
interface:
display_name: "Slides"
short_description: "Create and edit PPTX slide decks"
default_prompt: "Use $slides to create or update this PPTX slide deck with PptxGenJS and validate the layout."
@@ -0,0 +1,104 @@
// 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,
};
@@ -0,0 +1,333 @@
// 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,
};
@@ -0,0 +1,33 @@
// 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,
};
@@ -0,0 +1,51 @@
// 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,
};
@@ -0,0 +1,643 @@
// 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,
};
@@ -0,0 +1,358 @@
// 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;
}
@@ -0,0 +1,36 @@
// 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,
};
@@ -0,0 +1,789 @@
// 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,
};
@@ -0,0 +1,24 @@
// 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,
};
@@ -0,0 +1,61 @@
# 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.
@@ -0,0 +1,300 @@
#!/usr/bin/env python3
# Copyright (c) OpenAI. All rights reserved.
import argparse
import re
import sys
import tempfile
from math import ceil
from os import listdir
from os.path import basename, expanduser, isfile, join, splitext
from pathlib import Path
from typing import Literal
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from ensure_raster_image import SUPPORTED_EXTS, ensure_raster_image # type: ignore
from PIL import Image, ImageDraw, ImageFont, ImageOps
def _make_placeholder(w: int, h: int) -> Image.Image:
"""Create a visible placeholder tile with a light gray fill and a red X cross."""
ph = Image.new("RGBA", (w, h), (220, 220, 220, 255))
ph_draw = ImageDraw.Draw(ph)
line_color = (180, 0, 0, 255)
ph_draw.line([(0, 0), (ph.width - 1, ph.height - 1)], fill=line_color, width=3)
ph_draw.line([(ph.width - 1, 0), (0, ph.height - 1)], fill=line_color, width=3)
return ph
def _load_images_with_placeholders(
input_files: list[str], retain_converted_files: bool, fail_on_image_error: bool = False
) -> tuple[list[str], list[Image.Image | None]]:
labels = [basename(p) for p in input_files]
images: list[Image.Image | None] = []
if retain_converted_files:
for p in input_files:
try:
images.append(Image.open(ensure_raster_image(p)))
except Exception as e:
if fail_on_image_error:
raise
print(f'Warning: Failed to convert or load image "{p}": {e}')
images.append(None)
else:
with tempfile.TemporaryDirectory(prefix="montage_convert_") as tmp_conv:
for p in input_files:
try:
images.append(Image.open(ensure_raster_image(p, tmp_conv)))
except Exception as e:
if fail_on_image_error:
raise
print(f'Warning: Failed to convert or load image "{p}": {e}')
images.append(None)
return labels, images
def _natural_key(s: str) -> list:
"""Key function for natural sorting (e.g., Slide2 before Slide10)."""
return [int(part) if part.isdigit() else part for part in re.split(r"(\d+)", s)]
def create_montage(
input_files: list[str],
output_file: str,
num_col: int,
cell_w: int,
cell_h: int,
gap: int,
label_mode: Literal["number", "filename", "none"],
retain_converted_files: bool = False,
fail_on_image_error: bool = False,
) -> None:
"""Build a montage with a fixed number of columns.
Each cell has size `cell_w` x `cell_h`. Every input image is resized isotropically to fit inside
the cell. `gap` controls spacing around and between cells (outer margin equals gap).
Label behavior is controlled by `label_mode` which can be one of:
- "none": no labels are drawn
- "number": draw a 1-based index beneath each image
- "filename": draw the filename (no directory) beneath each image
"""
if num_col <= 0:
raise ValueError("num_col must be positive")
if cell_w <= 0 or cell_h <= 0:
raise ValueError("cell_w and cell_h must be positive")
labels, images = _load_images_with_placeholders(
input_files=input_files,
retain_converted_files=retain_converted_files,
fail_on_image_error=fail_on_image_error,
)
num_images = len(images)
num_valid = sum(1 for im in images if im is not None)
if num_valid == 0:
raise ValueError("No valid images to render.")
if num_valid < num_images:
cell_size = round(min(cell_w, cell_h) * 0.6)
placeholder = _make_placeholder(cell_size, cell_size)
else:
placeholder = None
cols = num_col
rows = ceil(num_images / cols)
temp_canvas = Image.new("RGB", (10, 10), (255, 255, 255))
temp_draw = ImageDraw.Draw(temp_canvas)
# Choose a readable default font size relative to cell height
font: ImageFont.FreeTypeFont | ImageFont.ImageFont
try:
# Attempt to use a common system font for clarity; fallback to default
font_size = max(12, min(36, int(cell_h * 0.12)))
font = ImageFont.truetype("arial.ttf", font_size)
except Exception:
font = ImageFont.load_default()
# Adjust default font effect size estimate
font_size = 12
draw_labels = label_mode != "none"
label_height = 0
if draw_labels:
# Height is approximately constant across strings for a given font
# Use 'Ag' to approximate ascent ('A') and descender ('g') for filename text
sample_text = "1" if label_mode == "number" else "Ag"
lbbox = temp_draw.textbbox((0, 0), sample_text, font=font)
label_height = ceil(lbbox[3] - lbbox[1]) + 6
row_h = cell_h + label_height
canvas_w = cols * cell_w + (cols + 1) * gap
canvas_h = rows * row_h + (rows + 1) * gap
# Light grey canvas background as in typical slide sorter view
canvas = Image.new("RGB", (canvas_w, canvas_h), (242, 242, 242))
draw = ImageDraw.Draw(canvas)
for idx, img in enumerate(images):
col = idx % cols
row = idx // cols
# Top-left corner of the cell including outer margin and gaps
x0 = gap + col * (cell_w + gap)
y0 = gap + row * (row_h + gap)
# Fit the image within the cell while preserving aspect ratio
if label_mode == "number":
label = str(idx + 1)
elif label_mode == "filename":
label = labels[idx]
else:
label = ""
if draw_labels:
bbox = draw.textbbox((0, 0), label, font=font)
text_w = bbox[2] - bbox[0]
else:
text_w = 0
if img:
resized = ImageOps.contain(
img.convert("RGBA"),
(cell_w, cell_h),
method=Image.Resampling.LANCZOS,
)
else:
print(f"Warning: Using placeholder for invalid image at row={row + 1}, col={col + 1}")
assert placeholder is not None
resized = placeholder
paste_x = x0 + (cell_w - resized.width) // 2
paste_y = y0 + (cell_h - resized.height) // 2
canvas.paste(
resized,
(paste_x, paste_y),
mask=resized.split()[3] if resized.mode == "RGBA" else None,
)
border_color = (160, 160, 160)
bw = 1
draw.rectangle(
[
paste_x - bw,
paste_y - bw,
paste_x + resized.width,
paste_y + resized.height,
],
outline=border_color,
width=bw,
)
if draw_labels:
tx = x0 + round((cell_w - text_w) / 2)
ty = y0 + cell_h + 3
draw.text((tx, ty), label, font=font, fill=(0, 0, 0))
canvas.save(output_file)
print(f"Montage saved to {output_file}")
def main() -> None:
parser = argparse.ArgumentParser(
description=(
"Create a montage with a fixed number of columns. "
"Each image is resized isotropically to fit inside a cell of size (cell_width x cell_height)."
)
)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--input_files", nargs="+", help="List of input image file paths")
group.add_argument("--input_dir", help="Directory containing input images")
parser.add_argument(
"--output_file",
required=True,
help=(
"Path to save the output montage image. The format is inferred from the file extension."
),
)
parser.add_argument(
"--num_col",
type=int,
default=5,
help="Number of images per row (default: 5)",
)
parser.add_argument(
"--cell_width",
type=int,
default=400,
help="Container width in pixels for each image (default: 400)",
)
parser.add_argument(
"--cell_height",
type=int,
default=225,
help="Container height in pixels for each image (default: 225)",
)
parser.add_argument(
"--gap",
type=int,
default=16,
help="Gap in pixels between images and canvas margins (default: 16)",
)
parser.add_argument(
"--label_mode",
choices=["number", "filename", "none"],
default="number",
help=(
"Label mode: 'number' to draw 1-based indices (default), 'filename' to use the "
"image's filename (no directory), or 'none' for no labels"
),
)
parser.add_argument(
"--retain_converted_files",
action="store_true",
default=False,
help=(
"If set, write converted images (e.g., SVG->PNG, WDP->PNG) next to the original files "
"instead of a temporary directory."
),
)
parser.add_argument(
"--fail_on_image_error",
action="store_true",
default=False,
help=(
"If set, fail immediately when any image conversion/loading fails (no placeholders). "
"By default, failures are tolerated and placeholders are used."
),
)
args = parser.parse_args()
output_path = expanduser(args.output_file)
if args.input_files:
input_files = [expanduser(p) for p in args.input_files]
else:
input_dir = expanduser(args.input_dir)
names = sorted(listdir(input_dir), key=_natural_key)
dir_entries = [join(input_dir, f) for f in names]
input_files = [
p for p in dir_entries if isfile(p) and splitext(p)[1].lower() in SUPPORTED_EXTS
]
if not input_files:
raise ValueError(
"No image files with supported extensions were found in the specified directory."
)
create_montage(
input_files=input_files,
output_file=output_path,
num_col=args.num_col,
cell_w=args.cell_width,
cell_h=args.cell_height,
gap=args.gap,
label_mode=args.label_mode,
retain_converted_files=args.retain_converted_files,
fail_on_image_error=args.fail_on_image_error,
)
if __name__ == "__main__":
main()
@@ -0,0 +1,873 @@
#!/usr/bin/env python3
"""Copyright (c) OpenAI. All rights reserved.
Detect missing fonts for PPTX rendering by converting to ODP and inspecting the resolved font
families per slide.
Overview
========
PowerPoint files (PPTX) declare requested font families in runs and theme defaults, but the actual
font used at render time depends on the renderer (LibreOffice in our pipeline), platform
availability, and style inheritance. To make detection stable and renderer-accurate, this module:
- Extracts requested families from PPTX per slide (reads a:r/a:rPr plus document defaults, grouped
by script: latin/ea/cs/sym). Analysis is done per run: we infer the script from run text and
select the matching a:rPr child (e.g., latin/ea/cs). Fonts declared for other scripts in the same
run are not counted as used.
- Converts the PPTX to ODP using headless LibreOffice and parses ODP content.xml and styles.xml to
discover which families LibreOffice actually resolved for each slide (including master pages and
defaults).
- Classifies each requested family on each slide into two buckets:
- font_missing: the family is not installed on the system (per fontconfig synonyms), so resolution
cannot possibly match the request.
- font_substituted: the family is installed but was resolved to another family in ODP for the
slide (theme/style inheritance or glyph coverage), i.e., installed but substituted.
Key Design
-----------------------
1) Inspect the renderer's decision, not only the author's request. Reading PPTX alone tells you what
was requested, not what LibreOffice will choose after applying styles and availability checks.
Converting to ODP and reading the resolved fo:font-family/style:font-name* values yields a
faithful view of what the renderer actually used for each slide.
2) Robust style resolution across ODP structures. Fonts can be specified under multiple layers. We
parse office:automatic-styles (both content.xml and styles.xml), office:styles and
style:default-style, draw:master-page references used by slides, nested style:text-properties
under paragraph-properties, and parent style chains (style:parent-style-name). A text-based
fallback parser supplements XML namespace lookups when vendor XML variations occur.
3) Scalable aliasing via fontconfig synonyms, not ad hoc maps. PostScript names, full names, and
family names often differ. We build a synonym map from fc-list that unifies those identifiers. We
deliberately do NOT use fc-match -s fallback chains for matching, because fallback families
(e.g., DejaVu Sans) would mask missing/substitution cases and produce false passes.
4) Clear classification: missing vs substituted.
- Missing: no synonym of the requested base family is present in the installed font set (per
fontconfig). These require installation.
- Substituted: the family is installed, but ODP does not reference it on the slide (LibreOffice
chose another family), which is useful for diagnosing style/theme issues or glyph-coverage
driven substitutions.
Not Chosen (and why)
--------------------
- PDF inspection (e.g., pdffonts): PostScript names don't reliably map back to authoring families;
PDFs often reflect subsetted fonts and fallback choices, making robust detection noisy.
- Ad hoc alias tables: unscalable for large-scale fonts and platform variants; the fontconfig
synonym corpus covers family/fullname/PostScript consistently.
- Treating fallback families as matches (fc-match -s): causes false negatives by accepting generic
fallbacks when the requested family is missing.
- Hardcoding checks in the renderer: we keep detection separate from render_slides to avoid
coupling and allow standalone checking.
CLI
---
- JSON output exposes two categories by default (and text mode mirrors them): font_missing_overall/
font_missing_by_slide and font_substituted_overall/font_substituted_by_slide.
- Flags include_missing/include_substituted control which categories are emitted (default True/True).
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import tempfile
import xml.etree.ElementTree as ET
from functools import lru_cache
from os.path import abspath, basename, exists, expanduser, join, splitext
from zipfile import ZipFile
STYLE_TOKENS = [
"regular",
"condensed",
"compressed",
"narrow",
"italic",
"oblique",
"semibold",
"demibold",
"bold",
"black",
"extra light",
"ultra light",
"extralight",
"ultralight",
"light",
"thin",
"medium",
]
def normalize_font_family_name(name: str) -> str:
s = name.casefold()
s = re.sub(r"\([^)]*\)", " ", s)
s = re.sub(r"[\s\-\_\.,/\'\"]+", " ", s)
return s.strip()
def _or_dummy(node: ET.Element | None) -> ET.Element:
"""Return the element if not None, otherwise a harmless dummy element.
Avoids deprecated truthiness checks on Element instances (`elem or dummy`).
"""
return node if node is not None else ET.Element("dummy")
@lru_cache(maxsize=1)
def _build_fc_synonym_map() -> dict[str, set[str]]:
"""Build synonym map from fontconfig; raise on failures; memoized (size=1)."""
proc = subprocess.run(
[
"fc-list",
"--format",
"%{family}\t%{fullname}\t%{postscriptname}\n",
],
capture_output=True,
text=True,
check=True,
)
syn: dict[str, set[str]] = {}
for line in (proc.stdout or "").splitlines():
parts = line.split("\t")
if len(parts) != 3:
continue
fam_field, full_field, ps_field = parts
names: set[str] = set()
for field in (fam_field, full_field, ps_field):
for item in field.split(","):
norm = normalize_font_family_name(item)
if norm:
names.add(norm)
names.add(norm.replace(" ", ""))
for name in list(names):
bucket = syn.setdefault(name, set())
bucket.update(names)
return syn
def _expand_via_fontconfig(family_base_norm: str) -> set[str]:
# Accept only true aliases/synonyms (family/fullname/PostScript) — not fallback replacements
acceptable: set[str] = {family_base_norm, family_base_norm.replace(" ", "")}
syn = _build_fc_synonym_map()
if family_base_norm in syn:
acceptable.update(syn[family_base_norm])
no_space = family_base_norm.replace(" ", "")
if no_space in syn:
acceptable.update(syn[no_space])
return acceptable
def parse_font_family_base_and_styles(name_norm: str) -> tuple[str, set[str]]:
tokens = name_norm.split()
required: set[str] = set()
weight_code_map = {
"25": "ultra light",
"35": "thin",
"45": "light",
"55": "regular",
"65": "medium",
"75": "bold",
"85": "black",
"95": "black",
}
if tokens and tokens[0].isdigit() and tokens[0] in weight_code_map:
required.add(weight_code_map[tokens[0]])
tokens = tokens[1:]
if len(tokens) == 1:
t = tokens[0]
fused_map = [
("extralight", "extra light"),
("ultralight", "ultra light"),
("semibold", "semibold"),
("demibold", "semibold"),
("condensed", "condensed"),
("compressed", "condensed"),
("narrow", "condensed"),
("italic", "italic"),
("oblique", "italic"),
("bold", "bold"),
("black", "black"),
("light", "light"),
("thin", "thin"),
("medium", "medium"),
("regular", "regular"),
]
changed = True
while changed:
changed = False
for suf, tok in fused_map:
if t.endswith(suf) and len(t) > len(suf):
t = t[: -len(suf)]
required.add(tok)
changed = True
break
return (t.strip(), required)
while tokens:
tail = " ".join(tokens[-2:]) if len(tokens) >= 2 else tokens[-1]
matched = None
for style in STYLE_TOKENS:
if tail == style:
matched = style
break
if matched is None and tokens[-1] in STYLE_TOKENS:
matched = tokens[-1]
if matched is None:
break
if matched in ("compressed", "narrow"):
required.add("condensed")
elif matched == "roman":
required.add("regular")
elif matched == "demibold":
required.add("semibold")
else:
required.add(matched)
if " " in matched:
tokens = tokens[:-2]
else:
tokens = tokens[:-1]
return (" ".join(tokens).strip(), required)
def _split_odf_family_list(value: str) -> list[str]:
out: list[str] = []
for part in value.split(","):
p = part.strip().strip("\"' ")
if p:
out.append(normalize_font_family_name(p))
return out
def extract_used_fonts_from_pptx(pptx_path: str) -> dict[int, set[str]]:
by_slide: dict[int, set[str]] = {}
with ZipFile(pptx_path, "r") as zf:
for name in zf.namelist():
if not (name.startswith("ppt/slides/slide") and name.endswith(".xml")):
continue
base = os.path.basename(name)
m = re.search(r"(?i)slide(\d+)\.xml$", base)
slide_num = int(m.group(1)) if m else None
with zf.open(name) as f:
tree = ET.parse(f)
root = tree.getroot()
ns = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"}
defaults = _collect_default_font_faces(root)
for r in root.findall(".//a:r", ns):
parts: list[str] = []
for t in r.findall("a:t", ns):
if t.text:
parts.append(t.text)
text = "".join(parts)
if not text:
continue
script = _detect_script_tag(text)
rpr = r.find("a:rPr", ns)
face_norm: str | None = None
if rpr is not None:
child = rpr.find(f"a:{script}", ns)
if child is not None:
face = child.get("typeface")
if face and not face.startswith("+"):
face_norm = normalize_font_family_name(face)
bucket = by_slide.setdefault(slide_num or -1, set())
if face_norm is None:
for f in defaults.get(script, set()):
bucket.add(f)
else:
bucket.add(face_norm)
return {k: v for k, v in by_slide.items() if k is not None and k != -1}
def _detect_script_tag(text: str) -> str:
for ch in text:
cp = ord(ch)
if (
0x4E00 <= cp <= 0x9FFF
or 0x3400 <= cp <= 0x4DBF
or 0xF900 <= cp <= 0xFAFF
or 0x3040 <= cp <= 0x309F
or 0x30A0 <= cp <= 0x30FF
or 0x31F0 <= cp <= 0x31FF
or 0xAC00 <= cp <= 0xD7AF
or 0x3100 <= cp <= 0x312F
or 0x3000 <= cp <= 0x303F
):
return "ea"
for ch in text:
cp = ord(ch)
if (
0x0590 <= cp <= 0x05FF
or 0x0600 <= cp <= 0x06FF
or 0x0700 <= cp <= 0x077F
or 0x0780 <= cp <= 0x07BF
or 0x0900 <= cp <= 0x0D7F
or 0x0E00 <= cp <= 0x0E7F
or 0x0E80 <= cp <= 0x0EFF
or 0xFB50 <= cp <= 0xFDFF
or 0xFE70 <= cp <= 0xFEFF
):
return "cs"
for ch in text:
cp = ord(ch)
if (
(0x0041 <= cp <= 0x005A)
or (0x0061 <= cp <= 0x007A)
or (0x0030 <= cp <= 0x0039)
or (0x00C0 <= cp <= 0x024F)
or (0x1E00 <= cp <= 0x1EFF)
):
return "latin"
return "latin"
def _collect_default_font_faces(root: ET.Element) -> dict[str, set[str]]:
ns = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"}
defaults: dict[str, set[str]] = {"latin": set(), "ea": set(), "cs": set(), "sym": set()}
for defrpr in root.findall(".//a:defRPr", ns):
for tag in ("latin", "ea", "cs", "sym"):
child = defrpr.find(f"a:{tag}", ns)
if child is not None:
face = child.get("typeface")
if face and not face.startswith("+"):
defaults[tag].add(normalize_font_family_name(face))
return defaults
def _run_soffice_convert(cmd: list[str]) -> None:
subprocess.run(
cmd,
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=os.environ.copy(),
)
def _export_to_odp(pptx_path: str, user_profile: str, out_dir: str, stem: str) -> str:
bin_path = shutil.which("soffice") or shutil.which("libreoffice") or "/usr/bin/libreoffice"
cmd_odp = [
bin_path,
"-env:UserInstallation=file://" + user_profile,
"--invisible",
"--headless",
"--norestore",
"--convert-to",
"odp",
"--outdir",
out_dir,
pptx_path,
]
_run_soffice_convert(cmd_odp)
odp_path = join(out_dir, f"{stem}.odp")
return odp_path if exists(odp_path) else ""
def _collect_face_map(root: ET.Element, ns: dict[str, str]) -> dict[str, str]:
face_map: dict[str, str] = {}
decls = root.find("office:font-face-decls", ns)
if decls is None:
return face_map
for ff in decls.findall("style:font-face", ns):
name_attr = ff.get("{urn:oasis:names:tc:opendocument:xmlns:style:1.0}name") or ff.get(
"style:name"
)
fam_attr = ff.get("{urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0}font-family")
if not name_attr or not fam_attr:
continue
face_map[normalize_font_family_name(name_attr)] = normalize_font_family_name(fam_attr)
return face_map
def _families_from_text_properties(
tp: ET.Element, ns: dict[str, str], face_map: dict[str, str]
) -> set[str]:
fams: set[str] = set()
# Inspect current node for direct font-family
fam_attr = tp.get("{urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0}font-family")
if fam_attr:
fams.update(_split_odf_family_list(fam_attr))
# Inspect font-name aliases on current node
for key in (
"{urn:oasis:names:tc:opendocument:xmlns:style:1.0}font-name",
"style:font-name",
"style:font-name-asian",
"style:font-name-complex",
):
val = tp.get(key)
if val:
norm_val = normalize_font_family_name(val)
mapped = face_map.get(norm_val)
if mapped:
fams.add(normalize_font_family_name(mapped))
else:
fams.add(norm_val)
# Some styles nest text-properties under paragraph-properties or default-style blocks
if not fams:
nested = None
# paragraph-properties/text-properties
pp = tp.find("style:paragraph-properties", ns)
if pp is not None:
nested = pp.find("style:text-properties", ns)
if nested is None:
# When tp is actually the style:style node, try finding child text-properties directly
nested = tp.find("style:text-properties", ns)
if nested is not None and nested is not tp:
fams.update(_families_from_text_properties(nested, ns, face_map))
return fams
def _extract_styles_from_container(
container: ET.Element | None, ns: dict[str, str], face_map: dict[str, str]
) -> tuple[dict[str, set[str]], set[str]]:
styles: dict[str, set[str]] = {}
defaults: set[str] = set()
if container is None:
return styles, defaults
for st in container.findall("style:style", ns):
name = st.get("{urn:oasis:names:tc:opendocument:xmlns:style:1.0}name") or st.get(
"style:name"
)
if not name:
continue
fams = _families_from_text_properties(
_or_dummy(st.find("style:text-properties", ns)), ns, face_map
)
if fams:
styles[name] = fams
for ds in container.findall("style:default-style", ns):
defaults.update(
_families_from_text_properties(
_or_dummy(ds.find("style:text-properties", ns)), ns, face_map
)
)
return styles, defaults
def _build_style_map(
content: ET.Element,
styles_root: ET.Element | None,
ns: dict[str, str],
face_map: dict[str, str],
) -> tuple[dict[str, set[str]], set[str]]:
style_map: dict[str, set[str]] = {}
default_fams: set[str] = set()
auto_styles = content.find("office:automatic-styles", ns)
styles_part, defaults_part = _extract_styles_from_container(auto_styles, ns, face_map)
style_map.update(styles_part)
default_fams.update(defaults_part)
if styles_root is not None:
# Also parse automatic-styles within styles.xml (document-styles)
styles_auto = styles_root.find("office:automatic-styles", ns)
styles_part, defaults_part = _extract_styles_from_container(styles_auto, ns, face_map)
for k, v in styles_part.items():
if k not in style_map:
style_map[k] = v
default_fams.update(defaults_part)
common_styles = styles_root.find("office:styles", ns)
styles_part, defaults_part = _extract_styles_from_container(common_styles, ns, face_map)
for k, v in styles_part.items():
if k not in style_map:
style_map[k] = v
default_fams.update(defaults_part)
# top-level default-style under styles_root
for ds in styles_root.findall("style:default-style", ns):
default_fams.update(
_families_from_text_properties(
_or_dummy(ds.find("style:text-properties", ns)), ns, face_map
)
)
# Fallback: include any remaining style:style definitions anywhere in styles.xml
for st in styles_root.findall(".//style:style", ns):
name = st.get("{urn:oasis:names:tc:opendocument:xmlns:style:1.0}name") or st.get(
"style:name"
)
if not name or name in style_map:
continue
fams = _families_from_text_properties(
_or_dummy(st.find("style:text-properties", ns)), ns, face_map
)
if fams:
style_map[name] = fams
# also check top-level default-style in content root
for ds in content.findall("style:default-style", ns):
default_fams.update(
_families_from_text_properties(
_or_dummy(ds.find("style:text-properties", ns)), ns, face_map
)
)
# Fallback: include any remaining style:style definitions anywhere in content.xml
for st in content.findall(".//style:style", ns):
name = st.get("{urn:oasis:names:tc:opendocument:xmlns:style:1.0}name") or st.get(
"style:name"
)
if not name or name in style_map:
continue
fams = _families_from_text_properties(
_or_dummy(st.find("style:text-properties", ns)), ns, face_map
)
if fams:
style_map[name] = fams
return style_map, default_fams
def _lookup_style_families(
style_name: str, ns: dict[str, str], face_map: dict[str, str], roots: list[ET.Element | None]
) -> set[str]:
fams: set[str] = set()
if not style_name:
return fams
visited: set[str] = set()
def _resolve(name: str) -> None:
if not name or name in visited:
return
visited.add(name)
for root in roots:
if root is None:
continue
node = root.find(f".//style:style[@style:name='{name}']", ns)
if node is None:
node = root.find(f".//style:style[@{{{ns['style']}}}name='{name}']", ns)
if node is None:
continue
fams.update(
_families_from_text_properties(
_or_dummy(node.find("style:text-properties", ns)), ns, face_map
)
)
# Follow parent style chain if present
parent = node.get(
"{urn:oasis:names:tc:opendocument:xmlns:style:1.0}parent-style-name"
) or node.get("style:parent-style-name")
if parent:
_resolve(parent)
_resolve(style_name)
return fams
def _collect_slide_families(
page: ET.Element,
ns: dict[str, str],
style_map: dict[str, set[str]],
face_map: dict[str, str],
roots: list[ET.Element | None],
text_style_map: dict[str, set[str]] | None = None,
) -> set[str]:
slide_fams: set[str] = set()
for el in page.iter():
fam_attr = el.get(
"{urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0}font-family"
)
if fam_attr:
slide_fams.update(_split_odf_family_list(fam_attr))
for attr in (
"{urn:oasis:names:tc:opendocument:xmlns:text:1.0}style-name",
"text:style-name",
"{urn:oasis:names:tc:opendocument:xmlns:drawing:1.0}text-style-name",
"draw:text-style-name",
"draw:style-name",
"presentation:style-name",
):
style_name = el.get(attr)
if not style_name:
continue
resolved_fams: set[str] = set()
if style_name in style_map:
resolved_fams.update(style_map[style_name])
if not resolved_fams:
# Fallback: resolve on the fly from XML if not present in prebuilt style_map
resolved_fams.update(_lookup_style_families(style_name, ns, face_map, roots))
if not resolved_fams and text_style_map and style_name in text_style_map:
resolved_fams.update(text_style_map[style_name])
if resolved_fams:
slide_fams.update(resolved_fams)
return slide_fams
def _build_style_map_text(xml_text: str) -> dict[str, set[str]]:
# Best-effort textual extraction for cases missed by XML namespace lookups
# Finds style:style name="X" blocks and extracts fo:font-family and style:font-name attributes
style_map: dict[str, set[str]] = {}
# Non-greedy match of a style:style block
for m in re.finditer(
r"<style:style[^>]*?\bstyle:name=\"([^\"]+)\"[\s\S]*?(?:</style:style>)",
xml_text,
flags=re.IGNORECASE,
):
name = m.group(1).strip()
block = m.group(0)
fams: set[str] = set()
# fo:font-family may be a comma list
mff = re.search(r"fo:font-family=\"([^\"]+)\"", block, flags=re.IGNORECASE)
if mff:
for f in _split_odf_family_list(mff.group(1)):
fams.add(f)
# style:font-name may be a face alias; treat as family directly if present
mfn = re.search(r"style:font-name=\"([^\"]+)\"", block, flags=re.IGNORECASE)
if mfn:
fams.add(normalize_font_family_name(mfn.group(1)))
if fams:
style_map[name] = fams
return style_map
def _extract_slide_families_from_odp(odp_path: str) -> dict[int, set[str]]:
ns = {
"office": "urn:oasis:names:tc:opendocument:xmlns:office:1.0",
"style": "urn:oasis:names:tc:opendocument:xmlns:style:1.0",
"fo": "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0",
"draw": "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0",
"text": "urn:oasis:names:tc:opendocument:xmlns:text:1.0",
}
by_slide: dict[int, set[str]] = {}
with ZipFile(odp_path, "r") as zf:
content_bytes = zf.read("content.xml")
styles_bytes = zf.read("styles.xml") if "styles.xml" in zf.namelist() else None
content = ET.fromstring(content_bytes)
styles_root = ET.fromstring(styles_bytes) if styles_bytes is not None else None
styles_text = (
styles_bytes.decode("utf-8", errors="ignore") if styles_bytes is not None else ""
)
face_map: dict[str, str] = {}
face_map.update(_collect_face_map(content, ns))
if styles_root is not None:
face_map.update(_collect_face_map(styles_root, ns))
style_map, default_fams = _build_style_map(content, styles_root, ns, face_map)
# Augment style_map with textual parsing fallback (helps with tricky namespace emissions)
text_style_map: dict[str, set[str]] = {}
if styles_text:
text_style_map = _build_style_map_text(styles_text)
for k, v in text_style_map.items():
if k not in style_map:
style_map[k] = v
master_map: dict[str, set[str]] = _build_master_page_map(styles_root, ns, style_map)
pres = content.find("office:body", ns)
if pres is not None:
pres = pres.find("office:presentation", ns)
if pres is None:
return {}
pages = pres.findall("draw:page", ns)
global_fams: set[str] = set()
for idx, page in enumerate(pages, start=1):
slide_fams = _collect_slide_families(
page, ns, style_map, face_map, [content, styles_root], text_style_map
)
mp_name = page.get(
"{urn:oasis:names:tc:opendocument:xmlns:drawing:1.0}master-page-name"
) or page.get("draw:master-page-name")
if mp_name and mp_name in master_map:
slide_fams.update(master_map[mp_name])
# If theme placeholders like +mn lt are present, augment with defaults
if any(f.startswith("+") for f in slide_fams) and default_fams:
slide_fams.update(default_fams)
if not slide_fams and default_fams:
slide_fams.update(default_fams)
expanded: set[str] = set()
for f in slide_fams:
base, _ = parse_font_family_base_and_styles(f)
expanded.add(f)
expanded.add(base)
expanded.add(base.replace(" ", ""))
by_slide[idx] = expanded
global_fams.update(expanded)
# As a last resort, use global families
if global_fams:
for idx in list(by_slide.keys()):
if not by_slide[idx]:
by_slide[idx] = set(global_fams)
elif all(f.startswith("+") for f in by_slide[idx]):
by_slide[idx].update(global_fams)
return by_slide
def _build_master_page_map(
styles_root: ET.Element | None, ns: dict[str, str], style_map: dict[str, set[str]]
) -> dict[str, set[str]]:
master_map: dict[str, set[str]] = {}
if styles_root is None:
return master_map
master_styles = styles_root.find("office:master-styles", ns)
if master_styles is None:
return master_map
for mp in master_styles.findall("draw:master-page", ns):
mname = mp.get("{urn:oasis:names:tc:opendocument:xmlns:drawing:1.0}name") or mp.get(
"draw:name"
)
if not mname:
continue
fams: set[str] = set()
for el in mp.iter():
fam_attr = el.get(
"{urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0}font-family"
)
if fam_attr:
fams.update(_split_odf_family_list(fam_attr))
for attr in (
"{urn:oasis:names:tc:opendocument:xmlns:text:1.0}style-name",
"text:style-name",
"{urn:oasis:names:tc:opendocument:xmlns:drawing:1.0}text-style-name",
"draw:text-style-name",
"draw:style-name",
"presentation:style-name",
):
sname = el.get(attr)
if sname and sname in style_map:
fams.update(style_map[sname])
if fams:
expanded: set[str] = set()
for f in fams:
base, _ = parse_font_family_base_and_styles(f)
expanded.add(f)
expanded.add(base)
expanded.add(base.replace(" ", ""))
master_map[mname] = expanded
return master_map
def detect_missing_fonts_odp(pptx_path: str) -> tuple[set[str], dict[int, list[str]]]:
pptx_path = abspath(pptx_path)
used = extract_used_fonts_from_pptx(pptx_path)
with tempfile.TemporaryDirectory(prefix="soffice_profile_") as prof:
with tempfile.TemporaryDirectory(prefix="soffice_convert_") as out:
stem = splitext(basename(pptx_path))[0]
odp_path = _export_to_odp(pptx_path, prof, out, stem)
if not odp_path:
return set(), {}
slide_fams = _extract_slide_families_from_odp(odp_path)
missing_overall: set[str] = set()
missing_by_slide: dict[int, list[str]] = {}
syn_map = _build_fc_synonym_map()
for slide_num, req_fams in used.items():
odp_fams = slide_fams.get(slide_num, set())
slide_missing: list[str] = []
for req in req_fams:
fam_base, _ = parse_font_family_base_and_styles(req)
# Accept fontconfig-resolved aliases and no-space variants for the requested base family
acceptable: set[str] = _expand_via_fontconfig(fam_base)
# Determine if any acceptable alias is actually installed on system
installed = any(alias in syn_map for alias in acceptable)
# Missing if not installed at all, or if installed but not resolved in ODP families
if (not installed) or ((req not in odp_fams) and not (acceptable & odp_fams)):
slide_missing.append(req)
missing_overall.add(req)
if slide_missing:
missing_by_slide[slide_num] = sorted(slide_missing)
return missing_overall, missing_by_slide
def main() -> None:
parser = argparse.ArgumentParser(
description=(
"Detect missing/substituted fonts for a PPTX by converting to ODP and inspecting resolved families."
)
)
parser.add_argument("pptx_path", help="Path to .pptx file")
parser.add_argument(
"--json", dest="output_json", action="store_true", default=False, help="Emit JSON output"
)
parser.add_argument(
"--include-missing",
dest="include_missing",
action="store_true",
default=True,
help="Include missing category",
)
parser.add_argument(
"--include-substituted",
dest="include_substituted",
action="store_true",
default=True,
help="Include substituted category",
)
args = parser.parse_args()
pptx_path = abspath(expanduser(args.pptx_path))
used = extract_used_fonts_from_pptx(pptx_path)
# Only build ODP families if we need to report substitutions
slide_fams: dict[int, set[str]] = {}
odp_available = False
if args.include_substituted:
with tempfile.TemporaryDirectory(prefix="soffice_profile_") as prof:
with tempfile.TemporaryDirectory(prefix="soffice_convert_") as out:
stem = splitext(basename(pptx_path))[0]
odp_path = _export_to_odp(pptx_path, prof, out, stem)
if odp_path:
slide_fams = _extract_slide_families_from_odp(odp_path)
odp_available = True
syn_map = _build_fc_synonym_map()
font_missing_by_slide: dict[int, list[str]] = {}
font_substituted_by_slide: dict[int, list[str]] = {}
for slide_num, req_fams in used.items():
if args.include_substituted and odp_available:
odp_fams = slide_fams.get(slide_num, set())
else:
odp_fams = set()
miss_missing: list[str] = []
miss_sub: list[str] = []
for req in req_fams:
fam_base, _ = parse_font_family_base_and_styles(req)
acceptable: set[str] = _expand_via_fontconfig(fam_base)
installed = any(alias in syn_map for alias in acceptable)
if args.include_missing and not installed:
miss_missing.append(req)
if (
args.include_substituted
and odp_available
and installed
and (req not in odp_fams)
and not (acceptable & odp_fams)
):
miss_sub.append(req)
if miss_missing:
font_missing_by_slide[slide_num] = sorted(miss_missing)
if miss_sub:
font_substituted_by_slide[slide_num] = sorted(miss_sub)
font_missing_overall: set[str] = (
set().union(*font_missing_by_slide.values()) if font_missing_by_slide else set()
)
font_substituted_overall: set[str] = (
set().union(*font_substituted_by_slide.values()) if font_substituted_by_slide else set()
)
if args.output_json:
payload: dict[str, object] = {}
if args.include_missing:
payload["font_missing_overall"] = sorted(font_missing_overall)
payload["font_missing_by_slide"] = {str(k): v for k, v in font_missing_by_slide.items()}
if args.include_substituted:
payload["font_substituted_overall"] = sorted(font_substituted_overall)
payload["font_substituted_by_slide"] = {
str(k): v for k, v in font_substituted_by_slide.items()
}
print(json.dumps(payload))
else:
any_missing = args.include_missing and bool(font_missing_overall)
any_sub = args.include_substituted and bool(font_substituted_overall)
if any_missing or any_sub:
if any_missing:
print("Fonts missing (not installed):")
print(", ".join(sorted(font_missing_overall)))
for slide_num in sorted(font_missing_by_slide.keys()):
print(f"Slide {slide_num} missing: ", end="")
print(", ".join(font_missing_by_slide[slide_num]))
if any_sub:
print("Fonts substituted (installed but substituted during rendering):")
print(", ".join(sorted(font_substituted_overall)))
for slide_num in sorted(font_substituted_by_slide.keys()):
print(f"Slide {slide_num} substituted: ", end="")
print(", ".join(font_substituted_by_slide[slide_num]))
else:
print("No font issues detected.")
if __name__ == "__main__":
main()
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
"""Copyright (c) OpenAI. All rights reserved.
Ensures input images are rasterized, converting to PNG when needed. Primarily used to
preview image assets extracted from PowerPoint files.
Dependencies used by this tool:
- Inkscape: SVG/EMF/WMF rasterization
- ImageMagick: format bridging (TIFF→PNG, generic convert)
- Ghostscript: PDF/EPS/PS rasterization (first page)
- libheif-examples: heif-convert for HEIC/HEIF → PNG
- jxr-tools (or libjxr-tools on older distros): JxrDecApp for JPEG XR (JXR/WDP)
Install (Ubuntu/Debian):
sudo apt-get update
sudo apt-get install -y inkscape imagemagick ghostscript libheif-examples jxr-tools
# If jxr-tools not found on your distro, try:
# sudo apt-get install -y libjxr-tools
Verify:
inkscape --version
convert -version | grep -i "ImageMagick"
gs -v
heif-convert -h
JxrDecApp -h
"""
import argparse
import gzip
import shutil
from os import listdir
from os.path import basename, dirname, expanduser, isfile, join, splitext
from subprocess import run
RASTER_EXTS = {
".png",
".jpg",
".jpeg",
".bmp",
".gif",
".tif",
".tiff",
".webp",
}
CONVERTIBLE_EXTS = {
# Windows metafiles (and compressed variants)
".emf",
".wmf",
".emz",
".wmz",
# SVG
".svg",
".svgz",
# JPEG XR / HD Photo
".wdp",
".jxr",
# HEIF family
".heic",
".heif",
# Page-description formats (rasterize first page)
".pdf",
".eps",
".ps",
}
SUPPORTED_EXTS = RASTER_EXTS | CONVERTIBLE_EXTS
def _imagemagick_convert(src_path: str, dst_path: str) -> None:
binary = shutil.which("magick") or "convert"
run([binary, src_path, dst_path], check=True)
def ensure_raster_image(path: str, out_dir: str | None = None) -> str:
"""Return a raster image path for the given input, converting when needed.
- EMF/WMF/EMZ/WMZ are rasterized via Inkscape (EMZ/WMZ are decompressed first)
- SVG/SVGZ are rasterized via Inkscape
- WDP/JXR are converted via ImageMagick (if codec available)
- Known raster formats are returned as-is
Raises ValueError if the extension is not supported.
"""
base, ext = splitext(path)
ext_lower = ext.lower()
out_dir = out_dir or dirname(path)
out_path = join(out_dir, basename(base) + ".png")
# Convertible formats
if ext_lower in (".emf", ".wmf"):
run(["inkscape", path, "-o", out_path], check=True)
if isfile(out_path):
return out_path
raise RuntimeError("inkscape reported success but output file not found: " + out_path)
if ext_lower in (".emz", ".wmz"):
# Decompress into EMF/WMF then rasterize with Inkscape
decompressed = join(out_dir, basename(base) + (".emf" if ext_lower == ".emz" else ".wmf"))
with gzip.open(path, "rb") as zin, open(decompressed, "wb") as zout:
zout.write(zin.read())
run(
["inkscape", decompressed, "-o", out_path],
check=True,
)
if isfile(out_path):
return out_path
raise RuntimeError("inkscape reported success but output file not found: " + out_path)
if ext_lower in (".svg", ".svgz"):
run(["inkscape", path, "-o", out_path], check=True)
if isfile(out_path):
return out_path
raise RuntimeError("inkscape reported success but output file not found: " + out_path)
if ext_lower in (".wdp", ".jxr"):
tmp_tiff = join(out_dir, basename(base) + ".tiff")
run(["JxrDecApp", "-i", path, "-o", tmp_tiff], check=True)
_imagemagick_convert(tmp_tiff, out_path)
if isfile(out_path):
return out_path
raise RuntimeError("JPEG XR decode succeeded but PNG not found: " + out_path)
if ext_lower in (".heic", ".heif"):
# Use libheif's CLI for robust conversion
heif_convert = shutil.which("heif-convert") or "heif-convert"
run([heif_convert, path, out_path], check=True)
if isfile(out_path):
return out_path
raise RuntimeError("heif-convert reported success but output file not found: " + out_path)
if ext_lower in (".pdf", ".eps", ".ps"):
# Rasterize first page via Ghostscript
gs = shutil.which("gs") or "gs"
run(
[
gs,
"-dSAFER",
"-dBATCH",
"-dNOPAUSE",
"-sDEVICE=pngalpha",
"-dFirstPage=1",
"-dLastPage=1",
"-r200",
"-o",
out_path,
path,
],
check=True,
)
if isfile(out_path):
return out_path
raise RuntimeError("Ghostscript reported success but output file not found: " + out_path)
if ext_lower in RASTER_EXTS:
return path
raise ValueError(f"Unsupported image format for montage: {path}")
def main() -> None:
parser = argparse.ArgumentParser(
description=("Ensure input images are rasterized; convert to PNG if needed.")
)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--input_files", nargs="+", help="List of input image file paths")
group.add_argument("--input_dir", help="Directory containing input images")
parser.add_argument(
"--output_dir",
default=None,
help=(
"Directory to write converted PNGs. If omitted, converted files are written next to inputs."
),
)
args = parser.parse_args()
if args.input_files:
paths = [expanduser(p) for p in args.input_files]
else:
input_dir = expanduser(args.input_dir)
names = listdir(input_dir)
paths = [
join(input_dir, f)
for f in names
if isfile(join(input_dir, f)) and splitext(f)[1].lower() in SUPPORTED_EXTS
]
if not paths:
raise SystemExit("No files with supported extensions in input_dir")
out_dir = expanduser(args.output_dir) if args.output_dir else None
converted_paths = []
for p in paths:
if ensure_raster_image(p, out_dir) != p:
converted_paths.append(p)
if converted_paths:
print("Converted the following files to PNG:\n" + "\n".join(converted_paths))
if __name__ == "__main__":
main()
@@ -0,0 +1,273 @@
#!/usr/bin/env python3
# Copyright (c) OpenAI. All rights reserved.
import argparse
import os
import re
import subprocess
import tempfile
import xml.etree.ElementTree as ET
from os import makedirs, replace
from os.path import abspath, basename, exists, expanduser, join, splitext
from typing import Sequence, cast
from zipfile import ZipFile
from pdf2image import convert_from_path, pdfinfo_from_path
EMU_PER_INCH: int = 914_400
def calc_dpi_via_ooxml(input_path: str, max_w_px: int, max_h_px: int) -> int:
"""Calculate DPI from OOXML `ppt/presentation.xml` slide size (cx/cy in EMUs)."""
with ZipFile(input_path, "r") as zf:
xml = zf.read("ppt/presentation.xml")
root = ET.fromstring(xml)
ns = {"p": "http://schemas.openxmlformats.org/presentationml/2006/main"}
sld_sz = root.find("p:sldSz", ns)
if sld_sz is None:
raise RuntimeError("Slide size not found in presentation.xml")
cx = int(sld_sz.get("cx") or 0)
cy = int(sld_sz.get("cy") or 0)
if cx <= 0 or cy <= 0:
raise RuntimeError("Invalid slide size values in presentation.xml")
width_in = cx / EMU_PER_INCH
height_in = cy / EMU_PER_INCH
return round(min(max_w_px / width_in, max_h_px / height_in))
def calc_dpi_via_pdf(input_path: str, max_w_px: int, max_h_px: int) -> int:
"""Compute DPI from PDF page size.
For non-PDF inputs, first convert to PDF via LibreOffice to read page size.
For PDFs, use the PDF directly (avoids unnecessary conversion and failures).
"""
is_pdf = input_path.lower().endswith(".pdf")
with tempfile.TemporaryDirectory(prefix="soffice_profile_") as user_profile:
with tempfile.TemporaryDirectory(prefix="soffice_convert_") as convert_tmp_dir:
stem = splitext(basename(input_path))[0]
pdf_path = (
input_path
if is_pdf
else convert_to_pdf(input_path, user_profile, convert_tmp_dir, stem)
)
if not (pdf_path and exists(pdf_path)):
raise RuntimeError("Failed to produce/read PDF for DPI computation.")
info = pdfinfo_from_path(pdf_path)
size_val = info.get("Page size")
if not size_val:
for k, v in info.items():
if isinstance(v, str) and "size" in k.lower() and "pts" in v:
size_val = v
break
if not isinstance(size_val, str):
raise RuntimeError("Failed to read PDF page size for DPI computation.")
def _parse_page_size_to_pts(s: str) -> tuple[float, float]:
# Common formats from poppler/pdfinfo:
# - "612 x 792 pts (letter)"
# - "595.276 x 841.89 pts (A4)"
# - sometimes inches: "8.5 x 11 in"
m_pts = re.search(
r"([0-9]+(?:\.[0-9]+)?)\s*x\s*([0-9]+(?:\.[0-9]+)?)\s*pts\b",
s,
)
if m_pts:
return float(m_pts.group(1)), float(m_pts.group(2))
m_in = re.search(
r"([0-9]+(?:\.[0-9]+)?)\s*x\s*([0-9]+(?:\.[0-9]+)?)\s*in\b",
s,
)
if m_in:
w_in = float(m_in.group(1))
h_in = float(m_in.group(2))
return w_in * 72.0, h_in * 72.0
# Sometimes poppler returns without an explicit unit; treat as points.
m = re.search(r"([0-9]+(?:\.[0-9]+)?)\s*x\s*([0-9]+(?:\.[0-9]+)?)\b", s)
if m:
return float(m.group(1)), float(m.group(2))
raise RuntimeError(f"Unrecognized PDF page size format: {s!r}")
width_pts, height_pts = _parse_page_size_to_pts(size_val)
width_in = width_pts / 72.0
height_in = height_pts / 72.0
if width_in <= 0 or height_in <= 0:
raise RuntimeError("Invalid PDF page size values.")
return round(min(max_w_px / width_in, max_h_px / height_in))
def run_cmd_no_check(cmd: list[str]) -> None:
subprocess.run(
cmd,
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=os.environ.copy(),
)
def convert_to_pdf(
pptx_path: str,
user_profile: str,
convert_tmp_dir: str,
stem: str,
) -> str:
# Try direct PPTX -> PDF
cmd_pdf = [
"soffice",
"-env:UserInstallation=file://" + user_profile,
"--invisible",
"--headless",
"--norestore",
"--convert-to",
"pdf",
"--outdir",
convert_tmp_dir,
pptx_path,
]
run_cmd_no_check(cmd_pdf)
pdf_path = join(convert_tmp_dir, f"{stem}.pdf")
if exists(pdf_path):
return pdf_path
# Fallback: PPTX -> ODP, then ODP -> PDF
# Rationale: Saving as ODP normalizes PPTX-specific constructs via the ODF serializer,
# which often bypasses Impress PDF export issues on problematic decks.
cmd_odp = [
"soffice",
"-env:UserInstallation=file://" + user_profile,
"--invisible",
"--headless",
"--norestore",
"--convert-to",
"odp",
"--outdir",
convert_tmp_dir,
pptx_path,
]
run_cmd_no_check(cmd_odp)
odp_path = join(convert_tmp_dir, f"{stem}.odp")
if exists(odp_path):
# ODP -> PDF
cmd_odp_pdf = [
"soffice",
"-env:UserInstallation=file://" + user_profile,
"--invisible",
"--headless",
"--norestore",
"--convert-to",
"pdf",
"--outdir",
convert_tmp_dir,
odp_path,
]
run_cmd_no_check(cmd_odp_pdf)
if exists(pdf_path):
return pdf_path
return ""
def rasterize(
input_path: str,
out_dir: str,
dpi: int,
) -> Sequence[str]:
"""Rasterise PPTX/PDF to PNG files placed in out_dir and return the image paths."""
makedirs(out_dir, exist_ok=True)
input_path = abspath(input_path)
stem = splitext(basename(input_path))[0]
# Use a unique user profile to avoid LibreOffice profile lock when running concurrently
with tempfile.TemporaryDirectory(prefix="soffice_profile_") as user_profile:
# Write conversion outputs into a temp directory to avoid any IO oddities
with tempfile.TemporaryDirectory(prefix="soffice_convert_") as convert_tmp_dir:
is_pdf = input_path.lower().endswith(".pdf")
pdf_path = (
input_path
if is_pdf
else convert_to_pdf(input_path, user_profile, convert_tmp_dir, stem)
)
if not pdf_path or not exists(pdf_path):
raise RuntimeError(
"Failed to produce PDF for rasterization (direct and ODP fallback)."
)
# Perform rasterization while the temp PDF still exists
paths_raw = cast(
list[str],
convert_from_path(
pdf_path,
dpi=dpi,
fmt="png",
thread_count=8,
output_folder=out_dir,
paths_only=True,
output_file="slide",
),
)
# Rename convert_from_path's output format f'slide{thread_id:04d}-{page_num:02d}.png'
slides = []
for src_path in paths_raw:
base = splitext(basename(src_path))[0]
slide_num_str = base.split("-")[-1]
slide_num = int(slide_num_str)
dst_path = join(out_dir, f"slide-{slide_num}.png")
replace(src_path, dst_path)
slides.append((slide_num, dst_path))
slides.sort(key=lambda t: t[0])
final_paths = [path for _, path in slides]
return final_paths
def main() -> None:
parser = argparse.ArgumentParser(description="Render slides to images.")
parser.add_argument(
"input_path",
type=str,
help="Path to the input PowerPoint or PDF file.",
)
parser.add_argument(
"--output_dir",
type=str,
default=None,
help=(
"Output directory for the rendered images. "
"Defaults to a folder next to the input named after the input file (without extension)."
),
)
parser.add_argument(
"--width",
type=int,
default=1600,
help=(
"Approximate maximum width in pixels after isotropic scaling (default 1600). "
"The actual value may exceed slightly."
),
)
parser.add_argument(
"--height",
type=int,
default=900,
help=(
"Approximate maximum height in pixels after isotropic scaling (default 900). "
"The actual value may exceed slightly."
),
)
args = parser.parse_args()
input_path = abspath(expanduser(args.input_path))
out_dir = abspath(expanduser(args.output_dir)) if args.output_dir else splitext(input_path)[0]
if input_path.lower().endswith((".pptx", ".ppsx", ".potx", ".pptm", ".ppsm", ".potm")):
dpi = calc_dpi_via_ooxml(input_path, args.width, args.height)
else:
dpi = calc_dpi_via_pdf(input_path, args.width, args.height)
rasterize(input_path, out_dir, dpi)
print("Slides rendered to " + out_dir)
if __name__ == "__main__":
main()
@@ -0,0 +1,201 @@
#!/usr/bin/env python3
# Copyright (c) OpenAI. All rights reserved.
import argparse
import sys
import tempfile
from os.path import abspath, expanduser, join
from pathlib import Path
from typing import Sequence, cast
import numpy as np
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
import render_slides # type: ignore
from PIL import Image
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE
from pptx.util import Emu
# Configuration specific to overflow checking
PAD_PX: int = 100 # fixed padding on every side in pixels
PAD_RGB = (200, 200, 200)
EMU_PER_INCH: int = 914_400
def px_to_emu(px: int, dpi: int) -> Emu:
return Emu(int(px * EMU_PER_INCH // dpi))
def calc_tol(dpi: int) -> int:
"""Calculate per-channel colour tolerance appropriate for *dpi* (anti-aliasing tolerance)."""
if dpi >= 300:
return 0
# 1 at 250 DPI, 5 at 150 DPI, capped to 10.
tol = round((300 - dpi) / 25)
return min(max(tol, 1), 10)
def enlarge_deck(src: str, dst: str, pad_emu: Emu) -> tuple[int, int]:
"""Enlarge the input PPTX with a fixed grey padding and return the new page size."""
prs = Presentation(src)
w0 = cast(Emu, prs.slide_width)
h0 = cast(Emu, prs.slide_height)
w1 = Emu(w0 + 2 * pad_emu)
h1 = Emu(h0 + 2 * pad_emu)
prs.slide_width = w1
prs.slide_height = h1
for slide in prs.slides:
# Shift all shapes so the original canvas sits centred in the new deck.
for shp in list(slide.shapes):
shp.left = Emu(int(shp.left) + pad_emu)
shp.top = Emu(int(shp.top) + pad_emu)
pads = (
(Emu(0), Emu(0), pad_emu, h1), # left
(Emu(int(w1) - int(pad_emu)), Emu(0), pad_emu, h1), # right
(Emu(0), Emu(0), w1, pad_emu), # top
(Emu(0), Emu(int(h1) - int(pad_emu)), w1, pad_emu), # bottom
)
sp_tree = slide.shapes._spTree # pylint: disable=protected-access
for left, top, width, height in pads:
pad_shape = slide.shapes.add_shape(
MSO_AUTO_SHAPE_TYPE.RECTANGLE, left, top, width, height
)
pad_shape.fill.solid()
pad_shape.fill.fore_color.rgb = RGBColor(*PAD_RGB)
pad_shape.line.fill.background()
# Send pad behind all other shapes (index 2 after mandatory nodes)
sp_tree.remove(pad_shape._element)
sp_tree.insert(2, pad_shape._element)
prs.save(dst)
return int(w1), int(h1)
def inspect_images(
paths: Sequence[str],
pad_ratio_w: float,
pad_ratio_h: float,
dpi: int,
) -> list[int]:
"""Return 1-based indices of slides that contain pixels outside the pad."""
tol = calc_tol(dpi)
failures: list[int] = []
pad_colour = np.array(PAD_RGB, dtype=np.uint8)
for idx, img_path in enumerate(paths, start=1):
with Image.open(img_path) as img:
rgb = img.convert("RGB")
arr = np.asarray(rgb)
h, w, _ = arr.shape
# Exclude the innermost 1-pixel band
pad_x = int(w * pad_ratio_w) - 1
pad_y = int(h * pad_ratio_h) - 1
left_margin = arr[:, :pad_x, :]
right_margin = arr[:, w - pad_x :, :]
top_margin = arr[:pad_y, :, :]
bottom_margin = arr[h - pad_y :, :, :]
def _is_clean(margin: np.ndarray) -> bool:
diff = np.abs(margin.astype(np.int16) - pad_colour)
matches = np.all(diff <= tol, axis=-1)
mismatch_fraction = 1.0 - (np.count_nonzero(matches) / matches.size)
if dpi >= 300:
max_mismatch = 0.01
elif dpi >= 200:
max_mismatch = 0.02
else:
max_mismatch = 0.03
return mismatch_fraction <= max_mismatch
if not (
_is_clean(left_margin)
and _is_clean(right_margin)
and _is_clean(top_margin)
and _is_clean(bottom_margin)
):
failures.append(idx)
return failures
def main() -> None:
parser = argparse.ArgumentParser(
description=(
"Check a PPTX for content overflowing the original canvas by rendering with padding "
"and inspecting the margins."
)
)
parser.add_argument(
"input_path",
type=str,
help="Path to the input PPTX file.",
)
parser.add_argument(
"--width",
type=int,
default=1600,
help=(
"Approximate maximum width in pixels after isotropic scaling (default 1600). "
"The actual value may exceed slightly."
),
)
parser.add_argument(
"--height",
type=int,
default=900,
help=(
"Approximate maximum height in pixels after isotropic scaling (default 900). "
"The actual value may exceed slightly."
),
)
parser.add_argument(
"--pad_px",
type=int,
default=PAD_PX,
help="Padding in pixels to add on each side before rasterization.",
)
args = parser.parse_args()
input_path = abspath(expanduser(args.input_path))
# Width and height refer to the original, unaltered slide dimensions.
dpi = render_slides.calc_dpi_via_ooxml(input_path, args.width, args.height)
# Not using ``tempfile.TemporaryDirectory(delete=False)`` for Python 3.11 compatibility.
tmpdir = tempfile.mkdtemp()
enlarged_pptx = join(tmpdir, "enlarged.pptx")
pad_emu = px_to_emu(args.pad_px, dpi)
w1, h1 = enlarge_deck(input_path, enlarged_pptx, pad_emu=pad_emu)
pad_ratio_w = pad_emu / w1
pad_ratio_h = pad_emu / h1
img_dir = join(tmpdir, "imgs")
img_paths = render_slides.rasterize(enlarged_pptx, img_dir, dpi)
failing = inspect_images(img_paths, pad_ratio_w, pad_ratio_h, dpi)
if failing:
print(
"ERROR: Slides with content overflowing original canvas (1-based indexing): "
+ ", ".join(map(str, failing))
+ "\n"
+ "Rendered images with grey paddings for problematic slides are available at: "
)
for i in failing:
print(img_paths[i - 1])
else:
print("Test passed. No overflow detected.")
if __name__ == "__main__":
main()
+59 -36
View File
@@ -1,41 +1,50 @@
---
name: "spreadsheet"
description: "Use when tasks involve creating, editing, analyzing, or formatting spreadsheets (`.xlsx`, `.csv`, `.tsv`) using Python (`openpyxl`, `pandas`), especially when formulas, references, and formatting need to be preserved and verified."
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 (Create, Edit, Analyze, Visualize)
# Spreadsheet Skill
## When to use
- Build new workbooks with formulas, formatting, and structured layouts.
- 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 or references.
- Visualize data with charts/tables and sensible formatting.
- 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 goals (create, edit, analyze, visualize).
2. Use `openpyxl` for `.xlsx` edits and `pandas` for analysis and CSV/TSV workflows.
3. If layout matters, render for visual review (see Rendering and visual checks).
4. Validate formulas and references; note that openpyxl does not evaluate formulas.
5. Save outputs and clean up intermediate files.
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 when done.
- 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`.
- If you need charts, prefer `openpyxl.chart` for native Excel charts.
- 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, ask the user to review the output locally for layout accuracy.
- 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.
@@ -48,7 +57,7 @@ If `uv` is unavailable:
```
python3 -m pip install openpyxl pandas
```
Optional (chart-heavy or PDF review workflows):
Optional:
```
uv pip install matplotlib
```
@@ -65,7 +74,7 @@ brew install libreoffice poppler
sudo apt-get install -y libreoffice poppler-utils
```
If installation isn't possible in this environment, tell the user which dependency is missing and how to install it locally.
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.
@@ -75,48 +84,62 @@ No required environment variables.
## 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 (e.g., `=H6*(1+$B$3)` not `=H6*1.04`).
- Guard against errors (#REF!, #DIV/0!, #VALUE!, #N/A, #NAME?) with validation and checks.
- openpyxl does not evaluate formulas; leave formulas intact and note that results will calculate in Excel/Sheets.
- 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 sources of inputs in cell comments.
- For tabular data sourced from the web, include a Source column with URLs.
- 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 as dates, currency with symbols, percentages with sensible precision).
- Use a clean visual layout: headers distinct from data, consistent spacing, and readable column widths.
- Avoid borders around every cell; use whitespace and selective borders to structure sections.
- 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/derived values
- Green: linked/imported values
- Black: formulas and derived values
- Green: linked or imported values
- Gray: static constants
- Orange: review/caution
- Light red: error/flag
- Purple: control/logic
- Teal: visualization anchors (key KPIs or chart drivers)
- 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 "-".
- Format zeros as `-`.
- Negative numbers should be red and in parentheses.
- Always specify units in headers (e.g., "Revenue ($mm)").
- 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; use horizontal borders above totals across relevant columns.
- 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 left-aligned.
- Column labels for numeric data should be right-aligned; row labels should be left-aligned.
- Indent submetrics under their parent line items.
@@ -1,6 +1,6 @@
interface:
display_name: "Spreadsheet Skill (Create, Edit, Analyze, Visualize)"
display_name: "Spreadsheet Skill"
short_description: "Create, edit, and analyze spreadsheets"
icon_small: "./assets/spreadsheet-small.svg"
icon_large: "./assets/spreadsheet.png"
default_prompt: "Create or update a spreadsheet for this task with the right formulas, structure, and formatting."
default_prompt: "Use $spreadsheet to create or update a spreadsheet for this task with the right formulas, structure, and formatting."
@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path fill="currentColor" 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"/>
<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

After

Width:  |  Height:  |  Size: 1.4 KiB