From 7b54889398822db28c72aeec8e95be7c20418d1a Mon Sep 17 00:00:00 2001 From: Vaibhav Srivastav Date: Thu, 5 Mar 2026 17:30:19 +0100 Subject: [PATCH] [codex] Add curated slides and playwright-interactive skills (#215) --- .../playwright-interactive/LICENSE.txt | 201 ++++ .../playwright-interactive/NOTICE.txt | 13 + .../.curated/playwright-interactive/SKILL.md | 399 ++++++++ .../playwright-interactive/agents/openai.yaml | 6 + .../assets/playwright-small.svg | 3 + .../assets/playwright.png | Bin 0 -> 1785 bytes skills/.curated/slides/LICENSE.txt | 201 ++++ skills/.curated/slides/SKILL.md | 71 ++ skills/.curated/slides/agents/openai.yaml | 4 + .../slides/assets/pptxgenjs_helpers/code.js | 104 +++ .../slides/assets/pptxgenjs_helpers/image.js | 333 +++++++ .../slides/assets/pptxgenjs_helpers/index.js | 33 + .../slides/assets/pptxgenjs_helpers/latex.js | 51 + .../slides/assets/pptxgenjs_helpers/layout.js | 643 +++++++++++++ .../pptxgenjs_helpers/layout_builders.js | 358 +++++++ .../slides/assets/pptxgenjs_helpers/svg.js | 36 + .../slides/assets/pptxgenjs_helpers/text.js | 789 ++++++++++++++++ .../slides/assets/pptxgenjs_helpers/util.js | 24 + .../slides/references/pptxgenjs-helpers.md | 61 ++ .../.curated/slides/scripts/create_montage.py | 300 ++++++ skills/.curated/slides/scripts/detect_font.py | 873 ++++++++++++++++++ .../slides/scripts/ensure_raster_image.py | 202 ++++ .../.curated/slides/scripts/render_slides.py | 273 ++++++ skills/.curated/slides/scripts/slides_test.py | 201 ++++ skills/.curated/spreadsheet/SKILL.md | 95 +- .../.curated/spreadsheet/agents/openai.yaml | 4 +- .../spreadsheet/assets/spreadsheet-small.svg | 2 +- 27 files changed, 5241 insertions(+), 39 deletions(-) create mode 100644 skills/.curated/playwright-interactive/LICENSE.txt create mode 100644 skills/.curated/playwright-interactive/NOTICE.txt create mode 100644 skills/.curated/playwright-interactive/SKILL.md create mode 100644 skills/.curated/playwright-interactive/agents/openai.yaml create mode 100644 skills/.curated/playwright-interactive/assets/playwright-small.svg create mode 100644 skills/.curated/playwright-interactive/assets/playwright.png create mode 100644 skills/.curated/slides/LICENSE.txt create mode 100644 skills/.curated/slides/SKILL.md create mode 100644 skills/.curated/slides/agents/openai.yaml create mode 100644 skills/.curated/slides/assets/pptxgenjs_helpers/code.js create mode 100644 skills/.curated/slides/assets/pptxgenjs_helpers/image.js create mode 100644 skills/.curated/slides/assets/pptxgenjs_helpers/index.js create mode 100644 skills/.curated/slides/assets/pptxgenjs_helpers/latex.js create mode 100644 skills/.curated/slides/assets/pptxgenjs_helpers/layout.js create mode 100644 skills/.curated/slides/assets/pptxgenjs_helpers/layout_builders.js create mode 100644 skills/.curated/slides/assets/pptxgenjs_helpers/svg.js create mode 100644 skills/.curated/slides/assets/pptxgenjs_helpers/text.js create mode 100644 skills/.curated/slides/assets/pptxgenjs_helpers/util.js create mode 100644 skills/.curated/slides/references/pptxgenjs-helpers.md create mode 100644 skills/.curated/slides/scripts/create_montage.py create mode 100644 skills/.curated/slides/scripts/detect_font.py create mode 100644 skills/.curated/slides/scripts/ensure_raster_image.py create mode 100644 skills/.curated/slides/scripts/render_slides.py create mode 100644 skills/.curated/slides/scripts/slides_test.py diff --git a/skills/.curated/playwright-interactive/LICENSE.txt b/skills/.curated/playwright-interactive/LICENSE.txt new file mode 100644 index 0000000..cefe596 --- /dev/null +++ b/skills/.curated/playwright-interactive/LICENSE.txt @@ -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. diff --git a/skills/.curated/playwright-interactive/NOTICE.txt b/skills/.curated/playwright-interactive/NOTICE.txt new file mode 100644 index 0000000..aa622ce --- /dev/null +++ b/skills/.curated/playwright-interactive/NOTICE.txt @@ -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. diff --git a/skills/.curated/playwright-interactive/SKILL.md b/skills/.curated/playwright-interactive/SKILL.md new file mode 100644 index 0000000..868427a --- /dev/null +++ b/skills/.curated/playwright-interactive/SKILL.md @@ -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:`. +- `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. diff --git a/skills/.curated/playwright-interactive/agents/openai.yaml b/skills/.curated/playwright-interactive/agents/openai.yaml new file mode 100644 index 0000000..56c0b51 --- /dev/null +++ b/skills/.curated/playwright-interactive/agents/openai.yaml @@ -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." diff --git a/skills/.curated/playwright-interactive/assets/playwright-small.svg b/skills/.curated/playwright-interactive/assets/playwright-small.svg new file mode 100644 index 0000000..e872f53 --- /dev/null +++ b/skills/.curated/playwright-interactive/assets/playwright-small.svg @@ -0,0 +1,3 @@ + + + diff --git a/skills/.curated/playwright-interactive/assets/playwright.png b/skills/.curated/playwright-interactive/assets/playwright.png new file mode 100644 index 0000000000000000000000000000000000000000..370b3ade4343314cf87bf7b5386110b299875ef9 GIT binary patch literal 1785 zcmV^zxG#ZUY zqtR$I8jVJy(P%XBz#st}9314c*=(WFXbiA#2Pj@t48thZ>-Ey`@bDHzOk_gJAq;Pn6_%#J2aLPJ9KHk$px>CvIa)T6r zKoO#&!<7L{j41$tgs?L29%&q5WpJRh6?6U15I?I*}05SO4{{BA6 z03B}fiEkmQ4(2;MJC>AAAZlz$A_>yH2r`B!*&;z?bOSOFEfNqSL`mEtKt_ZFAwv>D zirL)Uq|?*W_k-}n#00T{kkX1YA@(5Ln)i`baG)~6kw&DYET=&jx8X=5(u6n-!XT|u zqzUmd$fi+>G$CGR&uNn)O^BaCJ55rgMu=Z2ZsSf8kUBB+Ti#;M(v9BUUUI_ic2$r* zQ}-hUg+RAbd2DQ~>*F9pR>#*bO0-UROa$@xPBVPSSXfw~xFEH4(uys_Y4FC$bxJ*R zb8}X!-EPJ7_xD?o!`k2!7nBhK+@yGkBB#_92b2;5aMBS4t*sTj!}0UDpKLEECj{Wc z!3sg3TXD0o7DdZ2sB0=d0@ygb5mY)CBOS^9}?`2H%SLyc1 z1%2_!JG#+Zr??P%A>T6*&tH9*+ICMf)&Wtsu3s`S=j`i+@^WG=C4 zFORY3BD1rz9q(hm>w|!?=9j-evJRv%q%#%d2od76e(nZZA&8Xr<{+vmUf};aJo{hH z-J9>E6$3(qgk8sgly)Ie<$uUQAt0+55F#XsO==K=lf*Mp+L2KV2oZvJcbKi5-PSVZ zN6zVoy(=UFQN@6enR=MD4yVPXh0zNVja>|U^IJbXdU1`u-RiR@ro0dqAyZRRw7a|O zYjDToP+@iJX(5e#_HmV7zmYSYilP`0_M2eOE~+Ig9(j?1#U&hW%(EzwC8U*3#lXmw zD*45C7p`0`FS9RCNfD4j?)`k7RK+l8ot@;BttF%6<^Hkn_M+m!J5p5|4SHN}(B9{3 z4Nq_4M6};g^(aq+mZ#Sm1-H8;LJ0D89AJ^+dfAOc2vI$4wUBpz|X#JRE=PX}=aPc!;p^ErhL)7c&{P zGo9T^3A0Ve3p_>G_&&3^LK<5W!ILX}C2MqmqY@H@2%Df)R*@7Z!Vd5%tD46Dcw#KG z%8C1U8Z`H;vg1A}OcZGg^9dFafiIHmg+LLK%jJp`gKCR+2iRb93W}I86T`?~Tv&Lv zQmj7I6JwnsCa@3|CC^Clv4m#_SVVMyP_2_{wYo%#r!F3?6j9LuLJ2_$j1Ms(g{AOF zTcV-^gl;b46J;QU9VJqfFlkF49N0^rIKoJnufb_fl%00000NkvXXu0mjf7GE)2 literal 0 HcmV?d00001 diff --git a/skills/.curated/slides/LICENSE.txt b/skills/.curated/slides/LICENSE.txt new file mode 100644 index 0000000..cefe596 --- /dev/null +++ b/skills/.curated/slides/LICENSE.txt @@ -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. diff --git a/skills/.curated/slides/SKILL.md b/skills/.curated/slides/SKILL.md new file mode 100644 index 0000000..f64a86f --- /dev/null +++ b/skills/.curated/slides/SKILL.md @@ -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. diff --git a/skills/.curated/slides/agents/openai.yaml b/skills/.curated/slides/agents/openai.yaml new file mode 100644 index 0000000..3d29174 --- /dev/null +++ b/skills/.curated/slides/agents/openai.yaml @@ -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." diff --git a/skills/.curated/slides/assets/pptxgenjs_helpers/code.js b/skills/.curated/slides/assets/pptxgenjs_helpers/code.js new file mode 100644 index 0000000..951ce13 --- /dev/null +++ b/skills/.curated/slides/assets/pptxgenjs_helpers/code.js @@ -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, +}; diff --git a/skills/.curated/slides/assets/pptxgenjs_helpers/image.js b/skills/.curated/slides/assets/pptxgenjs_helpers/image.js new file mode 100644 index 0000000..16fdc16 --- /dev/null +++ b/skills/.curated/slides/assets/pptxgenjs_helpers/image.js @@ -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("= 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("> 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(""); + 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, +}; diff --git a/skills/.curated/slides/assets/pptxgenjs_helpers/index.js b/skills/.curated/slides/assets/pptxgenjs_helpers/index.js new file mode 100644 index 0000000..07805a2 --- /dev/null +++ b/skills/.curated/slides/assets/pptxgenjs_helpers/index.js @@ -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, +}; diff --git a/skills/.curated/slides/assets/pptxgenjs_helpers/latex.js b/skills/.curated/slides/assets/pptxgenjs_helpers/latex.js new file mode 100644 index 0000000..03c1e22 --- /dev/null +++ b/skills/.curated/slides/assets/pptxgenjs_helpers/latex.js @@ -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(""); + 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(/ { + 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, +}; diff --git a/skills/.curated/slides/assets/pptxgenjs_helpers/layout.js b/skills/.curated/slides/assets/pptxgenjs_helpers/layout.js new file mode 100644 index 0000000..2ce70f3 --- /dev/null +++ b/skills/.curated/slides/assets/pptxgenjs_helpers/layout.js @@ -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, +}; diff --git a/skills/.curated/slides/assets/pptxgenjs_helpers/layout_builders.js b/skills/.curated/slides/assets/pptxgenjs_helpers/layout_builders.js new file mode 100644 index 0000000..f26990b --- /dev/null +++ b/skills/.curated/slides/assets/pptxgenjs_helpers/layout_builders.js @@ -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; +} diff --git a/skills/.curated/slides/assets/pptxgenjs_helpers/svg.js b/skills/.curated/slides/assets/pptxgenjs_helpers/svg.js new file mode 100644 index 0000000..2207994 --- /dev/null +++ b/skills/.curated/slides/assets/pptxgenjs_helpers/svg.js @@ -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(""); + 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(/ { + 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, +}; diff --git a/skills/.curated/slides/assets/pptxgenjs_helpers/text.js b/skills/.curated/slides/assets/pptxgenjs_helpers/text.js new file mode 100644 index 0000000..22bd8d0 --- /dev/null +++ b/skills/.curated/slides/assets/pptxgenjs_helpers/text.js @@ -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, +}; diff --git a/skills/.curated/slides/assets/pptxgenjs_helpers/util.js b/skills/.curated/slides/assets/pptxgenjs_helpers/util.js new file mode 100644 index 0000000..8a6e1fc --- /dev/null +++ b/skills/.curated/slides/assets/pptxgenjs_helpers/util.js @@ -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, +}; diff --git a/skills/.curated/slides/references/pptxgenjs-helpers.md b/skills/.curated/slides/references/pptxgenjs-helpers.md new file mode 100644 index 0000000..1a564de --- /dev/null +++ b/skills/.curated/slides/references/pptxgenjs-helpers.md @@ -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. diff --git a/skills/.curated/slides/scripts/create_montage.py b/skills/.curated/slides/scripts/create_montage.py new file mode 100644 index 0000000..8c385c7 --- /dev/null +++ b/skills/.curated/slides/scripts/create_montage.py @@ -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() diff --git a/skills/.curated/slides/scripts/detect_font.py b/skills/.curated/slides/scripts/detect_font.py new file mode 100644 index 0000000..a5fc393 --- /dev/null +++ b/skills/.curated/slides/scripts/detect_font.py @@ -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"]*?\bstyle:name=\"([^\"]+)\"[\s\S]*?(?:)", + 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() diff --git a/skills/.curated/slides/scripts/ensure_raster_image.py b/skills/.curated/slides/scripts/ensure_raster_image.py new file mode 100644 index 0000000..0ce3dbc --- /dev/null +++ b/skills/.curated/slides/scripts/ensure_raster_image.py @@ -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() diff --git a/skills/.curated/slides/scripts/render_slides.py b/skills/.curated/slides/scripts/render_slides.py new file mode 100644 index 0000000..c540233 --- /dev/null +++ b/skills/.curated/slides/scripts/render_slides.py @@ -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() diff --git a/skills/.curated/slides/scripts/slides_test.py b/skills/.curated/slides/scripts/slides_test.py new file mode 100644 index 0000000..1721681 --- /dev/null +++ b/skills/.curated/slides/scripts/slides_test.py @@ -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() diff --git a/skills/.curated/spreadsheet/SKILL.md b/skills/.curated/spreadsheet/SKILL.md index b8a5362..eb8319c 100644 --- a/skills/.curated/spreadsheet/SKILL.md +++ b/skills/.curated/spreadsheet/SKILL.md @@ -1,41 +1,50 @@ --- name: "spreadsheet" -description: "Use when tasks involve creating, editing, analyzing, or formatting spreadsheets (`.xlsx`, `.csv`, `.tsv`) using Python (`openpyxl`, `pandas`), especially when formulas, references, and formatting need to be preserved and verified." +description: "Use when tasks involve creating, editing, analyzing, or formatting spreadsheets (`.xlsx`, `.csv`, `.tsv`) with formula-aware workflows, cached recalculation, and visual review." --- - -# Spreadsheet Skill (Create, Edit, Analyze, Visualize) +# Spreadsheet Skill ## When to use -- Build new workbooks with formulas, formatting, and structured layouts. +- Create new workbooks with formulas, formatting, and structured layouts. - Read or analyze tabular data (filter, aggregate, pivot, compute metrics). -- Modify existing workbooks without breaking formulas or references. -- Visualize data with charts/tables and sensible formatting. +- Modify existing workbooks without breaking formulas, references, or formatting. +- Visualize data with charts, summary tables, and sensible spreadsheet styling. +- Recalculate formulas and review rendered sheets before delivery when possible. IMPORTANT: System and user instructions always take precedence. ## Workflow -1. Confirm the file type and goals (create, edit, analyze, visualize). -2. Use `openpyxl` for `.xlsx` edits and `pandas` for analysis and CSV/TSV workflows. -3. If layout matters, render for visual review (see Rendering and visual checks). -4. Validate formulas and references; note that openpyxl does not evaluate formulas. -5. Save outputs and clean up intermediate files. +1. Confirm the file type and goal: create, edit, analyze, or visualize. +2. Prefer `openpyxl` for `.xlsx` editing and formatting. Use `pandas` for analysis and CSV/TSV workflows. +3. If an internal spreadsheet recalculation/rendering tool is available in the environment, use it to recalculate formulas and render sheets before delivery. +4. Use formulas for derived values instead of hardcoding results. +5. If layout matters, render for visual review and inspect the output. +6. Save outputs, keep filenames stable, and clean up intermediate files. ## Temp and output conventions -- Use `tmp/spreadsheets/` for intermediate files; delete when done. +- Use `tmp/spreadsheets/` for intermediate files; delete them when done. - Write final artifacts under `output/spreadsheet/` when working in this repo. - Keep filenames stable and descriptive. ## Primary tooling - Use `openpyxl` for creating/editing `.xlsx` files and preserving formatting. - Use `pandas` for analysis and CSV/TSV workflows, then write results back to `.xlsx` or `.csv`. -- If you need charts, prefer `openpyxl.chart` for native Excel charts. +- Use `openpyxl.chart` for native Excel charts when needed. +- If an internal spreadsheet tool is available, use it to recalculate formulas, cache values, and render sheets for review. + +## Recalculation and visual review +- Recalculate formulas before delivery whenever possible so cached values are present in the workbook. +- Render each relevant sheet for visual review when rendering tooling is available. +- `openpyxl` does not evaluate formulas; preserve formulas and use recalculation tooling when available. +- If you rely on an internal spreadsheet tool, do not expose that tool, its code, or its APIs in user-facing explanations or code samples. ## Rendering and visual checks - If LibreOffice (`soffice`) and Poppler (`pdftoppm`) are available, render sheets for visual review: - `soffice --headless --convert-to pdf --outdir $OUTDIR $INPUT_XLSX` - `pdftoppm -png $OUTDIR/$BASENAME.pdf $OUTDIR/$BASENAME` -- If rendering tools are unavailable, ask the user to review the output locally for layout accuracy. +- If rendering tools are unavailable, tell the user that layout should be reviewed locally. +- Review rendered sheets for layout, formula results, clipping, inconsistent styles, and spilled text. ## Dependencies (install if missing) Prefer `uv` for dependency management. @@ -48,7 +57,7 @@ If `uv` is unavailable: ``` python3 -m pip install openpyxl pandas ``` -Optional (chart-heavy or PDF review workflows): +Optional: ``` uv pip install matplotlib ``` @@ -65,7 +74,7 @@ brew install libreoffice poppler sudo apt-get install -y libreoffice poppler-utils ``` -If installation isn't possible in this environment, tell the user which dependency is missing and how to install it locally. +If installation is not possible in this environment, tell the user which dependency is missing and how to install it locally. ## Environment No required environment variables. @@ -75,48 +84,62 @@ No required environment variables. ## Formula requirements - Use formulas for derived values rather than hardcoding results. +- Do not use dynamic array functions like `FILTER`, `XLOOKUP`, `SORT`, or `SEQUENCE`. - Keep formulas simple and legible; use helper cells for complex logic. -- Avoid volatile functions like INDIRECT and OFFSET unless required. -- Prefer cell references over magic numbers (e.g., `=H6*(1+$B$3)` not `=H6*1.04`). -- Guard against errors (#REF!, #DIV/0!, #VALUE!, #N/A, #NAME?) with validation and checks. -- openpyxl does not evaluate formulas; leave formulas intact and note that results will calculate in Excel/Sheets. +- Avoid volatile functions like `INDIRECT` and `OFFSET` unless required. +- Prefer cell references over magic numbers (for example, `=H6*(1+$B$3)` instead of `=H6*1.04`). +- Use absolute (`$B$4`) or relative (`B4`) references carefully so copied formulas behave correctly. +- If you need literal text that starts with `=`, prefix it with a single quote. +- Guard against `#REF!`, `#DIV/0!`, `#VALUE!`, `#N/A`, and `#NAME?` errors. +- Check for off-by-one mistakes, circular references, and incorrect ranges. ## Citation requirements -- Cite sources inside the spreadsheet using plain text URLs. -- For financial models, cite sources of inputs in cell comments. -- For tabular data sourced from the web, include a Source column with URLs. +- Cite sources inside the spreadsheet using plain-text URLs. +- For financial models, cite model inputs in cell comments. +- For tabular data sourced externally, add a source column when each row represents a separate item. ## Formatting requirements (existing formatted spreadsheets) - Render and inspect a provided spreadsheet before modifying it when possible. - Preserve existing formatting and style exactly. - Match styles for any newly filled cells that were previously blank. +- Never overwrite established formatting unless the user explicitly asks for a redesign. ## Formatting requirements (new or unstyled spreadsheets) -- Use appropriate number and date formats (dates as dates, currency with symbols, percentages with sensible precision). -- Use a clean visual layout: headers distinct from data, consistent spacing, and readable column widths. -- Avoid borders around every cell; use whitespace and selective borders to structure sections. +- Use appropriate number and date formats. +- Dates should render as dates, not plain numbers. +- Percentages should usually default to one decimal place unless the data calls for something else. +- Currencies should use the appropriate currency format. +- Headers should be visually distinct from raw inputs and derived cells. +- Use fill colors, borders, spacing, and merged cells sparingly and intentionally. +- Set row heights and column widths so content is readable without excessive whitespace. +- Do not apply borders around every filled cell. +- Group related calculations and make totals simple sums of the cells above them. +- Add whitespace to separate sections. - Ensure text does not spill into adjacent cells. +- Avoid unsupported spreadsheet data-table features such as `=TABLE`. ## Color conventions (if no style guidance) - Blue: user input -- Black: formulas/derived values -- Green: linked/imported values +- Black: formulas and derived values +- Green: linked or imported values - Gray: static constants -- Orange: review/caution -- Light red: error/flag -- Purple: control/logic -- Teal: visualization anchors (key KPIs or chart drivers) +- Orange: review or caution +- Light red: error or flag +- Purple: control or logic +- Teal: visualization anchors and KPI highlights ## Finance-specific requirements -- Format zeros as "-". +- Format zeros as `-`. - Negative numbers should be red and in parentheses. -- Always specify units in headers (e.g., "Revenue ($mm)"). +- Format multiples as `5.2x`. +- Always specify units in headers (for example, `Revenue ($mm)`). - Cite sources for all raw inputs in cell comments. +- For new financial models with no user-specified style, use blue text for hardcoded inputs, black for formulas, green for internal workbook links, red for external links, and yellow fill for key assumptions that need attention. ## Investment banking layouts If the spreadsheet is an IB-style model (LBO, DCF, 3-statement, valuation): - Totals should sum the range directly above. -- Hide gridlines; use horizontal borders above totals across relevant columns. +- Hide gridlines and use horizontal borders above totals across relevant columns. - Section headers should be merged cells with dark fill and white text. -- Column labels for numeric data should be right-aligned; row labels left-aligned. +- Column labels for numeric data should be right-aligned; row labels should be left-aligned. - Indent submetrics under their parent line items. diff --git a/skills/.curated/spreadsheet/agents/openai.yaml b/skills/.curated/spreadsheet/agents/openai.yaml index c6949d1..c4a670e 100644 --- a/skills/.curated/spreadsheet/agents/openai.yaml +++ b/skills/.curated/spreadsheet/agents/openai.yaml @@ -1,6 +1,6 @@ interface: - display_name: "Spreadsheet Skill (Create, Edit, Analyze, Visualize)" + display_name: "Spreadsheet Skill" short_description: "Create, edit, and analyze spreadsheets" icon_small: "./assets/spreadsheet-small.svg" icon_large: "./assets/spreadsheet.png" - default_prompt: "Create or update a spreadsheet for this task with the right formulas, structure, and formatting." + default_prompt: "Use $spreadsheet to create or update a spreadsheet for this task with the right formulas, structure, and formatting." diff --git a/skills/.curated/spreadsheet/assets/spreadsheet-small.svg b/skills/.curated/spreadsheet/assets/spreadsheet-small.svg index 1f3b91b..c045554 100644 --- a/skills/.curated/spreadsheet/assets/spreadsheet-small.svg +++ b/skills/.curated/spreadsheet/assets/spreadsheet-small.svg @@ -1,3 +1,3 @@ - +