mirror of
https://github.com/openai/skills.git
synced 2026-09-11 19:47:26 +03:00
[codex] Add curated slides and playwright-interactive skills (#215)
This commit is contained in:
@@ -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 |
@@ -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,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()
|
||||||
@@ -1,41 +1,50 @@
|
|||||||
---
|
---
|
||||||
name: "spreadsheet"
|
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
|
||||||
# Spreadsheet Skill (Create, Edit, Analyze, Visualize)
|
|
||||||
|
|
||||||
## When to use
|
## 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).
|
- Read or analyze tabular data (filter, aggregate, pivot, compute metrics).
|
||||||
- Modify existing workbooks without breaking formulas or references.
|
- Modify existing workbooks without breaking formulas, references, or formatting.
|
||||||
- Visualize data with charts/tables and sensible 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.
|
IMPORTANT: System and user instructions always take precedence.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
1. Confirm the file type and goals (create, edit, analyze, visualize).
|
1. Confirm the file type and goal: create, edit, analyze, or visualize.
|
||||||
2. Use `openpyxl` for `.xlsx` edits and `pandas` for analysis and CSV/TSV workflows.
|
2. Prefer `openpyxl` for `.xlsx` editing and formatting. Use `pandas` for analysis and CSV/TSV workflows.
|
||||||
3. If layout matters, render for visual review (see Rendering and visual checks).
|
3. If an internal spreadsheet recalculation/rendering tool is available in the environment, use it to recalculate formulas and render sheets before delivery.
|
||||||
4. Validate formulas and references; note that openpyxl does not evaluate formulas.
|
4. Use formulas for derived values instead of hardcoding results.
|
||||||
5. Save outputs and clean up intermediate files.
|
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
|
## 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.
|
- Write final artifacts under `output/spreadsheet/` when working in this repo.
|
||||||
- Keep filenames stable and descriptive.
|
- Keep filenames stable and descriptive.
|
||||||
|
|
||||||
## Primary tooling
|
## Primary tooling
|
||||||
- Use `openpyxl` for creating/editing `.xlsx` files and preserving formatting.
|
- Use `openpyxl` for creating/editing `.xlsx` files and preserving formatting.
|
||||||
- Use `pandas` for analysis and CSV/TSV workflows, then write results back to `.xlsx` or `.csv`.
|
- Use `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
|
## Rendering and visual checks
|
||||||
- If LibreOffice (`soffice`) and Poppler (`pdftoppm`) are available, render sheets for visual review:
|
- If LibreOffice (`soffice`) and Poppler (`pdftoppm`) are available, render sheets for visual review:
|
||||||
- `soffice --headless --convert-to pdf --outdir $OUTDIR $INPUT_XLSX`
|
- `soffice --headless --convert-to pdf --outdir $OUTDIR $INPUT_XLSX`
|
||||||
- `pdftoppm -png $OUTDIR/$BASENAME.pdf $OUTDIR/$BASENAME`
|
- `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)
|
## Dependencies (install if missing)
|
||||||
Prefer `uv` for dependency management.
|
Prefer `uv` for dependency management.
|
||||||
@@ -48,7 +57,7 @@ If `uv` is unavailable:
|
|||||||
```
|
```
|
||||||
python3 -m pip install openpyxl pandas
|
python3 -m pip install openpyxl pandas
|
||||||
```
|
```
|
||||||
Optional (chart-heavy or PDF review workflows):
|
Optional:
|
||||||
```
|
```
|
||||||
uv pip install matplotlib
|
uv pip install matplotlib
|
||||||
```
|
```
|
||||||
@@ -65,7 +74,7 @@ brew install libreoffice poppler
|
|||||||
sudo apt-get install -y libreoffice poppler-utils
|
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
|
## Environment
|
||||||
No required environment variables.
|
No required environment variables.
|
||||||
@@ -75,48 +84,62 @@ No required environment variables.
|
|||||||
|
|
||||||
## Formula requirements
|
## Formula requirements
|
||||||
- Use formulas for derived values rather than hardcoding results.
|
- 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.
|
- Keep formulas simple and legible; use helper cells for complex logic.
|
||||||
- Avoid volatile functions like INDIRECT and OFFSET unless required.
|
- 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`).
|
- Prefer cell references over magic numbers (for example, `=H6*(1+$B$3)` instead of `=H6*1.04`).
|
||||||
- Guard against errors (#REF!, #DIV/0!, #VALUE!, #N/A, #NAME?) with validation and checks.
|
- Use absolute (`$B$4`) or relative (`B4`) references carefully so copied formulas behave correctly.
|
||||||
- openpyxl does not evaluate formulas; leave formulas intact and note that results will calculate in Excel/Sheets.
|
- 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
|
## Citation requirements
|
||||||
- Cite sources inside the spreadsheet using plain text URLs.
|
- Cite sources inside the spreadsheet using plain-text URLs.
|
||||||
- For financial models, cite sources of inputs in cell comments.
|
- For financial models, cite model inputs in cell comments.
|
||||||
- For tabular data sourced from the web, include a Source column with URLs.
|
- For tabular data sourced externally, add a source column when each row represents a separate item.
|
||||||
|
|
||||||
## Formatting requirements (existing formatted spreadsheets)
|
## Formatting requirements (existing formatted spreadsheets)
|
||||||
- Render and inspect a provided spreadsheet before modifying it when possible.
|
- Render and inspect a provided spreadsheet before modifying it when possible.
|
||||||
- Preserve existing formatting and style exactly.
|
- Preserve existing formatting and style exactly.
|
||||||
- Match styles for any newly filled cells that were previously blank.
|
- 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)
|
## Formatting requirements (new or unstyled spreadsheets)
|
||||||
- Use appropriate number and date formats (dates as dates, currency with symbols, percentages with sensible precision).
|
- Use appropriate number and date formats.
|
||||||
- Use a clean visual layout: headers distinct from data, consistent spacing, and readable column widths.
|
- Dates should render as dates, not plain numbers.
|
||||||
- Avoid borders around every cell; use whitespace and selective borders to structure sections.
|
- 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.
|
- Ensure text does not spill into adjacent cells.
|
||||||
|
- Avoid unsupported spreadsheet data-table features such as `=TABLE`.
|
||||||
|
|
||||||
## Color conventions (if no style guidance)
|
## Color conventions (if no style guidance)
|
||||||
- Blue: user input
|
- Blue: user input
|
||||||
- Black: formulas/derived values
|
- Black: formulas and derived values
|
||||||
- Green: linked/imported values
|
- Green: linked or imported values
|
||||||
- Gray: static constants
|
- Gray: static constants
|
||||||
- Orange: review/caution
|
- Orange: review or caution
|
||||||
- Light red: error/flag
|
- Light red: error or flag
|
||||||
- Purple: control/logic
|
- Purple: control or logic
|
||||||
- Teal: visualization anchors (key KPIs or chart drivers)
|
- Teal: visualization anchors and KPI highlights
|
||||||
|
|
||||||
## Finance-specific requirements
|
## Finance-specific requirements
|
||||||
- Format zeros as "-".
|
- Format zeros as `-`.
|
||||||
- Negative numbers should be red and in parentheses.
|
- 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.
|
- 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
|
## Investment banking layouts
|
||||||
If the spreadsheet is an IB-style model (LBO, DCF, 3-statement, valuation):
|
If the spreadsheet is an IB-style model (LBO, DCF, 3-statement, valuation):
|
||||||
- Totals should sum the range directly above.
|
- 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.
|
- 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.
|
- Indent submetrics under their parent line items.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
interface:
|
interface:
|
||||||
display_name: "Spreadsheet Skill (Create, Edit, Analyze, Visualize)"
|
display_name: "Spreadsheet Skill"
|
||||||
short_description: "Create, edit, and analyze spreadsheets"
|
short_description: "Create, edit, and analyze spreadsheets"
|
||||||
icon_small: "./assets/spreadsheet-small.svg"
|
icon_small: "./assets/spreadsheet-small.svg"
|
||||||
icon_large: "./assets/spreadsheet.png"
|
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">
|
<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>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
Reference in New Issue
Block a user