From fdf90d652aea00d3fa57803a348744b1d7670fcb Mon Sep 17 00:00:00 2001 From: edwardysun3 Date: Thu, 16 Apr 2026 09:52:19 -0700 Subject: [PATCH] Remove spreadsheets and slides skills (#350) Removes the curated spreadsheet and slides skills from the repository. Validation: not run; deletion-only change. --- skills/.curated/slides/LICENSE.txt | 201 ---- skills/.curated/slides/SKILL.md | 71 -- skills/.curated/slides/agents/openai.yaml | 6 - .../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 - .../.curated/slides/assets/slides-small.svg | 3 - skills/.curated/slides/assets/slides.png | Bin 1868 -> 0 bytes .../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/LICENSE.txt | 201 ---- skills/.curated/spreadsheet/SKILL.md | 145 --- .../.curated/spreadsheet/agents/openai.yaml | 6 - .../spreadsheet/assets/spreadsheet-small.svg | 3 - .../spreadsheet/assets/spreadsheet.png | Bin 3077 -> 0 bytes .../openpyxl/create_basic_spreadsheet.py | 51 - .../create_spreadsheet_with_styling.py | 96 -- .../openpyxl/read_existing_spreadsheet.py | 59 -- .../examples/openpyxl/styling_spreadsheet.py | 79 -- 29 files changed, 5202 deletions(-) delete mode 100644 skills/.curated/slides/LICENSE.txt delete mode 100644 skills/.curated/slides/SKILL.md delete mode 100644 skills/.curated/slides/agents/openai.yaml delete mode 100644 skills/.curated/slides/assets/pptxgenjs_helpers/code.js delete mode 100644 skills/.curated/slides/assets/pptxgenjs_helpers/image.js delete mode 100644 skills/.curated/slides/assets/pptxgenjs_helpers/index.js delete mode 100644 skills/.curated/slides/assets/pptxgenjs_helpers/latex.js delete mode 100644 skills/.curated/slides/assets/pptxgenjs_helpers/layout.js delete mode 100644 skills/.curated/slides/assets/pptxgenjs_helpers/layout_builders.js delete mode 100644 skills/.curated/slides/assets/pptxgenjs_helpers/svg.js delete mode 100644 skills/.curated/slides/assets/pptxgenjs_helpers/text.js delete mode 100644 skills/.curated/slides/assets/pptxgenjs_helpers/util.js delete mode 100644 skills/.curated/slides/assets/slides-small.svg delete mode 100644 skills/.curated/slides/assets/slides.png delete mode 100644 skills/.curated/slides/references/pptxgenjs-helpers.md delete mode 100644 skills/.curated/slides/scripts/create_montage.py delete mode 100644 skills/.curated/slides/scripts/detect_font.py delete mode 100644 skills/.curated/slides/scripts/ensure_raster_image.py delete mode 100644 skills/.curated/slides/scripts/render_slides.py delete mode 100644 skills/.curated/slides/scripts/slides_test.py delete mode 100644 skills/.curated/spreadsheet/LICENSE.txt delete mode 100644 skills/.curated/spreadsheet/SKILL.md delete mode 100644 skills/.curated/spreadsheet/agents/openai.yaml delete mode 100644 skills/.curated/spreadsheet/assets/spreadsheet-small.svg delete mode 100644 skills/.curated/spreadsheet/assets/spreadsheet.png delete mode 100644 skills/.curated/spreadsheet/references/examples/openpyxl/create_basic_spreadsheet.py delete mode 100644 skills/.curated/spreadsheet/references/examples/openpyxl/create_spreadsheet_with_styling.py delete mode 100644 skills/.curated/spreadsheet/references/examples/openpyxl/read_existing_spreadsheet.py delete mode 100644 skills/.curated/spreadsheet/references/examples/openpyxl/styling_spreadsheet.py diff --git a/skills/.curated/slides/LICENSE.txt b/skills/.curated/slides/LICENSE.txt deleted file mode 100644 index cefe596..0000000 --- a/skills/.curated/slides/LICENSE.txt +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright (c) Microsoft Corporation. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/skills/.curated/slides/SKILL.md b/skills/.curated/slides/SKILL.md deleted file mode 100644 index f64a86f..0000000 --- a/skills/.curated/slides/SKILL.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -name: slides -description: Create and edit presentation slide decks (`.pptx`) with PptxGenJS, bundled layout helpers, and render/validation utilities. Use when tasks involve building a new PowerPoint deck, recreating slides from screenshots/PDFs/reference decks, modifying slide content while preserving editable output, adding charts/diagrams/visuals, or diagnosing layout issues such as overflow, overlaps, and font substitution. ---- - -# Slides - -## Overview - -Use PptxGenJS for slide authoring. Do not use `python-pptx` for deck generation unless the task is inspection-only; keep editable output in JavaScript and deliver both the `.pptx` and the source `.js`. - -Keep work in a task-local directory. Only copy final artifacts to the requested destination after rendering and validation pass. - -## Bundled Resources - -- `assets/pptxgenjs_helpers/`: Copy this folder into the deck workspace and import it locally instead of reimplementing helper logic. -- `scripts/render_slides.py`: Rasterize a `.pptx` or `.pdf` to per-slide PNGs. -- `scripts/slides_test.py`: Detect content that overflows the slide canvas. -- `scripts/create_montage.py`: Build a contact-sheet style montage of rendered slides. -- `scripts/detect_font.py`: Report missing or substituted fonts as LibreOffice resolves them. -- `scripts/ensure_raster_image.py`: Convert SVG/EMF/HEIC/PDF-like assets into PNGs for quick inspection. -- `references/pptxgenjs-helpers.md`: Load only when you need API details or dependency notes. - -## Workflow - -1. Inspect the request and determine whether you are creating a new deck, recreating an existing deck, or editing one. -2. Set the slide size up front. Default to 16:9 (`LAYOUT_WIDE`) unless the source material clearly uses another aspect ratio. -3. Copy `assets/pptxgenjs_helpers/` into the working directory and import the helpers from there. -4. Build the deck in JavaScript with an explicit theme font, stable spacing, and editable PowerPoint-native elements when practical. -5. Run the bundled scripts from this skill directory or copy the needed ones into the task workspace. Render the result with `render_slides.py`, review the PNGs, and fix layout issues before delivery. -6. Run `slides_test.py` for overflow checks when slide edges are tight or the deck is dense. -7. Deliver the `.pptx`, the authoring `.js`, and any generated assets that are required to rebuild the deck. - -## Authoring Rules - -- Set theme fonts explicitly. Do not rely on PowerPoint defaults if typography matters. -- Use `autoFontSize`, `calcTextBox`, and related helpers to size text boxes; do not use PptxGenJS `fit` or `autoFit`. -- Use bullet options, not literal `•` characters. -- Use `imageSizingCrop` or `imageSizingContain` instead of PptxGenJS built-in image sizing. -- Use `latexToSvgDataUri()` for equations and `codeToRuns()` for syntax-highlighted code blocks. -- Prefer native PowerPoint charts for simple bar/line/pie/histogram style visuals so reviewers can edit them later. -- For charts or diagrams that PptxGenJS cannot express well, render SVG externally and place the SVG in the slide. -- Include both `warnIfSlideHasOverlaps(slide, pptx)` and `warnIfSlideElementsOutOfBounds(slide, pptx)` in the submitted JavaScript whenever you generate or substantially edit slides. -- Fix all unintentional overlap and out-of-bounds warnings before delivering. If an overlap is intentional, leave a short code comment near the relevant element. - -## Recreate Or Edit Existing Slides - -- Render the source deck or reference PDF first so you can compare slide geometry visually. -- Match the original aspect ratio before rebuilding layout. -- Preserve editability where possible: text should stay text, and simple charts should stay native charts. -- If a reference slide uses raster artwork, use `ensure_raster_image.py` to generate debug PNGs from vector or odd image formats before placing them. - -## Validation Commands - -Examples below assume you copied the needed scripts into the working directory. If not, invoke the same script paths relative to this skill folder. - -```bash -# Render slides to PNGs for review -python3 scripts/render_slides.py deck.pptx --output_dir rendered - -# Build a montage for quick scanning -python3 scripts/create_montage.py --input_dir rendered --output_file montage.png - -# Check for overflow beyond the original slide canvas -python3 scripts/slides_test.py deck.pptx - -# Detect missing or substituted fonts -python3 scripts/detect_font.py deck.pptx --json -``` - -Load `references/pptxgenjs-helpers.md` if you need the helper API summary or dependency details. diff --git a/skills/.curated/slides/agents/openai.yaml b/skills/.curated/slides/agents/openai.yaml deleted file mode 100644 index 6f77e49..0000000 --- a/skills/.curated/slides/agents/openai.yaml +++ /dev/null @@ -1,6 +0,0 @@ -interface: - display_name: "Slides" - short_description: "Create and edit PPTX slide decks" - icon_small: "./assets/slides-small.svg" - icon_large: "./assets/slides.png" - default_prompt: "Use $slides to create or update this PPTX slide deck with PptxGenJS and validate the layout." diff --git a/skills/.curated/slides/assets/pptxgenjs_helpers/code.js b/skills/.curated/slides/assets/pptxgenjs_helpers/code.js deleted file mode 100644 index 951ce13..0000000 --- a/skills/.curated/slides/assets/pptxgenjs_helpers/code.js +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) OpenAI. All rights reserved. -"use strict"; - -const fs = require("fs"); -const Prism = require("prismjs"); -let THEME_MAP; - -function loadPrismLanguage(lang) { - const normalized = String(lang || "plaintext").toLowerCase(); - const known = new Set([ - "markup", - "html", - "xml", - "svg", - "mathml", - "css", - "clike", - "javascript", - "js", - "typescript", - "ts", - "python", - "py", - "bash", - "sh", - "json", - "yaml", - "yml", - ]); - const map = { - js: "javascript", - ts: "typescript", - py: "python", - sh: "bash", - yml: "yaml", - html: "markup", - xml: "markup", - }; - const id = map[normalized] || normalized; - if (!Prism.languages[id]) { - try { - require(`prismjs/components/prism-${id}`); - } catch (_e) {} - } - return Prism.languages[id] || Prism.languages.plain || {}; -} - -function buildThemeMap(themeCssModule = "prismjs/themes/prism-okaidia.css") { - try { - const css = fs.readFileSync(require.resolve(themeCssModule), "utf8"); - return Object.fromEntries( - [ - ...css.matchAll( - /\.token\.([\w-]+)[^{]*\{[^}]*color:\s*([^;\s]+)[^}]*\}/g - ), - ].map(([, t, c]) => [t, c.replace(/#|!important/g, "").trim()]) - ); - } catch (err) { - return { plain: "FFFFFF", comment: "999999" }; - } -} - -function getThemeMap() { - if (!THEME_MAP) THEME_MAP = buildThemeMap(); - return THEME_MAP; -} - -function run(text, type = "plain") { - const theme = getThemeMap(); - return { - text, - options: { - fontFace: "Consolas", - color: theme[type] || theme.plain || "FFFFFF", - fontSize: 14, - }, - }; -} - -function tokensToRuns(tokens) { - return tokens.flatMap((t) => - typeof t === "string" - ? [run(t)] - : Array.isArray(t.content) - ? tokensToRuns(t.content) - : [run(t.content, t.type)] - ); -} - -function codeToRuns(code, lang) { - const grammar = loadPrismLanguage(lang); - const lines = String(code || "").split("\n"); - const pad = lines.length.toString().length; - return lines.flatMap((line, i) => [ - run(`${(i + 1).toString().padStart(pad, " ")} `, "comment"), - ...tokensToRuns(Prism.tokenize(line, grammar)), - ...(i < lines.length - 1 ? [run("\n")] : []), - ]); -} - -module.exports = { - codeToRuns, - buildThemeMap, -}; diff --git a/skills/.curated/slides/assets/pptxgenjs_helpers/image.js b/skills/.curated/slides/assets/pptxgenjs_helpers/image.js deleted file mode 100644 index 16fdc16..0000000 --- a/skills/.curated/slides/assets/pptxgenjs_helpers/image.js +++ /dev/null @@ -1,333 +0,0 @@ -// Copyright (c) OpenAI. All rights reserved. -"use strict"; - -const fs = require("fs"); - -// Accept either a filesystem path, a data URI, raw SVG string, or a Buffer -// and normalize to a Buffer for type/size probing. -function readInputAsBuffer(source) { - if (!source) throw new Error("Image source is empty"); - if (Buffer.isBuffer(source)) return { buffer: source, type: "buffer" }; - if (typeof source === "string") { - // data URI (we primarily emit base64 data URIs for SVG via helpers) - if (source.startsWith("data:")) { - const type = "dataUri"; - const comma = source.indexOf(","); - const payload = comma !== -1 ? source.slice(comma + 1) : source; - // Our helpers use base64; if not, try URI decode then treat as raw text - try { - return { buffer: Buffer.from(payload, "base64"), type: type }; - } catch (_e) { - try { - return { - buffer: Buffer.from(decodeURIComponent(payload), "utf8"), - type: type, - }; - } catch (_e2) { - return { buffer: Buffer.from(payload, "utf8"), type: type }; - } - } - } - // Raw inline SVG string - if (source.includes("= 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 deleted file mode 100644 index 07805a2..0000000 --- a/skills/.curated/slides/assets/pptxgenjs_helpers/index.js +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) OpenAI. All rights reserved. -"use strict"; - -const VERSION = "1.2.0"; - -const text = require("./text"); -const image = require("./image"); -const svg = require("./svg"); -const latex = require("./latex"); -const code = require("./code"); -const layout = require("./layout"); -const layoutBuilders = require("./layout_builders"); -const util = require("./util"); - -module.exports = { - VERSION, - // text layout - ...text, - // images - ...image, - // svg helpers - ...svg, - // LaTeX -> SVG - ...latex, - // code block -> pptx text runs - ...code, - // slide layout analyzers - ...layout, - // slide layout builders - ...layoutBuilders, - // text layout helpers and utilities - ...util, -}; diff --git a/skills/.curated/slides/assets/pptxgenjs_helpers/latex.js b/skills/.curated/slides/assets/pptxgenjs_helpers/latex.js deleted file mode 100644 index 03c1e22..0000000 --- a/skills/.curated/slides/assets/pptxgenjs_helpers/latex.js +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) OpenAI. All rights reserved. -"use strict"; - -let _mathjax; -let _adaptor; -let _doc; - -function ensureMathJax() { - if (_mathjax && _adaptor && _doc) return; - try { - const { mathjax } = require("mathjax-full/js/mathjax.js"); - const { TeX } = require("mathjax-full/js/input/tex.js"); - const { SVG } = require("mathjax-full/js/output/svg.js"); - const { liteAdaptor } = require("mathjax-full/js/adaptors/liteAdaptor.js"); - const { RegisterHTMLHandler } = require("mathjax-full/js/handlers/html.js"); - const { AllPackages } = require("mathjax-full/js/input/tex/AllPackages.js"); - - _adaptor = liteAdaptor(); - RegisterHTMLHandler(_adaptor); - const tex = new TeX({ packages: AllPackages }); - const out = new SVG({ fontCache: "local" }); - _doc = mathjax.document("", { InputJax: tex, OutputJax: out }); - _mathjax = mathjax; - } catch (err) { - throw new Error( - "mathjax-full is not installed. Run `npm i mathjax-full` or avoid latexToSvgDataUri()." - ); - } -} - -function latexToSvgDataUri(latex, display = true) { - ensureMathJax(); - const html = _adaptor.outerHTML(_doc.convert(latex, { display })); - const a = html.indexOf(""); - 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 deleted file mode 100644 index 2ce70f3..0000000 --- a/skills/.curated/slides/assets/pptxgenjs_helpers/layout.js +++ /dev/null @@ -1,643 +0,0 @@ -// Copyright (c) OpenAI. All rights reserved. -"use strict"; - -function inferElementType(obj) { - if (!obj) return "unknown"; - const data = obj.data || obj.options || {}; - // Distinguish lines explicitly via type only. Many objects have a 'line' style; don't misclassify those. - if (obj.type === "line") return "line"; - if (obj.type && typeof obj.type === "string") return obj.type; - if (obj.text || typeof data.text === "string") return "text"; - if (data.path || obj.image) return "image"; - if (data.chartType) return "chart"; - if (data.shape || data.line) return "shape"; - if (data.mediaType) return "media"; - if (data.table || Array.isArray(data.rows)) return "table"; - if (data.smartArt) return "smartart"; - return "unknown"; -} - -const TEXT_OVERLAP_ERROR_THRESHOLD = 0.1; -const RECTIFY_DIRECTION_EQUALITY_TOLERANCE = 0.15; - -function warnIfSlideHasOverlaps(slide, pptx, options = {}) { - if (!slide || !Array.isArray(slide._slideObjects)) { - console.warn("Invalid slide object passed to warnIfSlideOverlaps()"); - return; - } - const opts = { - // By default, containment cases are very common (e.g., full-slide backgrounds) - // and usually not actionable. Mute them unless explicitly requested. - muteContainment: - options.muteContainment !== undefined ? options.muteContainment : true, - // Do NOT ignore lines or decorative shapes by default; users want true overlaps. - ignoreLines: - options.ignoreLines !== undefined ? options.ignoreLines : false, - ignoreDecorativeShapes: - options.ignoreDecorativeShapes !== undefined - ? options.ignoreDecorativeShapes - : false, - }; - const slideIndex = - pptx && Array.isArray(pptx._slides) ? pptx._slides.indexOf(slide) : -1; - const slideLabel = - slideIndex >= 0 ? `Slide ${slideIndex + 1}` : "(Unknown slide index)"; - const formatElement = (el) => { - const cx = (el.x + el.w / 2).toFixed(3); - const cy = (el.y + el.h / 2).toFixed(3); - return `element ${el.index} (${el.type}, center_x=${cx}, center_y=${cy})`; - }; - const elements = slide._slideObjects.map((obj, i) => { - const { - x = 0, - y = 0, - w = 0, - h = 0, - fill, - line, - } = obj.data || obj.options || {}; - const type = inferElementType(obj); - const isDecorative = (() => { - if (!opts.ignoreDecorativeShapes) return false; - // Border rectangles used as frames: transparent fill (or fully transparent) with a stroke - const transparency = - typeof fill?.transparency === "number" ? fill.transparency : null; - const hasOnlyBorder = !!line && (!fill || transparency !== null); - const fullyTransparent = transparency !== null && transparency >= 99; - return type === "shape" && hasOnlyBorder && fullyTransparent; - })(); - const ignorable = (opts.ignoreLines && type === "line") || isDecorative; - return { index: i, type, x, y, w, h, ignorable }; - }); - let overlapCount = 0; - let containmentCount = 0; - for (let i = 0; i < elements.length; i++) { - const a = elements[i]; - if (a.ignorable) continue; - for (let j = i + 1; j < elements.length; j++) { - const b = elements[j]; - if (b.ignorable) continue; - const comparison = compareElementPosition(slide, a.index, b.index); - if (comparison.relation === "overlapping") { - // Special-case: diagonal line's bounding box overlapping a rectangle is often a false positive. - const EPS = 1e-6; - const getBounds = (e) => ({ - x: e.x, - y: e.y, - x2: e.x + e.w, - y2: e.y + e.h, - }); - const lineRectFalsePositive = (() => { - const oneIsLine = (a.type === "line") ^ (b.type === "line"); - if (!oneIsLine) return false; - const line = a.type === "line" ? a : b; - const rect = a.type === "line" ? b : a; - // If line is diagonal, verify actual segment intersects rect; if not, ignore. - const isDiagonal = line.w > EPS && line.h > EPS; - const lineSeg = { - x1: line.x, - y1: line.y, - x2: line.x + line.w, - y2: line.y + line.h, - }; - const rectB = getBounds(rect); - const pointInRect = (px, py, rb) => - px >= rb.x - EPS && - px <= rb.x2 + EPS && - py >= rb.y - EPS && - py <= rb.y2 + EPS; - const segsIntersect = (p1, p2, q1, q2) => { - const cross = (ax, ay, bx, by) => ax * by - ay * bx; - const d1x = p2.x - p1.x, - d1y = p2.y - p1.y; - const d2x = q2.x - q1.x, - d2y = q2.y - q1.y; - const denom = cross(d1x, d1y, d2x, d2y); - if (Math.abs(denom) < EPS) { - // Parallel: check colinearity and overlapping projections - const crossCol = cross(q1.x - p1.x, q1.y - p1.y, d1x, d1y); - if (Math.abs(crossCol) > EPS) return false; - const proj = (a, b, c) => - Math.min(Math.max(a, b), Math.max(Math.min(a, b), c)); - const overlapX = !( - Math.max(p1.x, p2.x) < Math.min(q1.x, q2.x) - EPS || - Math.max(q1.x, q2.x) < Math.min(p1.x, p2.x) - EPS - ); - const overlapY = !( - Math.max(p1.y, p2.y) < Math.min(q1.y, q2.y) - EPS || - Math.max(q1.y, q2.y) < Math.min(p1.y, p2.y) - EPS - ); - return overlapX && overlapY; - } - const t = cross(q1.x - p1.x, q1.y - p1.y, d2x, d2y) / denom; - const u = cross(q1.x - p1.x, q1.y - p1.y, d1x, d1y) / denom; - return t >= -EPS && t <= 1 + EPS && u >= -EPS && u <= 1 + EPS; - }; - const intersectsRect = (seg, rb) => { - if ( - pointInRect(seg.x1, seg.y1, rb) || - pointInRect(seg.x2, seg.y2, rb) - ) - return true; - const r1 = { x: rb.x, y: rb.y }, - r2 = { x: rb.x2, y: rb.y }, - r3 = { x: rb.x2, y: rb.y2 }, - r4 = { x: rb.x, y: rb.y2 }; - const p1 = { x: seg.x1, y: seg.y1 }, - p2 = { x: seg.x2, y: seg.y2 }; - return ( - segsIntersect(p1, p2, r1, r2) || - segsIntersect(p1, p2, r2, r3) || - segsIntersect(p1, p2, r3, r4) || - segsIntersect(p1, p2, r4, r1) - ); - }; - return isDiagonal && !intersectsRect(lineSeg, rectB); - })(); - if (!lineRectFalsePositive) { - overlapCount++; - - const severeTextOverlap = (() => { - if (!comparison.intersection) return false; - const exceedsThreshold = (element) => - element.type === "text" && - comparison.intersection.w >= TEXT_OVERLAP_ERROR_THRESHOLD && - comparison.intersection.h >= TEXT_OVERLAP_ERROR_THRESHOLD; - return exceedsThreshold(a) || exceedsThreshold(b); - })(); - if (severeTextOverlap) { - const overlapW = comparison.intersection.w; - const overlapH = comparison.intersection.h; - let rectificationSuggestion = ""; - if (overlapW > EPS && overlapH > EPS) { - const maxOverlap = Math.max(overlapW, overlapH); - const diffRatio = Math.abs(overlapW - overlapH) / maxOverlap; - const directions = []; - // Attempt to determine the primary direction of the overlap. This is the direction - // in which the overlap is smaller (and so requires the smallest adjustment to rectify). - if (diffRatio <= RECTIFY_DIRECTION_EQUALITY_TOLERANCE) { - directions.push("horizontally", "vertically"); - } else if (overlapW < overlapH) { - directions.push("horizontally"); - } else { - directions.push("vertically"); - } - rectificationSuggestion = `Suggestion: reposition elements ${directions.join( - " and " - )}.`; - } - - console.error( - `❌ ${slideLabel}: Severe text overlap detected between ${formatElement( - a - )} and ${formatElement( - b - )} (overlap_horizontal=${comparison.intersection.w.toFixed( - 3 - )}, overlap_vertical=${comparison.intersection.h.toFixed( - 3 - )}). THIS MUST BE FIXED. ${rectificationSuggestion}` - ); - } else { - console.warn( - `⚠️ ${slideLabel}: Overlap detected between ${formatElement( - a - )} and ${formatElement(b)}.` - ); - } - } - } else if (comparison.relation === "contained") { - if (!opts.muteContainment) { - containmentCount++; - const container = elements[comparison.containerIndex]; - const contained = elements[comparison.containedIndex]; - console.warn( - `⚠️ ${slideLabel}: ${formatElement( - contained - )} is fully contained within ${formatElement(container)}` - ); - } else { - // Still count internally when muted? We keep for summary only when un-muted - } - } - } - } - if (!(overlapCount === 0 && (!containmentCount || opts.muteContainment))) { - const issues = []; - if (overlapCount > 0) issues.push(`${overlapCount} overlapping pair(s)`); - if (!opts.muteContainment && containmentCount > 0) - issues.push(`${containmentCount} containment case(s)`); - console.log(`⚠️ ${slideLabel}: Found ${issues.join(" and ")}.`); - } -} - -function compareElementPosition(slide, firstIndex, secondIndex) { - if (!slide || !Array.isArray(slide._slideObjects)) { - throw new Error("Invalid slide object passed to compareElementPosition()"); - } - if ( - typeof firstIndex !== "number" || - typeof secondIndex !== "number" || - !Number.isInteger(firstIndex) || - !Number.isInteger(secondIndex) - ) { - throw new Error("Element indices must be integer values."); - } - const elements = slide._slideObjects; - if ( - firstIndex < 0 || - firstIndex >= elements.length || - secondIndex < 0 || - secondIndex >= elements.length - ) { - throw new Error( - "Element index out of bounds for compareElementPosition()." - ); - } - const EPS = 1e-4; - const getBounds = (obj) => { - const source = obj?.data || obj?.options || {}; - let x = typeof source.x === "number" ? source.x : 0; - let y = typeof source.y === "number" ? source.y : 0; - let w = typeof source.w === "number" ? source.w : 0; - let h = typeof source.h === "number" ? source.h : 0; - if (source.sizing && source.sizing.type === "crop") { - if (typeof source.sizing.w === "number") w = source.sizing.w; - if (typeof source.sizing.h === "number") h = source.sizing.h; - } - return { x, y, w, h, x2: x + w, y2: y + h }; - }; - const boundsA = getBounds(elements[firstIndex]); - const boundsB = getBounds(elements[secondIndex]); - const separated = - boundsA.x2 < boundsB.x - EPS || - boundsB.x2 < boundsA.x - EPS || - boundsA.y2 < boundsB.y - EPS || - boundsB.y2 < boundsA.y - EPS; - if (separated) { - return { - relation: "disjoint", - containerIndex: null, - containedIndex: null, - aBounds: boundsA, - bBounds: boundsB, - intersection: null, - }; - } - const aContainsB = - boundsA.x <= boundsB.x + EPS && - boundsA.y <= boundsB.y + EPS && - boundsA.x2 >= boundsB.x2 - EPS && - boundsA.y2 >= boundsB.y2 - EPS; - const bContainsA = - boundsB.x <= boundsA.x + EPS && - boundsB.y <= boundsA.y + EPS && - boundsB.x2 >= boundsA.x2 - EPS && - boundsB.y2 >= boundsA.y2 - EPS; - const ix1 = Math.max(boundsA.x, boundsB.x); - const iy1 = Math.max(boundsA.y, boundsB.y); - const ix2 = Math.min(boundsA.x2, boundsB.x2); - const iy2 = Math.min(boundsA.y2, boundsB.y2); - const intersectionWidth = Math.max(0, ix2 - ix1); - const intersectionHeight = Math.max(0, iy2 - iy1); - const intersection = - intersectionWidth > EPS && intersectionHeight > EPS - ? { x: ix1, y: iy1, w: intersectionWidth, h: intersectionHeight } - : null; - if (aContainsB && !bContainsA) { - return { - relation: "contained", - containerIndex: firstIndex, - containedIndex: secondIndex, - aBounds: boundsA, - bBounds: boundsB, - intersection, - }; - } - if (bContainsA && !aContainsB) { - return { - relation: "contained", - containerIndex: secondIndex, - containedIndex: firstIndex, - aBounds: boundsA, - bBounds: boundsB, - intersection, - }; - } - if (intersection) { - return { - relation: "overlapping", - containerIndex: null, - containedIndex: null, - aBounds: boundsA, - bBounds: boundsB, - intersection, - }; - } - return { - relation: "touching", - containerIndex: null, - containedIndex: null, - aBounds: boundsA, - bBounds: boundsB, - intersection: null, - }; -} - -const VALID_ALIGNMENTS = new Set([ - "left", - "right", - "top", - "bottom", - "verticallyCenter", - "horizontallyCenter", -]); - -const getElementBounds = (obj) => { - const source = obj?.data || obj?.options || {}; - let x = typeof source.x === "number" ? source.x : 0; - let y = typeof source.y === "number" ? source.y : 0; - let w = typeof source.w === "number" ? source.w : 0; - let h = typeof source.h === "number" ? source.h : 0; - // If an image is placed with crop sizing, pptxgenjs stores a larger virtual image w/h - // and a viewport in source.sizing.{w,h}. For visual overlap purposes, use the viewport. - if (source.sizing && source.sizing.type === "crop") { - if (typeof source.sizing.w === "number") w = source.sizing.w; - if (typeof source.sizing.h === "number") h = source.sizing.h; - } - return { x, y, w, h, x2: x + w, y2: y + h }; -}; - -const setElementPosition = (obj, coords) => { - const ensureTarget = (targetObj) => { - if (!targetObj || typeof targetObj !== "object") return null; - return targetObj; - }; - const targets = []; - const dataTarget = ensureTarget(obj.data); - if (dataTarget) targets.push(dataTarget); - const optionsTarget = - obj.options && obj.options !== obj.data ? ensureTarget(obj.options) : null; - if (optionsTarget) targets.push(optionsTarget); - if (targets.length === 0) { - obj.data = obj.data && typeof obj.data === "object" ? obj.data : {}; - targets.push(obj.data); - } - targets.forEach((target) => { - if (coords.x !== undefined) target.x = coords.x; - if (coords.y !== undefined) target.y = coords.y; - }); -}; - -const dimensionKeyPairs = [ - ["width", "height"], - ["w", "h"], - ["cx", "cy"], - ["slideWidth", "slideHeight"], - ["slideWidthInches", "slideHeightInches"], - ["widthInches", "heightInches"], -]; - -const toNumber = (value) => { - if (typeof value === "number" && Number.isFinite(value)) return value; - if (typeof value === "string") { - const parsed = parseFloat(value); - return Number.isFinite(parsed) ? parsed : null; - } - return null; -}; - -const readDimensionsFromObject = (candidate, seen = new Set()) => { - if (!candidate || typeof candidate !== "object") return null; - if (seen.has(candidate)) return null; - seen.add(candidate); - for (const [wKey, hKey] of dimensionKeyPairs) { - const width = toNumber(candidate[wKey]); - const height = toNumber(candidate[hKey]); - if (width !== null && height !== null && width > 0 && height > 0) { - return { width, height }; - } - } - const nestedKeys = ["size", "slideSize", "layout", "slideLayout"]; - for (const key of nestedKeys) { - const nested = readDimensionsFromObject(candidate[key], seen); - if (nested) return nested; - } - return null; -}; - -const getSlideDimensions = (slide, pptx) => { - const candidates = [ - slide?._presLayout, - slide?._slideLayout, - slide?._pres?.layout, - slide?._parent?.layout, - slide?._layout, - pptx?._presLayout, - pptx?._layout, - pptx?.layout, - pptx?.presLayout, - ]; - for (const candidate of candidates) { - const dims = readDimensionsFromObject(candidate); - if (dims) { - // Some internals are in EMUs; convert if values look too large for inches - const EMU_PER_IN = 914400; - const looksEmu = dims.width > 1000 || dims.height > 1000; - if (looksEmu) { - return { - width: dims.width / EMU_PER_IN, - height: dims.height / EMU_PER_IN, - source: "emu_converted", - }; - } - return { ...dims, source: "detected" }; - } - } - throw new Error( - "getSlideDimensions(): Unable to determine slide dimensions from pptxgenjs internals." - ); -}; - -function alignSlideElements(slide, indices, alignment) { - if (!slide || !Array.isArray(slide._slideObjects)) { - throw new Error("Invalid slide object passed to alignSlideElements()"); - } - if (!Array.isArray(indices) || indices.length === 0) { - throw new Error("indices must be a non-empty array."); - } - if (!VALID_ALIGNMENTS.has(alignment)) { - throw new Error(`Unsupported alignment option: ${alignment}`); - } - const uniqueIndices = [...new Set(indices)]; - const elements = slide._slideObjects; - const selected = uniqueIndices.map((idx) => { - if (typeof idx !== "number" || !Number.isInteger(idx)) { - throw new Error("Element indices must be integers."); - } - if (idx < 0 || idx >= elements.length) { - throw new Error("Element index out of bounds for alignSlideElements()."); - } - const obj = elements[idx]; - const bounds = getElementBounds(obj); - return { index: idx, obj, bounds }; - }); - if (selected.length < 2) return; - const minX = Math.min(...selected.map((item) => item.bounds.x)); - const maxX2 = Math.max(...selected.map((item) => item.bounds.x2)); - const minY = Math.min(...selected.map((item) => item.bounds.y)); - const maxY2 = Math.max(...selected.map((item) => item.bounds.y2)); - const centerX = (minX + maxX2) / 2; - const centerY = (minY + maxY2) / 2; - selected.forEach(({ obj, bounds }) => { - const { w, h } = bounds; - switch (alignment) { - case "left": - setElementPosition(obj, { x: minX }); - break; - case "right": - setElementPosition(obj, { x: maxX2 - w }); - break; - case "top": - setElementPosition(obj, { y: minY }); - break; - case "bottom": - setElementPosition(obj, { y: maxY2 - h }); - break; - case "horizontallyCenter": - setElementPosition(obj, { x: centerX - w / 2 }); - break; - case "verticallyCenter": - setElementPosition(obj, { y: centerY - h / 2 }); - break; - default: - throw new Error(`Unhandled alignment option: ${alignment}`); - } - }); -} - -function distributeSlideElements(slide, indices, direction) { - if (!slide || !Array.isArray(slide._slideObjects)) { - throw new Error("Invalid slide object passed to distributeSlideElements()"); - } - if (!Array.isArray(indices) || indices.length === 0) { - throw new Error("indices must be a non-empty array."); - } - if (direction !== "horizontal" && direction !== "vertical") { - throw new Error(`Unsupported distribution direction: ${direction}`); - } - const uniqueIndices = [...new Set(indices)]; - if (uniqueIndices.length < 2) return; - const elements = slide._slideObjects; - const selected = uniqueIndices.map((idx) => { - if (typeof idx !== "number" || !Number.isInteger(idx)) { - throw new Error("Element indices must be integers."); - } - if (idx < 0 || idx >= elements.length) { - throw new Error( - "Element index out of bounds for distributeSlideElements()." - ); - } - const obj = elements[idx]; - const bounds = getElementBounds(obj); - return { index: idx, obj, bounds }; - }); - const axisStartKey = direction === "horizontal" ? "x" : "y"; - const axisEndKey = direction === "horizontal" ? "x2" : "y2"; - const sizeKey = direction === "horizontal" ? "w" : "h"; - selected.sort((a, b) => { - const delta = a.bounds[axisStartKey] - b.bounds[axisStartKey]; - return Math.abs(delta) > 1e-6 ? delta : a.index - b.index; - }); - const minCoord = Math.min( - ...selected.map((item) => item.bounds[axisStartKey]) - ); - const maxCoord = Math.max(...selected.map((item) => item.bounds[axisEndKey])); - const totalSpan = maxCoord - minCoord; - const gaps = selected.length - 1; - const totalSize = selected.reduce( - (sum, item) => sum + item.bounds[sizeKey], - 0 - ); - const gapSize = gaps > 0 ? (totalSpan - totalSize) / gaps : 0; - let cursor = minCoord; - selected.forEach(({ obj, bounds }) => { - if (direction === "horizontal") { - setElementPosition(obj, { x: cursor }); - cursor += bounds.w + gapSize; - } else { - setElementPosition(obj, { y: cursor }); - cursor += bounds.h + gapSize; - } - }); -} - -function warnIfSlideElementsOutOfBounds(slide, pptx) { - if (!slide || !Array.isArray(slide._slideObjects)) { - console.warn( - "Invalid slide object passed to warnIfSlideElementsOutOfBounds()" - ); - return; - } - const { - width: slideWidth, - height: slideHeight, - source, - } = getSlideDimensions(slide, pptx); - const slideIndex = - pptx && Array.isArray(pptx._slides) ? pptx._slides.indexOf(slide) : -1; - const slideLabel = - slideIndex >= 0 ? `Slide ${slideIndex + 1}` : "(Unknown slide index)"; - if (source === "default") { - console.warn( - `⚠️ ${slideLabel}: Unable to determine slide dimensions from pptxgenjs internals; assuming width=${slideWidth}, height=${slideHeight}.` - ); - } - const EPS = 1e-4; - let outOfBoundsCount = 0; - const formatElement = (idx, type, bounds) => { - const cx = (bounds.x + bounds.w / 2).toFixed(3); - const cy = (bounds.y + bounds.h / 2).toFixed(3); - return `Element ${idx} (${type}, center_x=${cx}, center_y=${cy})`; - }; - slide._slideObjects.forEach((obj, index) => { - const bounds = getElementBounds(obj); - const type = inferElementType(obj); - const violations = []; - if (bounds.x < -EPS) violations.push(`left=${bounds.x.toFixed(3)} < 0`); - if (bounds.y < -EPS) violations.push(`top=${bounds.y.toFixed(3)} < 0`); - if (bounds.x2 > slideWidth + EPS) - violations.push( - `right=${bounds.x2.toFixed(3)} > width=${slideWidth.toFixed(3)}` - ); - if (bounds.y2 > slideHeight + EPS) - violations.push( - `bottom=${bounds.y2.toFixed(3)} > height=${slideHeight.toFixed(3)}` - ); - if (violations.length > 0) { - outOfBoundsCount++; - console.warn( - `⚠️ ${slideLabel}: ${formatElement( - index, - type, - bounds - )} exceeds slide bounds (${violations.join(", ")}).` - ); - } - }); - if (outOfBoundsCount > 0) { - console.log( - `⚠️ ${slideLabel}: Found ${outOfBoundsCount} element(s) extending beyond the slide bounds.` - ); - } -} - -module.exports = { - inferElementType, - compareElementPosition, - warnIfSlideHasOverlaps, - alignSlideElements, - distributeSlideElements, - warnIfSlideElementsOutOfBounds, - getSlideDimensions, -}; diff --git a/skills/.curated/slides/assets/pptxgenjs_helpers/layout_builders.js b/skills/.curated/slides/assets/pptxgenjs_helpers/layout_builders.js deleted file mode 100644 index f26990b..0000000 --- a/skills/.curated/slides/assets/pptxgenjs_helpers/layout_builders.js +++ /dev/null @@ -1,358 +0,0 @@ -// Copyright (c) OpenAI. All rights reserved. -"use strict"; - -const { calcTextBox, autoFontSize } = require("./text"); -const { imageSizingCrop, imageSizingContain } = require("./image"); -const { getSlideDimensions } = require("./layout"); - -module.exports = { - addImageTextCard, - addCardRow, - addThreeLevelTree, -}; - -function addImageTextCard(slide, opts = {}) { - const x = toNumberOr(opts.x, 0); - const y = toNumberOr(opts.y, 0); - const w = toNumberOr(opts.width, 3.0); - const gap = toNumberOr(opts.gap, 0.15); - const image = opts.image || {}; - const text = opts.text || ""; - const textBox = opts.textBox || {}; - - const boxH = toNumberOr(image.boxHeight, 2.2); - const sizing = (image.sizing || "crop").toLowerCase(); - let imgPlacement; - if (image.path || image.data) { - const base = image.path ? { path: image.path } : { data: image.data }; - if (sizing === "contain") { - imgPlacement = imageSizingContain( - image.path || image.data, - x, - y, - w, - boxH - ); - slide.addImage({ ...base, ...imgPlacement }); - } else { - const c = image.crop || {}; - imgPlacement = imageSizingCrop( - image.path || image.data, - x, - y, - w, - boxH, - c.cx, - c.cy, - c.cw, - c.ch - ); - slide.addImage({ ...base, ...imgPlacement }); - } - } - - const textY = y + boxH + gap; - const fontSize = toNumberOr(textBox.fontSize, 14); - const fontFaceRaw = textBox.fontFace; - const fontFace = - typeof fontFaceRaw === "string" && fontFaceRaw.trim().length > 0 - ? fontFaceRaw.trim() - : null; - if (!fontFace) { - throw new Error( - "addImageTextCard(): textBox.fontFace is required for text measurement." - ); - } - let hText; - let textOptions; - - if (textBox.h != null && Number.isFinite(toNumberOr(textBox.h, NaN))) { - // Layout-first: caller fixed the box height, so adjust font size to fit via autoFontSize. - const fixedH = toNumberOr(textBox.h, 0); - const baseOpts = { - x, - y: textY, - w, - h: fixedH, - mode: textBox.mode || "auto", - fontSize, - minFontSize: textBox.minFontSize, - maxFontSize: textBox.maxFontSize, - margin: textBox.margin, - paraSpaceAfter: textBox.paraSpaceAfter, - }; - const autoOpts = autoFontSize(text, fontFace, baseOpts); - hText = fixedH; - textOptions = { - ...autoOpts, - fontFace, - color: textBox.color, - align: textBox.align, - valign: textBox.valign || "top", - fill: opts.background, - }; - } else { - // Content-first: fixed font size, let calcTextBox derive the required height. - const layout = calcTextBox(fontSize, { - text, - w, - fontFace, - margin: textBox.margin, - paraSpaceAfter: textBox.paraSpaceAfter, - }); - hText = layout.h; - textOptions = { - x, - y: textY, - w, - h: hText, - fontFace, - fontSize, - color: textBox.color, - align: textBox.align, - valign: textBox.valign || "top", - paraSpaceAfter: textBox.paraSpaceAfter, - margin: textBox.margin, - fill: opts.background, - }; - } - - slide.addText(text, textOptions); - - return { - x, - y, - w, - image: { - x: imgPlacement?.x ?? x, - y, - w: imgPlacement?.w ?? w, - h: imgPlacement?.h ?? boxH, - }, - text: { x, y: textY, w, h: hText }, - }; -} - -function addCardRow(slide, region, cards = [], options = {}) { - const rx = toNumberOr(region.x, 0.4); - const ry = toNumberOr(region.y, 1.6); - const slideWidth = getSlideDimensions(slide).width; - const rw = toNumberOr(region.w, slideWidth - rx * 2); - const gap = toNumberOr(options.gap, 0.25); - const count = cards.length; - if (count === 0) return []; - - let cardW; - if (options.widthStrategy === "fixed") { - cardW = toNumberOr( - options.cardWidth, - rw / count - (gap * (count - 1)) / count - ); - } else { - cardW = (rw - gap * (count - 1)) / count; - } - - const totalWidth = cardW * count + gap * (count - 1); - const align = options.align || "left"; - const ox = - align === "center" - ? (rw - totalWidth) / 2 - : align === "right" - ? rw - totalWidth - : 0; - - const placements = []; - for (let i = 0; i < count; i++) { - const x = rx + ox + i * (cardW + gap); - placements.push( - addImageTextCard(slide, { ...cards[i], x, y: ry, width: cardW }) - ); - } - return placements; -} - -function addThreeLevelTree(slide, opts = {}) { - const slideWidth = getSlideDimensions(slide).width; - const cx = toNumberOr(opts.centerX, slideWidth / 2); - const topY = toNumberOr(opts.topY, 1.6); - - const rootW = toNumberOr(opts.root?.w, 3.3333333); - const rootH = toNumberOr(opts.root?.h, 0.93333333); - const rootX = cx - rootW / 2; - const rootFontFaceRaw = opts.root?.fontFace; - const rootFontFace = - typeof rootFontFaceRaw === "string" && rootFontFaceRaw.trim().length > 0 - ? rootFontFaceRaw.trim() - : null; - if (!rootFontFace) { - throw new Error( - "addThreeLevelTree(): opts.root.fontFace is required for text measurement." - ); - } - const rootFontSize = toNumberOr(opts.root?.fontSize, 16); - const rootText = opts.root?.text || ""; - const rootTextOpts = autoFontSize(rootText, rootFontFace, { - x: rootX, - y: topY, - w: rootW, - h: rootH, - mode: opts.root?.mode || "shrink", - fontSize: rootFontSize, - minFontSize: opts.root?.minFontSize, - maxFontSize: opts.root?.maxFontSize, - }); - slide.addText(rootText, { - ...rootTextOpts, - align: "center", - valign: "mid", - fontFace: rootFontFace, - color: opts.root?.color || "FFFFFF", - fill: { color: opts.root?.fill || "0B0F1A" }, - line: { color: opts.root?.line || opts.root?.fill || "0B0F1A" }, - }); - - const midLabels = Array.isArray(opts.mid?.labels) ? opts.mid.labels : []; - const midFontFaceRaw = opts.mid?.fontFace; - const midFontFace = - typeof midFontFaceRaw === "string" && midFontFaceRaw.trim().length > 0 - ? midFontFaceRaw.trim() - : null; - if (!midFontFace) { - throw new Error( - "addThreeLevelTree(): opts.mid.fontFace is required for text measurement." - ); - } - let midW = toNumberOr(opts.mid?.w, NaN); - const midH = toNumberOr(opts.mid?.h, rootH); - const midY = toNumberOr(opts.mid?.y, topY + rootH + 1.2); - const requestedSpacing = toNumberOr(opts.mid?.spacing, NaN); // center-to-center distance if provided - const leftRightMargin = toNumberOr(opts.mid?.marginX, 0.6); - const availableRowWidth = slideWidth - leftRightMargin * 2; - const countMid = midLabels.length; - const minGap = 0.4; - if (!Number.isFinite(midW) && Number.isFinite(requestedSpacing)) { - // Derive midW from spacing and available width - const totalSpan = requestedSpacing * (countMid - 1) + 0; // span between first and last centers - const maxW = Math.min(rootW, (availableRowWidth - totalSpan) / countMid); - midW = Math.max(0.8, maxW); - } - if (!Number.isFinite(midW)) { - // Fit equally within available width with minimum gaps - midW = Math.max( - 0.8, - (availableRowWidth - minGap * (countMid - 1)) / countMid - ); - } - // Compute gap to center-group horizontally without overlap - let gap = Math.max( - minGap, - (availableRowWidth - midW * countMid) / Math.max(1, countMid - 1) - ); - const totalWidth = midW * countMid + gap * (countMid - 1); - const startLeft = cx - totalWidth / 2; - for (let i = 0; i < midLabels.length; i++) { - const x = startLeft + i * (midW + gap); - const midText = midLabels[i] || ""; - const midFontSize = toNumberOr(opts.mid?.fontSize, 16); - const midTextOpts = autoFontSize(midText, midFontFace, { - x, - y: midY, - w: midW, - h: midH, - mode: opts.mid?.mode || "shrink", - fontSize: midFontSize, - minFontSize: opts.mid?.minFontSize, - maxFontSize: opts.mid?.maxFontSize, - }); - slide.addText(midText, { - ...midTextOpts, - align: "center", - valign: "mid", - fontFace: midFontFace, - color: opts.mid?.color || "000000", - fill: { color: opts.mid?.fill || "A0BEC2" }, - line: { color: opts.mid?.line || opts.mid?.fill || "A0BEC2" }, - }); - addConnector(slide, cx, topY + rootH, x + midW / 2, midY, opts.line); - } - - const leavesPerMid = Array.isArray(opts.leaf?.labelsPerMid) - ? opts.leaf.labelsPerMid - : []; - const leafFontFaceRaw = opts.leaf?.fontFace; - const leafFontFace = - typeof leafFontFaceRaw === "string" && leafFontFaceRaw.trim().length > 0 - ? leafFontFaceRaw.trim() - : null; - if (!leafFontFace) { - throw new Error( - "addThreeLevelTree(): opts.leaf.fontFace is required for text measurement." - ); - } - const leafW = toNumberOr(opts.leaf?.w, 1.05); - const leafH = toNumberOr(opts.leaf?.h, 1.0666667); - const leafY = toNumberOr(opts.leaf?.y, midY + midH + 1.0); - const minLeafGap = 0.2; - for (let i = 0; i < midLabels.length; i++) { - const xBase = startLeft + i * (midW + gap); - const childLabels = Array.isArray(leavesPerMid[i]) ? leavesPerMid[i] : []; - const childCount = childLabels.length || 3; - // Compute per-mid gap to fit children within midW without overlap - const leafGap = Math.max( - minLeafGap, - (midW - childCount * leafW) / Math.max(1, childCount - 1) - ); - const totalWidth = childCount * leafW + (childCount - 1) * leafGap; - const leftX = xBase + (midW - totalWidth) / 2; - for (let j = 0; j < childCount; j++) { - const x = leftX + j * (leafW + leafGap); - const leafText = childLabels[j] || ""; - const leafFontSize = toNumberOr(opts.leaf?.fontSize, 16); - const leafTextOpts = autoFontSize(leafText, leafFontFace, { - x, - y: leafY, - w: leafW, - h: leafH, - mode: opts.leaf?.mode || "shrink", - fontSize: leafFontSize, - minFontSize: opts.leaf?.minFontSize, - maxFontSize: opts.leaf?.maxFontSize, - }); - slide.addText(leafText, { - ...leafTextOpts, - align: "center", - valign: "mid", - fontFace: leafFontFace, - color: opts.leaf?.color || "000000", - fill: { color: opts.leaf?.fill || "A6C1EE" }, - line: { color: opts.leaf?.line || opts.leaf?.fill || "A6C1EE" }, - }); - addConnector( - slide, - xBase + midW / 2, - midY + midH, - x + leafW / 2, - leafY, - opts.line - ); - } - } -} - -function addConnector(slide, x1, y1, x2, y2, line = {}) { - const x = Math.min(x1, x2); - const y = Math.min(y1, y2); - slide.addShape("line", { - x, - y, - w: Math.abs(x2 - x1), - h: Math.abs(y2 - y1), - line: { color: line.color || "000000", pt: line.pt || 1 }, - flipH: x2 < x1 ? true : undefined, - }); -} - -function toNumberOr(v, fallback) { - const n = typeof v === "string" ? parseFloat(v) : v; - return Number.isFinite(n) ? n : fallback; -} diff --git a/skills/.curated/slides/assets/pptxgenjs_helpers/svg.js b/skills/.curated/slides/assets/pptxgenjs_helpers/svg.js deleted file mode 100644 index 2207994..0000000 --- a/skills/.curated/slides/assets/pptxgenjs_helpers/svg.js +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) OpenAI. All rights reserved. -"use strict"; - -function toDataUri(svg) { - return "data:image/svg+xml;base64," + Buffer.from(svg).toString("base64"); -} - -function sanitizeSvg(svg) { - let inner = svg; - const a = inner.indexOf(""); - 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 deleted file mode 100644 index 22bd8d0..0000000 --- a/skills/.curated/slides/assets/pptxgenjs_helpers/text.js +++ /dev/null @@ -1,789 +0,0 @@ -// Copyright (c) OpenAI. All rights reserved. -"use strict"; - -const { spawnSync } = require("child_process"); -const { Canvas } = require("skia-canvas"); -// Unicode line-break iterator (UAX #14) so we mimic PPT/LibreOffice wrapping rules. -const LineBreaker = require("linebreak"); -const fontkit = require("fontkit"); -const TEXT_MEASURER = getTextMeasurer(); -const registeredFontVariants = new Set(); -const fontPathCache = new Map(); -const fontKitCache = new Map(); - -// Estimate the text box height for a given font size and line count. -// NOTE: This is an analytical approximation, not an exact reproduction of -// PowerPoint/LibreOffice layout. Always verify visually and adjust based on -// actual rendering if precise fit is required. -function calcTextBoxHeightSimple( - fontSize, - lines = 1, - leading = 1.15, - padding = 0.3 -) { - const lineHeightIn = (fontSize / 72) * leading; - return lines * lineHeightIn + padding; -} - -// Compute font size that fits given text within a fixed box. -// NOTE: autoFontSize uses skia-canvas measurement stack to approximate the font size -// that will fit in a given box. Rendering engines may differ slightly, so -// treat the result as an estimate and tweak as needed after visual inspection. -// Signature: -// autoFontSize(textOrRuns, fontFace, opts?) -// - fontFace must be provided as the 2nd positional argument and cannot be in opts. -// - All modes always respect [minFontSize, maxFontSize] as a CLOSED interval when provided. -// Modes: -// - mode: "shrink" => shrink only (search [minFontSize, min(maxFontSize, fontSize)]) -// - mode: "enlarge" => enlarge only (search [max(minFontSize, fontSize), maxFontSize]) -// - mode: "auto" => shrink + enlarge (search [minFontSize, maxFontSize]); fontSize optional. -// In "auto" mode fontSize is not required; when omitted we simply search the whole [minFontSize, maxFontSize] range. -// Returns a cloned options object with computed fontSize. fit: "shrink" is appended only when mode === "shrink". -function autoFontSize(textOrRuns, fontFace, opts = {}) { - const x = toNumber(opts.x, 0); - const y = toNumber(opts.y, 0); - const w = toNumber(opts.w, 0); - const h = toNumber(opts.h, 0); - if (!(w > 0 && h > 0)) throw new Error("autoFontSize(): non-positive w or h"); - - const face = typeof fontFace === "string" ? fontFace.trim() : ""; - if (face.length === 0) { - throw new Error( - "autoFontSize(): fontFace is required as the 2nd positional argument." - ); - } - - // Fast-path: if there is no visible text content, just return the - // (optionally clamped) reference fontSize; there is nothing to fit. - const hasAnyText = - normalizeText(textOrRuns).trim().length > 0 || - (Array.isArray(textOrRuns) && - textOrRuns.some( - (run) => run && typeof run.text === "string" && run.text.trim().length - )); - - const fontStyle = - opts.italic === true || opts.fontStyle === "italic" ? "italic" : "normal"; - const fontWeight = - opts.bold === true || String(opts.fontWeight || "").toLowerCase() === "bold" - ? "bold" - : "normal"; - const leading = toNumber(opts.leading, 1.15) || 1.15; - - const modeRaw = typeof opts.mode === "string" ? opts.mode : "auto"; // 'auto' (default) | 'shrink' | 'enlarge' - const mode = modeRaw.toLowerCase(); - const isShrink = mode === "shrink"; - const isEnlarge = mode === "enlarge"; - const isAuto = mode === "auto"; - - const refPtRaw = toNumber(opts.fontSize, NaN); - const hasRefPt = Number.isFinite(refPtRaw); - const refPt = hasRefPt ? refPtRaw : NaN; - - // Base bounds (closed interval). Defaults: - // - minFontSize: 1pt - // - maxFontSize: 1000pt (unless the caller provided a tighter bound) - let minPt = toNumber(opts.minFontSize, NaN); - let maxPt = toNumber(opts.maxFontSize, NaN); - const userProvidedMax = Number.isFinite(maxPt); - if (!Number.isFinite(minPt)) { - minPt = 1; - } - if (!Number.isFinite(maxPt)) { - maxPt = 1000; - } - - if (isShrink || isEnlarge) { - if (!hasRefPt) { - throw new Error( - "autoFontSize(): mode 'shrink' or 'enlarge' requires fontSize" - ); - } - } - - if (isShrink) { - // Shrink only: never exceed the requested size (and respect maxFontSize). - maxPt = Math.min(maxPt, refPt); - } else if (isEnlarge) { - // Enlarge only: never go below the requested size (and respect minFontSize). - minPt = Math.max(minPt, refPt); - } else if (isAuto && hasRefPt && userProvidedMax) { - // Auto mode with an explicit maxFontSize: honor [minFontSize, maxFontSize] - // as the search band while allowing both shrink and enlarge within it. - } else if (!isAuto) { - throw new Error( - `autoFontSize(): unsupported mode "${modeRaw}", expected "auto" | "shrink" | "enlarge"` - ); - } - - if (!(maxPt > 0 && maxPt >= minPt)) { - throw new Error( - "autoFontSize(): invalid minFontSize/maxFontSize bounds after normalization" - ); - } - - // If there is no actual text, we can skip measurement entirely and just - // clamp the reference size to [minPt, maxPt]. - if (!hasAnyText) { - const chosen = - (hasRefPt && Math.max(minPt, Math.min(maxPt, refPt))) || minPt; - const out = { ...opts, x, y, w, h, fontSize: chosen }; - if (isShrink) out.fit = "shrink"; - return out; - } - - // Search the space of candidate font sizes with a small step and a safety - // bias baked into the fit test: - // - precision: 0.05pt (~1/20pt) so we land very close to the true max-fit. - // - safetyFactor: we require that the calcTextBox()-measured height is - // within a small margin of the caller-provided box height, so that the - // same layout engine used by calcTextBox drives autoFontSize decisions. - const precision = 0.05; // point precision for search (~1/20pt) - const safetyFactor = 0.97; - - let lo = minPt; - let hi = maxPt; - let best = lo; - while (hi - lo > precision) { - const mid = (lo + hi) / 2; - // Delegate measurement to calcTextBox so that autoFontSize and - // calcTextBox share the exact same layout pipeline (paragraph modeling, - // bullet handling, margins, padding, width scaling, etc.). - const layout = calcTextBox(mid, { - text: textOrRuns, - w, - fontFace: face, - fontStyle, - fontWeight, - leading, - margin: opts.margin, - padding: opts.padding, - paraSpaceAfter: opts.paraSpaceAfter, - }); - const fits = layout.h <= h * safetyFactor + 1e-6; - if (fits) { - best = mid; - lo = mid; // try larger - } else { - hi = mid; // shrink - } - } - // Closed interval: clamp to [minPt, maxPt]. - const finalPt = Math.max(minPt, Math.min(maxPt, best)); - - // Pass through all original options, override fontSize and append fit: "shrink" - const out = { ...opts, x, y, w, h, fontSize: finalPt }; - if (isShrink) out.fit = "shrink"; - return out; -} - -// Calculate text box metrics using skia-canvas measurement (lines, height, -// width) for a given font size and text payload. -// NOTE: calcTextBox approximates how many lines and how much space text will -// occupy using our JS measurement pipeline. It is designed to be close to -// PowerPoint/LibreOffice but is not guaranteed pixel-perfect—always adjust -// based on actual slide rendering when precision matters. -// Signature: -// calcTextBox(fontSizePt, opts) -// - fontSizePt: number (points) -// - opts (keywords): { -// text?: string | runs[], -// w?: number (inches), -// h?: number (inches), -// lines?: number, -// fontFace?: string, // required when measuring by width/height with text -// fontStyle?: 'normal' | 'italic', italic?: boolean, -// fontWeight?: 'normal' | 'bold', bold?: boolean, -// leading?: number (line height multiplier, default 1.15), -// padding?: number (inches, default 0.3), -// paraSpaceAfter?: number (points, default 0) -// } -// Modes (auto-detected): -// a) Given lines -> compute height -// b) Given width + text -> compute height and lines -// c) Given height + text -> compute width and lines -// Throws when insufficient info is provided. -function calcTextBox(fontSizePt, opts = {}) { - const textInput = opts.text ?? ""; - const text = normalizeText(textInput || ""); - const face = - typeof opts.fontFace === "string" && opts.fontFace.trim().length > 0 - ? opts.fontFace.trim() - : ""; - const fontStyle = - opts.italic === true || opts.fontStyle === "italic" ? "italic" : "normal"; - const fontWeight = - opts.bold === true || String(opts.fontWeight || "").toLowerCase() === "bold" - ? "bold" - : "normal"; - const leading = toNumber(opts.leading, 1.15) || 1.15; - const padding = toNumber(opts.padding, 0.3); // inches (allow 0) - const paraSpaceAfterPt = toNumber(opts.paraSpaceAfter, 0) || 0; // points - const lineHeightIn = (fontSizePt / 72) * leading; - const margins = normalizeMargins(opts.margin); - const measurer = TEXT_MEASURER; - - const hasLines = Number.isFinite(toNumber(opts.lines, NaN)); - const hasWidth = Number.isFinite(toNumber(opts.w, NaN)); - const hasHeight = Number.isFinite(toNumber(opts.h, NaN)); - const paragraphs = buildParagraphModels(textInput, { - fontSizePt, - // Do not silently substitute a default font here; callers measuring by - // width/height are required to pass an explicit fontFace so that our - // metrics match the actual slide theme. - fontFace: face, - fontStyle, - fontWeight, - leading, - paraSpaceAfterPt, - }); - const hasAnyText = paragraphs.some((p) => p.text.length > 0); - - // Empirical top inset: PPT text frames render a small gutter above the first line - // even with zero margins. Model it as a fraction of the font size so callers can - // visually trim by shifting y up and growing h by the same amount. - const topInsetIn = (fontSizePt / 72) * 0.2; // ~20% of font size (inches) - - if (hasLines) { - // Mode (a): Given lines -> compute height only - const lines = toNumber(opts.lines, 1); - const contentH = Math.max(0, lines * lineHeightIn + padding); - const h = contentH + margins.top + margins.bottom; - const passthrough = buildPassthroughOptions(opts, fontSizePt, margins); - return { - ...passthrough, - w: toNumber(opts.w, NaN) || null, - h, - lines, - contentH, - margins, - topInset: topInsetIn, - }; - } - - if (hasWidth && hasAnyText) { - // Mode (b): Given width + text -> compute height and lines - if (face.length === 0) { - throw new Error( - "calcTextBox(): opts.fontFace is required when measuring by width." - ); - } - const boxW = toNumber(opts.w, 0); - if (!(boxW > 0)) - throw new Error("calcTextBox(): width must be > 0 in mode 'width'"); - const innerW = Math.max(0, boxW - margins.left - margins.right); - const { lines, heightIn } = layoutGivenWidth(paragraphs, innerW); - const contentH = Math.max(0, heightIn + padding); - const h = contentH + margins.top + margins.bottom; - const passthrough = buildPassthroughOptions(opts, fontSizePt, margins); - return { - ...passthrough, - w: boxW, - h, - lines, - contentH, - margins, - topInset: topInsetIn, - }; - } - - if (hasHeight && hasAnyText) { - // Mode (c): Given height + text -> compute minimal width and lines to fit - if (face.length === 0) { - throw new Error( - "calcTextBox(): opts.fontFace is required when measuring by height." - ); - } - const boxH = toNumber(opts.h, 0); - if (!(boxH > 0)) - throw new Error("calcTextBox(): height must be > 0 in mode 'height'"); - const innerH = Math.max(0, boxH - margins.top - margins.bottom); - // Upper bound: single-line width across paragraphs - const singleLineWidth = paragraphs.reduce((mx, p) => { - const width = measureRunWidth(p, p.text) + p.textIndentIn; - return Math.max(mx, width); - }, 0); - const minHeightOneLine = Math.max( - 0, - paragraphs.reduce((sum, p, idx) => { - const lineHeight = (p.fontSizePt / 72) * p.leading; - sum += lineHeight; - if (idx !== paragraphs.length - 1) sum += p.paraSpaceAfterIn; - return sum; - }, 0) - ); - if (minHeightOneLine + padding - innerH > 1e-6) { - throw new Error( - "calcTextBox(): height too small for one-line layout at this font size" - ); - } - // Lower bound: longest token width - const longestTokenWidth = paragraphs.reduce((mx, p) => { - const tokens = splitTextIntoTokens(p.text); - for (const tk of tokens) { - if (tk.length === 0) continue; - const wIn = measureRunWidth(p, tk) + p.textIndentIn; - if (wIn > mx) mx = wIn; - } - return mx; - }, 0); - let lo = Math.max(0.01, longestTokenWidth); - let hi = Math.max(lo, singleLineWidth); - let best = hi; - for (let iter = 0; iter < 32; iter++) { - const mid = (lo + hi) / 2; - const { lines, heightIn } = layoutGivenWidth(paragraphs, mid); - const totalH = heightIn + padding; - if (totalH <= innerH + 1e-6) { - best = mid; - hi = mid; - } else { - lo = mid; - } - } - const { lines, heightIn } = layoutGivenWidth(paragraphs, best); - const contentH = heightIn + padding; - const passthrough = buildPassthroughOptions(opts, fontSizePt, margins); - return { - ...passthrough, - w: best + margins.left + margins.right, - h: contentH + margins.top + margins.bottom, - lines, - contentH, - margins, - topInset: topInsetIn, - }; - } - - throw new Error( - "calcTextBox(): insufficient information. Provide {lines} or ({w,text}) or ({h,text})." - ); -} - -function layoutGivenWidth(paragraphs, boxW) { - let totalLines = 0; - let heightIn = 0; - for (let i = 0; i < paragraphs.length; i++) { - const para = paragraphs[i]; - const widthScale = getWidthScaleForParagraph(para); - const usableWidth = Math.max(0.01, boxW - para.textIndentIn) * widthScale; - const lines = greedyWrap(para, usableWidth); - const count = Math.max(1, lines.length); - totalLines += count; - const lineHeightIn = (para.fontSizePt / 72) * para.leading; - heightIn += count * lineHeightIn; - if (i !== paragraphs.length - 1) heightIn += para.paraSpaceAfterIn; - } - return { lines: totalLines, heightIn }; -} - -function greedyWrap(paragraph, maxWidthIn) { - const text = paragraph.text || ""; - if (text.length === 0) return [""]; - const breaker = new LineBreaker(text); - const breakpoints = []; - let bk; - while ((bk = breaker.nextBreak())) { - breakpoints.push({ pos: bk.position, required: bk.required }); - } - const lines = []; - let start = skipTextWhitespace(text, 0); - let idx = 0; - while (start < text.length) { - while (idx < breakpoints.length && breakpoints[idx].pos <= start) idx++; - let chosen = null; - let probe = idx; - while (probe < breakpoints.length) { - const br = breakpoints[probe]; - const slice = text.slice(start, br.pos); - const width = measureRunWidth(paragraph, trimLineEnd(slice)); - if (width <= maxWidthIn + 1e-6) { - chosen = br; - probe++; - if (br.required) break; - } else { - break; - } - } - if (!chosen) { - const forced = forceBreakSegment(text, start, maxWidthIn, paragraph); - if (forced.segment.length === 0) break; - lines.push(trimLineEnd(forced.segment)); - start = skipTextWhitespace(text, forced.nextIndex); - continue; - } - const lineText = text.slice(start, chosen.pos); - lines.push(trimLineEnd(lineText)); - start = skipTextWhitespace(text, chosen.pos); - } - if (!lines.length) lines.push(""); - return lines; -} - -function splitTextIntoTokens(text) { - if (typeof text !== "string") return [""]; - const tokens = text.split(/(\s+)/); - return tokens.length ? tokens : [""]; -} - -function trimLineEnd(value) { - return typeof value === "string" ? value.replace(/\s+$/u, "") : ""; -} - -function measureRunWidth(paragraph, text) { - if (!text || text.length === 0) return 0; - const fontData = getFontData( - paragraph.fontFace, - paragraph.fontStyle, - paragraph.fontWeight - ); - if (fontData && fontData.font) { - const layout = fontData.font.layout(text); - const widthPts = - (layout.advanceWidth / fontData.font.unitsPerEm) * paragraph.fontSizePt; - return Math.max(0, widthPts / 72); - } - return TEXT_MEASURER( - text, - paragraph.fontSizePt, - paragraph.fontFace, - paragraph.fontStyle, - paragraph.fontWeight - ); -} - -function forceBreakSegment(text, start, maxWidthIn, paragraph) { - const chars = Array.from(text.slice(start)); - if (chars.length === 0) return { segment: "", nextIndex: text.length }; - let buffer = ""; - let consumedUnits = 0; - for (let i = 0; i < chars.length; i++) { - const candidate = buffer + chars[i]; - const width = measureRunWidth(paragraph, trimLineEnd(candidate)); - if (width <= maxWidthIn + 1e-6) { - buffer = candidate; - consumedUnits += chars[i].length; - continue; - } - if (buffer.length === 0) { - buffer = chars[i]; - consumedUnits += chars[i].length; - } - break; - } - if (buffer.length === 0) { - buffer = chars[0] || ""; - consumedUnits = buffer.length; - } - return { segment: buffer, nextIndex: start + consumedUnits }; -} - -function skipTextWhitespace(text, index) { - let idx = index; - while (idx < text.length && /\s/.test(text[idx])) idx++; - return idx; -} - -function buildParagraphModels(textOrRuns, baseStyle) { - const entries = collectParagraphEntries(textOrRuns); - if (entries.length === 0) { - return [resolveParagraphStyle({ text: "" }, baseStyle)]; - } - return entries.map((entry) => resolveParagraphStyle(entry, baseStyle)); -} - -function collectParagraphEntries(textOrRuns) { - const result = []; - if (Array.isArray(textOrRuns)) { - for (const entry of textOrRuns) { - if (typeof entry === "string") { - pushParagraphSegments(entry, undefined, result); - } else if (entry && typeof entry === "object") { - pushParagraphSegments(entry.text ?? "", entry.options || {}, result); - } - } - return result; - } - pushParagraphSegments(textOrRuns ?? "", undefined, result); - return result; -} - -function pushParagraphSegments(text, options, target) { - const normalized = String(text ?? ""); - const parts = normalized.split(/\r?\n/); - if (parts.length === 0) { - target.push({ text: "", options }); - return; - } - for (const part of parts) { - target.push({ text: part, options }); - } -} - -function resolveParagraphStyle(entry, baseStyle) { - const opts = entry.options || {}; - const fontFace = - (opts.fontFace && String(opts.fontFace).trim()) || - baseStyle.fontFace || - "Arial"; - const fontStyle = - opts.italic === true || opts.fontStyle === "italic" - ? "italic" - : baseStyle.fontStyle || "normal"; - const fontWeight = - opts.bold === true || String(opts.fontWeight || "").toLowerCase() === "bold" - ? "bold" - : baseStyle.fontWeight || "normal"; - const fontSizePt = - toNumber(opts.fontSize, baseStyle.fontSizePt) || baseStyle.fontSizePt; - const leading = - toNumber(opts.leading, baseStyle.leading) || baseStyle.leading || 1.15; - const paraSpaceAfterPt = - toNumber(opts.paraSpaceAfter, baseStyle.paraSpaceAfterPt) || - baseStyle.paraSpaceAfterPt || - 0; - const hasBullet = !!opts.bullet; - let indentPt = toNumber(opts.indent, NaN); - if (!Number.isFinite(indentPt) && hasBullet) { - indentPt = toNumber(opts.bullet.indent, NaN); - } - if (!Number.isFinite(indentPt)) indentPt = 0; - const hangingPt = toNumber(opts.hanging, 0) || 0; - let textIndentIn = 0; - if (indentPt > 0) { - if (hasBullet) { - // PowerPoint-style bullets: "indent" is the distance from the left edge - // of the text box to the start of the text (the bullet itself is hung - // using the hanging value). This means the available width for the text - // is boxWidth - indent, not boxWidth - (indent - hanging). Modeling it - // this way matches the manual line counts from PowerPoint/LibreOffice. - textIndentIn = indentPt / 72; - } else { - // Non-bullet paragraphs keep the prior behavior where hanging reduces - // the effective indent (similar to CSS text-indent). - textIndentIn = Math.max(0, (indentPt - hangingPt) / 72); - } - } - return { - text: entry.text || "", - fontFace, - fontStyle, - fontWeight, - fontSizePt, - leading, - paraSpaceAfterIn: paraSpaceAfterPt / 72, - textIndentIn, - }; -} - -function getFontData(face, fontStyle, fontWeight) { - const key = makeFontCacheKey(face, fontStyle, fontWeight); - if (fontKitCache.has(key)) return fontKitCache.get(key); - const fontPath = findFontPath(face, fontStyle, fontWeight); - if (!fontPath) { - fontKitCache.set(key, null); - return null; - } - try { - let font = fontkit.openSync(fontPath); - if (font && typeof font.fonts === "object") { - font = selectCollectionFont(font, fontStyle, fontWeight); - } - if (!font || typeof font.layout !== "function") { - fontKitCache.set(key, null); - return null; - } - registerCanvasFontVariant(fontPath, face, fontStyle, fontWeight, key); - const payload = { font, path: fontPath }; - fontKitCache.set(key, payload); - return payload; - } catch (err) { - fontKitCache.set(key, null); - return null; - } -} - -function makeFontCacheKey(face, fontStyle, fontWeight) { - const family = (face || "Arial").trim(); - const style = (fontStyle || "normal").toLowerCase(); - const weight = (fontWeight || "normal").toLowerCase(); - return `${family}::${style}::${weight}`; -} - -function registerCanvasFontVariant( - fontPath, - face, - fontStyle, - fontWeight, - cacheKey -) { - if (registeredFontVariants.has(cacheKey)) return; - try { - Canvas.registerFont(fontPath, { - family: face, - style: fontStyle || "normal", - weight: fontWeight || "normal", - }); - registeredFontVariants.add(cacheKey); - } catch (err) { - // ignore registration failure; measurement will fall back to Skia default - } -} - -function findFontPath(face, fontStyle, fontWeight) { - const family = (face || "").trim(); - if (family.length === 0) return null; - const key = makeFontCacheKey(family, fontStyle, fontWeight); - if (fontPathCache.has(key)) return fontPathCache.get(key); - const styleParts = []; - if ((fontWeight || "").toLowerCase() === "bold") styleParts.push("Bold"); - if ((fontStyle || "").toLowerCase() === "italic") styleParts.push("Italic"); - const styleQuery = - styleParts.length > 0 ? `:style=${styleParts.join(" ")}` : ""; - const query = `${family}${styleQuery}`; - const result = spawnSync("fc-match", ["-f", "%{file}", query], { - encoding: "utf8", - }); - if (result.status === 0) { - const output = String(result.stdout || "").trim(); - if (output.length > 0) { - fontPathCache.set(key, output); - return output; - } - } - fontPathCache.set(key, null); - return null; -} - -function selectCollectionFont(collection, fontStyle, fontWeight) { - const fonts = collection.fonts || []; - if (fonts.length === 0) return null; - const wantItalic = (fontStyle || "").toLowerCase() === "italic"; - const wantBold = (fontWeight || "").toLowerCase() === "bold"; - let best = fonts[0]; - let bestScore = scoreFontVariant(best, wantItalic, wantBold); - for (let i = 1; i < fonts.length; i++) { - const candidate = fonts[i]; - const score = scoreFontVariant(candidate, wantItalic, wantBold); - if (score > bestScore) { - best = candidate; - bestScore = score; - } - } - return best; -} - -function scoreFontVariant(font, wantItalic, wantBold) { - if (!font) return -1; - const name = String(font.fullName || font.postscriptName || "").toLowerCase(); - const isItalic = /italic|oblique/.test(name); - const isBold = /bold|black|heavy|semibold|extrabold/.test(name); - let score = 0; - if (isItalic === wantItalic) score += 1; - if (isBold === wantBold) score += 1; - return score; -} - -// Empirical width scaling to better match PowerPoint/LibreOffice line breaks. -// A tiny global shrink (about -1.5%) nudges borderline words to wrap the same -// way Office does, with per-script tweaks for cases where our measurer -// systematically under- or over-estimates glyph widths. We intentionally avoid -// per-font calibration so this helper generalizes beyond the regression deck. -function getWidthScaleForParagraph(paragraph) { - if (!paragraph || typeof paragraph.text !== "string") return 1; - const text = paragraph.text; - // Thai script: our measurer tends to slightly over-estimate, which can cause - // extra wraps. Give it a bit more room horizontally. - if (/[ก-๛]/u.test(text)) { - return 1.2; - } - - // Arabic: we usually underestimate, so shrink available width a bit more to - // encourage earlier breaks. - if (/[\u0600-\u06FF]/u.test(text)) { - return 0.97; - } - - // Base shrink for most Latin and other scripts. - return 0.985; -} - -// Build options to pass directly to pptx.addText. We exclude measurement-only -// fields and fill sensible defaults (e.g., fontSize) so callers can spread -// the result into addText just like the image sizing helpers. -function buildPassthroughOptions(opts, fontSizePt, margins) { - const exclude = new Set([ - "text", - "lines", - "w", // will be set by calcTextBox - "h", // will be set by calcTextBox - // fontFace/style/weight are useful for addText; allow passthrough - "leading", - "padding", - ]); - const out = {}; - for (const k of Object.keys(opts)) { - if (!exclude.has(k)) out[k] = opts[k]; - } - if (out.fontSize == null) out.fontSize = fontSizePt; - if (opts.margin != null) out.margin = margins; - return out; -} - -function getTextMeasurer() { - // Skia-canvas only for accurate shaping and Fontconfig-based resolution. - // Throws if skia-canvas is not available. - const canvas = new Canvas(2, 2); - const ctx = canvas.getContext("2d"); - const PX_PER_IN = 96; - return (text, fontSizePt, fontFace, fontStyle, fontWeight) => { - const px = (fontSizePt / 72) * PX_PER_IN; - const style = fontStyle || "normal"; - const weight = fontWeight || "normal"; - // CSS shorthand: style weight size family - ctx.font = `${style} ${weight} ${px}px ${fontFace || "Arial"}`; - const metrics = ctx.measureText(text); - return (metrics.width || 0) / PX_PER_IN; - }; -} - -function normalizeMargins(m) { - const toInches = (value) => - typeof value === "number" && Number.isFinite(value) ? value / 72 : 0; - if (m && typeof m === "object") { - if (Number.isFinite(m.left) || Number.isFinite(m.top)) { - return { - left: toInches(m.left), - right: toInches(m.right), - top: toInches(m.top), - bottom: toInches(m.bottom), - }; - } - } - const all = toInches(m); - return { left: all, right: all, top: all, bottom: all }; -} - -function normalizeText(textOrRuns) { - if (Array.isArray(textOrRuns)) { - return textOrRuns - .map((item) => { - if (typeof item === "string") return item; - if (item && typeof item.text === "string") return item.text; - return ""; - }) - .join(""); - } - return typeof textOrRuns === "string" ? textOrRuns : String(textOrRuns ?? ""); -} - -function toNumber(v, fallback) { - const n = typeof v === "string" ? parseFloat(v) : v; - return Number.isFinite(n) ? n : fallback; -} - -module.exports = { - calcTextBoxHeightSimple, - calcTextBox, - autoFontSize, -}; diff --git a/skills/.curated/slides/assets/pptxgenjs_helpers/util.js b/skills/.curated/slides/assets/pptxgenjs_helpers/util.js deleted file mode 100644 index 8a6e1fc..0000000 --- a/skills/.curated/slides/assets/pptxgenjs_helpers/util.js +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) OpenAI. All rights reserved. -"use strict"; - -// Safe outer shadow helper (avoid inner/outer mix and XML pitfalls) -function safeOuterShadow( - color = "000000", - opacity = 0.25, - angle = 45, - blur = 3, - offset = 2 -) { - return { - type: "outer", - color, - opacity, - angle, - blur, - offset, - }; -} - -module.exports = { - safeOuterShadow, -}; diff --git a/skills/.curated/slides/assets/slides-small.svg b/skills/.curated/slides/assets/slides-small.svg deleted file mode 100644 index 8afd52d..0000000 --- a/skills/.curated/slides/assets/slides-small.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/skills/.curated/slides/assets/slides.png b/skills/.curated/slides/assets/slides.png deleted file mode 100644 index c05e3091345368c72714539ffd6614bca92e95a6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1868 zcmbtV>p#1a0mx$c6Awtr}&-ojEFP`W5JkRs;dGSzftgnhgVgnP}f#`fVLUJfo;!35ed%}u2~*LAWwk|209*h;h; z4VWiUeu-o&8W0``;Is3mRwj|z>U_28pCV7Dn)DK$0MfNFCAS73l-)uaipu+UJ0f20 zBUSo~4{`MCr`!9{_z`Y&t^Vf3y-i|HBLI$72G5EKL|MaSlK5HgUqYr|QX(KWYGa%`BnDJg294~r@;T*4@)OARB+3Kt*+j0WIH3VKu zqv|%`!r-gKHpG}!@NLbxxa2*;cA;>e?VBgFreJ1uoY@~rcXdvR#t1$YLxsAM6_yIA5_)j3dyF%7pv4tw8KBR9P^OZ8)0jig+l>=$koD7*7=vV{0eN7T z*MG8lrM_!X9ibVjRURued^+^h7jBQc&whPO7GoyxtnU5R<1Pb$MiB*ir;!fyrpSm; zx_CIm?w*(`O&SB96v9GAEzp_NEGuU_a-YhGUnd6A++5dmX8y>Kx6%Hs2+G4jnC*jX-(+> zrRx7ac63F(_&EM%{~!ZiY2SdF^E+qF9Lqs)eHM)`wsAwX{r_s19^;_8-X~kE<`*qY z5-ArCK7FDwmW{QeqxC691rEuEAYvWt0Qr14y5G_39-Yu_7Jj=0g2EDHF2J?x4RcHK zU23cBVBnP*{VAS7MftD2U(&8M;~#e*d9EUFqj?~(*UI2z7IT|qhf@f>x|1~ZBMMcwBa+yfW{jEHoog$QNX+LT+{RyO$^7H(u{CO@W3aC$klBa) z!<-c%0maC{SwDZn$Ba{i9I-*vUV`aFyDRa!cVHOinQNdroI*!JXf@)h`RVzHb^!^! zVf&?T8qRw`{8DK>M22K1euVJYAgqU-msvXQUxJdC9LMRzpf)F(V%fXsmsPB5?%w7% z?Rui?16E;=INiB|@6%GOOjM+P_DIlHzq9qDk)-{&txgiYvBM7z|K{Mrrj)bX$Dn2onvHa|wct61;Aqg>OGk>lS|q$H?30rz z=^K_ciyp<_Red*{GoFg|MIs)CL~YQIMK6z>rv*mvGfFkq0USk0o$GPSTOq#dQ~V~qMUO|vi)p6Se5gXPfFhBwwcp&gL9xdF0b(?hTfeBDl9H2?M+Yy7XSw!XjS};6$J9|76HJ_#2Q 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 deleted file mode 100644 index a5fc393..0000000 --- a/skills/.curated/slides/scripts/detect_font.py +++ /dev/null @@ -1,873 +0,0 @@ -#!/usr/bin/env python3 -"""Copyright (c) OpenAI. All rights reserved. - -Detect missing fonts for PPTX rendering by converting to ODP and inspecting the resolved font -families per slide. - -Overview -======== -PowerPoint files (PPTX) declare requested font families in runs and theme defaults, but the actual -font used at render time depends on the renderer (LibreOffice in our pipeline), platform -availability, and style inheritance. To make detection stable and renderer-accurate, this module: - -- Extracts requested families from PPTX per slide (reads a:r/a:rPr plus document defaults, grouped - by script: latin/ea/cs/sym). Analysis is done per run: we infer the script from run text and - select the matching a:rPr child (e.g., latin/ea/cs). Fonts declared for other scripts in the same - run are not counted as used. -- Converts the PPTX to ODP using headless LibreOffice and parses ODP content.xml and styles.xml to - discover which families LibreOffice actually resolved for each slide (including master pages and - defaults). -- Classifies each requested family on each slide into two buckets: - - font_missing: the family is not installed on the system (per fontconfig synonyms), so resolution - cannot possibly match the request. - - font_substituted: the family is installed but was resolved to another family in ODP for the - slide (theme/style inheritance or glyph coverage), i.e., installed but substituted. - -Key Design ------------------------ -1) Inspect the renderer's decision, not only the author's request. Reading PPTX alone tells you what - was requested, not what LibreOffice will choose after applying styles and availability checks. - Converting to ODP and reading the resolved fo:font-family/style:font-name* values yields a - faithful view of what the renderer actually used for each slide. - -2) Robust style resolution across ODP structures. Fonts can be specified under multiple layers. We - parse office:automatic-styles (both content.xml and styles.xml), office:styles and - style:default-style, draw:master-page references used by slides, nested style:text-properties - under paragraph-properties, and parent style chains (style:parent-style-name). A text-based - fallback parser supplements XML namespace lookups when vendor XML variations occur. - -3) Scalable aliasing via fontconfig synonyms, not ad hoc maps. PostScript names, full names, and - family names often differ. We build a synonym map from fc-list that unifies those identifiers. We - deliberately do NOT use fc-match -s fallback chains for matching, because fallback families - (e.g., DejaVu Sans) would mask missing/substitution cases and produce false passes. - -4) Clear classification: missing vs substituted. - - Missing: no synonym of the requested base family is present in the installed font set (per - fontconfig). These require installation. - - Substituted: the family is installed, but ODP does not reference it on the slide (LibreOffice - chose another family), which is useful for diagnosing style/theme issues or glyph-coverage - driven substitutions. - -Not Chosen (and why) --------------------- -- PDF inspection (e.g., pdffonts): PostScript names don't reliably map back to authoring families; - PDFs often reflect subsetted fonts and fallback choices, making robust detection noisy. -- Ad hoc alias tables: unscalable for large-scale fonts and platform variants; the fontconfig - synonym corpus covers family/fullname/PostScript consistently. -- Treating fallback families as matches (fc-match -s): causes false negatives by accepting generic - fallbacks when the requested family is missing. -- Hardcoding checks in the renderer: we keep detection separate from render_slides to avoid - coupling and allow standalone checking. - -CLI ---- -- JSON output exposes two categories by default (and text mode mirrors them): font_missing_overall/ - font_missing_by_slide and font_substituted_overall/font_substituted_by_slide. -- Flags include_missing/include_substituted control which categories are emitted (default True/True). -""" - -import argparse -import json -import os -import re -import shutil -import subprocess -import tempfile -import xml.etree.ElementTree as ET -from functools import lru_cache -from os.path import abspath, basename, exists, expanduser, join, splitext -from zipfile import ZipFile - -STYLE_TOKENS = [ - "regular", - "condensed", - "compressed", - "narrow", - "italic", - "oblique", - "semibold", - "demibold", - "bold", - "black", - "extra light", - "ultra light", - "extralight", - "ultralight", - "light", - "thin", - "medium", -] - - -def normalize_font_family_name(name: str) -> str: - s = name.casefold() - s = re.sub(r"\([^)]*\)", " ", s) - s = re.sub(r"[\s\-\_\.,/\'\"]+", " ", s) - return s.strip() - - -def _or_dummy(node: ET.Element | None) -> ET.Element: - """Return the element if not None, otherwise a harmless dummy element. - - Avoids deprecated truthiness checks on Element instances (`elem or dummy`). - """ - return node if node is not None else ET.Element("dummy") - - -@lru_cache(maxsize=1) -def _build_fc_synonym_map() -> dict[str, set[str]]: - """Build synonym map from fontconfig; raise on failures; memoized (size=1).""" - proc = subprocess.run( - [ - "fc-list", - "--format", - "%{family}\t%{fullname}\t%{postscriptname}\n", - ], - capture_output=True, - text=True, - check=True, - ) - syn: dict[str, set[str]] = {} - for line in (proc.stdout or "").splitlines(): - parts = line.split("\t") - if len(parts) != 3: - continue - fam_field, full_field, ps_field = parts - names: set[str] = set() - for field in (fam_field, full_field, ps_field): - for item in field.split(","): - norm = normalize_font_family_name(item) - if norm: - names.add(norm) - names.add(norm.replace(" ", "")) - for name in list(names): - bucket = syn.setdefault(name, set()) - bucket.update(names) - return syn - - -def _expand_via_fontconfig(family_base_norm: str) -> set[str]: - # Accept only true aliases/synonyms (family/fullname/PostScript) — not fallback replacements - acceptable: set[str] = {family_base_norm, family_base_norm.replace(" ", "")} - syn = _build_fc_synonym_map() - if family_base_norm in syn: - acceptable.update(syn[family_base_norm]) - no_space = family_base_norm.replace(" ", "") - if no_space in syn: - acceptable.update(syn[no_space]) - return acceptable - - -def parse_font_family_base_and_styles(name_norm: str) -> tuple[str, set[str]]: - tokens = name_norm.split() - required: set[str] = set() - weight_code_map = { - "25": "ultra light", - "35": "thin", - "45": "light", - "55": "regular", - "65": "medium", - "75": "bold", - "85": "black", - "95": "black", - } - if tokens and tokens[0].isdigit() and tokens[0] in weight_code_map: - required.add(weight_code_map[tokens[0]]) - tokens = tokens[1:] - if len(tokens) == 1: - t = tokens[0] - fused_map = [ - ("extralight", "extra light"), - ("ultralight", "ultra light"), - ("semibold", "semibold"), - ("demibold", "semibold"), - ("condensed", "condensed"), - ("compressed", "condensed"), - ("narrow", "condensed"), - ("italic", "italic"), - ("oblique", "italic"), - ("bold", "bold"), - ("black", "black"), - ("light", "light"), - ("thin", "thin"), - ("medium", "medium"), - ("regular", "regular"), - ] - changed = True - while changed: - changed = False - for suf, tok in fused_map: - if t.endswith(suf) and len(t) > len(suf): - t = t[: -len(suf)] - required.add(tok) - changed = True - break - return (t.strip(), required) - - while tokens: - tail = " ".join(tokens[-2:]) if len(tokens) >= 2 else tokens[-1] - matched = None - for style in STYLE_TOKENS: - if tail == style: - matched = style - break - if matched is None and tokens[-1] in STYLE_TOKENS: - matched = tokens[-1] - if matched is None: - break - if matched in ("compressed", "narrow"): - required.add("condensed") - elif matched == "roman": - required.add("regular") - elif matched == "demibold": - required.add("semibold") - else: - required.add(matched) - if " " in matched: - tokens = tokens[:-2] - else: - tokens = tokens[:-1] - return (" ".join(tokens).strip(), required) - - -def _split_odf_family_list(value: str) -> list[str]: - out: list[str] = [] - for part in value.split(","): - p = part.strip().strip("\"' ") - if p: - out.append(normalize_font_family_name(p)) - return out - - -def extract_used_fonts_from_pptx(pptx_path: str) -> dict[int, set[str]]: - by_slide: dict[int, set[str]] = {} - with ZipFile(pptx_path, "r") as zf: - for name in zf.namelist(): - if not (name.startswith("ppt/slides/slide") and name.endswith(".xml")): - continue - base = os.path.basename(name) - m = re.search(r"(?i)slide(\d+)\.xml$", base) - slide_num = int(m.group(1)) if m else None - with zf.open(name) as f: - tree = ET.parse(f) - root = tree.getroot() - ns = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"} - defaults = _collect_default_font_faces(root) - for r in root.findall(".//a:r", ns): - parts: list[str] = [] - for t in r.findall("a:t", ns): - if t.text: - parts.append(t.text) - text = "".join(parts) - if not text: - continue - script = _detect_script_tag(text) - rpr = r.find("a:rPr", ns) - face_norm: str | None = None - if rpr is not None: - child = rpr.find(f"a:{script}", ns) - if child is not None: - face = child.get("typeface") - if face and not face.startswith("+"): - face_norm = normalize_font_family_name(face) - bucket = by_slide.setdefault(slide_num or -1, set()) - if face_norm is None: - for f in defaults.get(script, set()): - bucket.add(f) - else: - bucket.add(face_norm) - return {k: v for k, v in by_slide.items() if k is not None and k != -1} - - -def _detect_script_tag(text: str) -> str: - for ch in text: - cp = ord(ch) - if ( - 0x4E00 <= cp <= 0x9FFF - or 0x3400 <= cp <= 0x4DBF - or 0xF900 <= cp <= 0xFAFF - or 0x3040 <= cp <= 0x309F - or 0x30A0 <= cp <= 0x30FF - or 0x31F0 <= cp <= 0x31FF - or 0xAC00 <= cp <= 0xD7AF - or 0x3100 <= cp <= 0x312F - or 0x3000 <= cp <= 0x303F - ): - return "ea" - for ch in text: - cp = ord(ch) - if ( - 0x0590 <= cp <= 0x05FF - or 0x0600 <= cp <= 0x06FF - or 0x0700 <= cp <= 0x077F - or 0x0780 <= cp <= 0x07BF - or 0x0900 <= cp <= 0x0D7F - or 0x0E00 <= cp <= 0x0E7F - or 0x0E80 <= cp <= 0x0EFF - or 0xFB50 <= cp <= 0xFDFF - or 0xFE70 <= cp <= 0xFEFF - ): - return "cs" - for ch in text: - cp = ord(ch) - if ( - (0x0041 <= cp <= 0x005A) - or (0x0061 <= cp <= 0x007A) - or (0x0030 <= cp <= 0x0039) - or (0x00C0 <= cp <= 0x024F) - or (0x1E00 <= cp <= 0x1EFF) - ): - return "latin" - return "latin" - - -def _collect_default_font_faces(root: ET.Element) -> dict[str, set[str]]: - ns = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"} - defaults: dict[str, set[str]] = {"latin": set(), "ea": set(), "cs": set(), "sym": set()} - for defrpr in root.findall(".//a:defRPr", ns): - for tag in ("latin", "ea", "cs", "sym"): - child = defrpr.find(f"a:{tag}", ns) - if child is not None: - face = child.get("typeface") - if face and not face.startswith("+"): - defaults[tag].add(normalize_font_family_name(face)) - return defaults - - -def _run_soffice_convert(cmd: list[str]) -> None: - subprocess.run( - cmd, - check=False, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - env=os.environ.copy(), - ) - - -def _export_to_odp(pptx_path: str, user_profile: str, out_dir: str, stem: str) -> str: - bin_path = shutil.which("soffice") or shutil.which("libreoffice") or "/usr/bin/libreoffice" - cmd_odp = [ - bin_path, - "-env:UserInstallation=file://" + user_profile, - "--invisible", - "--headless", - "--norestore", - "--convert-to", - "odp", - "--outdir", - out_dir, - pptx_path, - ] - _run_soffice_convert(cmd_odp) - odp_path = join(out_dir, f"{stem}.odp") - return odp_path if exists(odp_path) else "" - - -def _collect_face_map(root: ET.Element, ns: dict[str, str]) -> dict[str, str]: - face_map: dict[str, str] = {} - decls = root.find("office:font-face-decls", ns) - if decls is None: - return face_map - for ff in decls.findall("style:font-face", ns): - name_attr = ff.get("{urn:oasis:names:tc:opendocument:xmlns:style:1.0}name") or ff.get( - "style:name" - ) - fam_attr = ff.get("{urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0}font-family") - if not name_attr or not fam_attr: - continue - face_map[normalize_font_family_name(name_attr)] = normalize_font_family_name(fam_attr) - return face_map - - -def _families_from_text_properties( - tp: ET.Element, ns: dict[str, str], face_map: dict[str, str] -) -> set[str]: - fams: set[str] = set() - # Inspect current node for direct font-family - fam_attr = tp.get("{urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0}font-family") - if fam_attr: - fams.update(_split_odf_family_list(fam_attr)) - # Inspect font-name aliases on current node - for key in ( - "{urn:oasis:names:tc:opendocument:xmlns:style:1.0}font-name", - "style:font-name", - "style:font-name-asian", - "style:font-name-complex", - ): - val = tp.get(key) - if val: - norm_val = normalize_font_family_name(val) - mapped = face_map.get(norm_val) - if mapped: - fams.add(normalize_font_family_name(mapped)) - else: - fams.add(norm_val) - # Some styles nest text-properties under paragraph-properties or default-style blocks - if not fams: - nested = None - # paragraph-properties/text-properties - pp = tp.find("style:paragraph-properties", ns) - if pp is not None: - nested = pp.find("style:text-properties", ns) - if nested is None: - # When tp is actually the style:style node, try finding child text-properties directly - nested = tp.find("style:text-properties", ns) - if nested is not None and nested is not tp: - fams.update(_families_from_text_properties(nested, ns, face_map)) - return fams - - -def _extract_styles_from_container( - container: ET.Element | None, ns: dict[str, str], face_map: dict[str, str] -) -> tuple[dict[str, set[str]], set[str]]: - styles: dict[str, set[str]] = {} - defaults: set[str] = set() - if container is None: - return styles, defaults - for st in container.findall("style:style", ns): - name = st.get("{urn:oasis:names:tc:opendocument:xmlns:style:1.0}name") or st.get( - "style:name" - ) - if not name: - continue - fams = _families_from_text_properties( - _or_dummy(st.find("style:text-properties", ns)), ns, face_map - ) - if fams: - styles[name] = fams - for ds in container.findall("style:default-style", ns): - defaults.update( - _families_from_text_properties( - _or_dummy(ds.find("style:text-properties", ns)), ns, face_map - ) - ) - return styles, defaults - - -def _build_style_map( - content: ET.Element, - styles_root: ET.Element | None, - ns: dict[str, str], - face_map: dict[str, str], -) -> tuple[dict[str, set[str]], set[str]]: - style_map: dict[str, set[str]] = {} - default_fams: set[str] = set() - auto_styles = content.find("office:automatic-styles", ns) - styles_part, defaults_part = _extract_styles_from_container(auto_styles, ns, face_map) - style_map.update(styles_part) - default_fams.update(defaults_part) - if styles_root is not None: - # Also parse automatic-styles within styles.xml (document-styles) - styles_auto = styles_root.find("office:automatic-styles", ns) - styles_part, defaults_part = _extract_styles_from_container(styles_auto, ns, face_map) - for k, v in styles_part.items(): - if k not in style_map: - style_map[k] = v - default_fams.update(defaults_part) - common_styles = styles_root.find("office:styles", ns) - styles_part, defaults_part = _extract_styles_from_container(common_styles, ns, face_map) - for k, v in styles_part.items(): - if k not in style_map: - style_map[k] = v - default_fams.update(defaults_part) - # top-level default-style under styles_root - for ds in styles_root.findall("style:default-style", ns): - default_fams.update( - _families_from_text_properties( - _or_dummy(ds.find("style:text-properties", ns)), ns, face_map - ) - ) - # Fallback: include any remaining style:style definitions anywhere in styles.xml - for st in styles_root.findall(".//style:style", ns): - name = st.get("{urn:oasis:names:tc:opendocument:xmlns:style:1.0}name") or st.get( - "style:name" - ) - if not name or name in style_map: - continue - fams = _families_from_text_properties( - _or_dummy(st.find("style:text-properties", ns)), ns, face_map - ) - if fams: - style_map[name] = fams - # also check top-level default-style in content root - for ds in content.findall("style:default-style", ns): - default_fams.update( - _families_from_text_properties( - _or_dummy(ds.find("style:text-properties", ns)), ns, face_map - ) - ) - # Fallback: include any remaining style:style definitions anywhere in content.xml - for st in content.findall(".//style:style", ns): - name = st.get("{urn:oasis:names:tc:opendocument:xmlns:style:1.0}name") or st.get( - "style:name" - ) - if not name or name in style_map: - continue - fams = _families_from_text_properties( - _or_dummy(st.find("style:text-properties", ns)), ns, face_map - ) - if fams: - style_map[name] = fams - return style_map, default_fams - - -def _lookup_style_families( - style_name: str, ns: dict[str, str], face_map: dict[str, str], roots: list[ET.Element | None] -) -> set[str]: - fams: set[str] = set() - if not style_name: - return fams - visited: set[str] = set() - - def _resolve(name: str) -> None: - if not name or name in visited: - return - visited.add(name) - for root in roots: - if root is None: - continue - node = root.find(f".//style:style[@style:name='{name}']", ns) - if node is None: - node = root.find(f".//style:style[@{{{ns['style']}}}name='{name}']", ns) - if node is None: - continue - fams.update( - _families_from_text_properties( - _or_dummy(node.find("style:text-properties", ns)), ns, face_map - ) - ) - # Follow parent style chain if present - parent = node.get( - "{urn:oasis:names:tc:opendocument:xmlns:style:1.0}parent-style-name" - ) or node.get("style:parent-style-name") - if parent: - _resolve(parent) - - _resolve(style_name) - return fams - - -def _collect_slide_families( - page: ET.Element, - ns: dict[str, str], - style_map: dict[str, set[str]], - face_map: dict[str, str], - roots: list[ET.Element | None], - text_style_map: dict[str, set[str]] | None = None, -) -> set[str]: - slide_fams: set[str] = set() - for el in page.iter(): - fam_attr = el.get( - "{urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0}font-family" - ) - if fam_attr: - slide_fams.update(_split_odf_family_list(fam_attr)) - for attr in ( - "{urn:oasis:names:tc:opendocument:xmlns:text:1.0}style-name", - "text:style-name", - "{urn:oasis:names:tc:opendocument:xmlns:drawing:1.0}text-style-name", - "draw:text-style-name", - "draw:style-name", - "presentation:style-name", - ): - style_name = el.get(attr) - if not style_name: - continue - resolved_fams: set[str] = set() - if style_name in style_map: - resolved_fams.update(style_map[style_name]) - if not resolved_fams: - # Fallback: resolve on the fly from XML if not present in prebuilt style_map - resolved_fams.update(_lookup_style_families(style_name, ns, face_map, roots)) - if not resolved_fams and text_style_map and style_name in text_style_map: - resolved_fams.update(text_style_map[style_name]) - if resolved_fams: - slide_fams.update(resolved_fams) - return slide_fams - - -def _build_style_map_text(xml_text: str) -> dict[str, set[str]]: - # Best-effort textual extraction for cases missed by XML namespace lookups - # Finds style:style name="X" blocks and extracts fo:font-family and style:font-name attributes - style_map: dict[str, set[str]] = {} - # Non-greedy match of a style:style block - for m in re.finditer( - r"]*?\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 deleted file mode 100644 index 0ce3dbc..0000000 --- a/skills/.curated/slides/scripts/ensure_raster_image.py +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/env python3 -"""Copyright (c) OpenAI. All rights reserved. - -Ensures input images are rasterized, converting to PNG when needed. Primarily used to -preview image assets extracted from PowerPoint files. - - -Dependencies used by this tool: -- Inkscape: SVG/EMF/WMF rasterization -- ImageMagick: format bridging (TIFF→PNG, generic convert) -- Ghostscript: PDF/EPS/PS rasterization (first page) -- libheif-examples: heif-convert for HEIC/HEIF → PNG -- jxr-tools (or libjxr-tools on older distros): JxrDecApp for JPEG XR (JXR/WDP) - -Install (Ubuntu/Debian): - sudo apt-get update - sudo apt-get install -y inkscape imagemagick ghostscript libheif-examples jxr-tools - # If jxr-tools not found on your distro, try: - # sudo apt-get install -y libjxr-tools - -Verify: - inkscape --version - convert -version | grep -i "ImageMagick" - gs -v - heif-convert -h - JxrDecApp -h -""" - -import argparse -import gzip -import shutil -from os import listdir -from os.path import basename, dirname, expanduser, isfile, join, splitext -from subprocess import run - -RASTER_EXTS = { - ".png", - ".jpg", - ".jpeg", - ".bmp", - ".gif", - ".tif", - ".tiff", - ".webp", -} - -CONVERTIBLE_EXTS = { - # Windows metafiles (and compressed variants) - ".emf", - ".wmf", - ".emz", - ".wmz", - # SVG - ".svg", - ".svgz", - # JPEG XR / HD Photo - ".wdp", - ".jxr", - # HEIF family - ".heic", - ".heif", - # Page-description formats (rasterize first page) - ".pdf", - ".eps", - ".ps", -} - -SUPPORTED_EXTS = RASTER_EXTS | CONVERTIBLE_EXTS - - -def _imagemagick_convert(src_path: str, dst_path: str) -> None: - binary = shutil.which("magick") or "convert" - run([binary, src_path, dst_path], check=True) - - -def ensure_raster_image(path: str, out_dir: str | None = None) -> str: - """Return a raster image path for the given input, converting when needed. - - - EMF/WMF/EMZ/WMZ are rasterized via Inkscape (EMZ/WMZ are decompressed first) - - SVG/SVGZ are rasterized via Inkscape - - WDP/JXR are converted via ImageMagick (if codec available) - - Known raster formats are returned as-is - - Raises ValueError if the extension is not supported. - """ - base, ext = splitext(path) - ext_lower = ext.lower() - out_dir = out_dir or dirname(path) - out_path = join(out_dir, basename(base) + ".png") - - # Convertible formats - if ext_lower in (".emf", ".wmf"): - run(["inkscape", path, "-o", out_path], check=True) - if isfile(out_path): - return out_path - raise RuntimeError("inkscape reported success but output file not found: " + out_path) - - if ext_lower in (".emz", ".wmz"): - # Decompress into EMF/WMF then rasterize with Inkscape - decompressed = join(out_dir, basename(base) + (".emf" if ext_lower == ".emz" else ".wmf")) - with gzip.open(path, "rb") as zin, open(decompressed, "wb") as zout: - zout.write(zin.read()) - run( - ["inkscape", decompressed, "-o", out_path], - check=True, - ) - if isfile(out_path): - return out_path - raise RuntimeError("inkscape reported success but output file not found: " + out_path) - - if ext_lower in (".svg", ".svgz"): - run(["inkscape", path, "-o", out_path], check=True) - if isfile(out_path): - return out_path - raise RuntimeError("inkscape reported success but output file not found: " + out_path) - - if ext_lower in (".wdp", ".jxr"): - tmp_tiff = join(out_dir, basename(base) + ".tiff") - run(["JxrDecApp", "-i", path, "-o", tmp_tiff], check=True) - _imagemagick_convert(tmp_tiff, out_path) - if isfile(out_path): - return out_path - raise RuntimeError("JPEG XR decode succeeded but PNG not found: " + out_path) - - if ext_lower in (".heic", ".heif"): - # Use libheif's CLI for robust conversion - heif_convert = shutil.which("heif-convert") or "heif-convert" - run([heif_convert, path, out_path], check=True) - if isfile(out_path): - return out_path - raise RuntimeError("heif-convert reported success but output file not found: " + out_path) - - if ext_lower in (".pdf", ".eps", ".ps"): - # Rasterize first page via Ghostscript - gs = shutil.which("gs") or "gs" - run( - [ - gs, - "-dSAFER", - "-dBATCH", - "-dNOPAUSE", - "-sDEVICE=pngalpha", - "-dFirstPage=1", - "-dLastPage=1", - "-r200", - "-o", - out_path, - path, - ], - check=True, - ) - if isfile(out_path): - return out_path - raise RuntimeError("Ghostscript reported success but output file not found: " + out_path) - - if ext_lower in RASTER_EXTS: - return path - - raise ValueError(f"Unsupported image format for montage: {path}") - - -def main() -> None: - parser = argparse.ArgumentParser( - description=("Ensure input images are rasterized; convert to PNG if needed.") - ) - group = parser.add_mutually_exclusive_group(required=True) - group.add_argument("--input_files", nargs="+", help="List of input image file paths") - group.add_argument("--input_dir", help="Directory containing input images") - parser.add_argument( - "--output_dir", - default=None, - help=( - "Directory to write converted PNGs. If omitted, converted files are written next to inputs." - ), - ) - args = parser.parse_args() - - if args.input_files: - paths = [expanduser(p) for p in args.input_files] - else: - input_dir = expanduser(args.input_dir) - names = listdir(input_dir) - paths = [ - join(input_dir, f) - for f in names - if isfile(join(input_dir, f)) and splitext(f)[1].lower() in SUPPORTED_EXTS - ] - if not paths: - raise SystemExit("No files with supported extensions in input_dir") - - out_dir = expanduser(args.output_dir) if args.output_dir else None - converted_paths = [] - for p in paths: - if ensure_raster_image(p, out_dir) != p: - converted_paths.append(p) - - if converted_paths: - print("Converted the following files to PNG:\n" + "\n".join(converted_paths)) - - -if __name__ == "__main__": - main() diff --git a/skills/.curated/slides/scripts/render_slides.py b/skills/.curated/slides/scripts/render_slides.py deleted file mode 100644 index c540233..0000000 --- a/skills/.curated/slides/scripts/render_slides.py +++ /dev/null @@ -1,273 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) OpenAI. All rights reserved. -import argparse -import os -import re -import subprocess -import tempfile -import xml.etree.ElementTree as ET -from os import makedirs, replace -from os.path import abspath, basename, exists, expanduser, join, splitext -from typing import Sequence, cast -from zipfile import ZipFile - -from pdf2image import convert_from_path, pdfinfo_from_path - -EMU_PER_INCH: int = 914_400 - - -def calc_dpi_via_ooxml(input_path: str, max_w_px: int, max_h_px: int) -> int: - """Calculate DPI from OOXML `ppt/presentation.xml` slide size (cx/cy in EMUs).""" - with ZipFile(input_path, "r") as zf: - xml = zf.read("ppt/presentation.xml") - root = ET.fromstring(xml) - ns = {"p": "http://schemas.openxmlformats.org/presentationml/2006/main"} - sld_sz = root.find("p:sldSz", ns) - if sld_sz is None: - raise RuntimeError("Slide size not found in presentation.xml") - cx = int(sld_sz.get("cx") or 0) - cy = int(sld_sz.get("cy") or 0) - if cx <= 0 or cy <= 0: - raise RuntimeError("Invalid slide size values in presentation.xml") - width_in = cx / EMU_PER_INCH - height_in = cy / EMU_PER_INCH - return round(min(max_w_px / width_in, max_h_px / height_in)) - - -def calc_dpi_via_pdf(input_path: str, max_w_px: int, max_h_px: int) -> int: - """Compute DPI from PDF page size. - - For non-PDF inputs, first convert to PDF via LibreOffice to read page size. - For PDFs, use the PDF directly (avoids unnecessary conversion and failures). - """ - is_pdf = input_path.lower().endswith(".pdf") - with tempfile.TemporaryDirectory(prefix="soffice_profile_") as user_profile: - with tempfile.TemporaryDirectory(prefix="soffice_convert_") as convert_tmp_dir: - stem = splitext(basename(input_path))[0] - pdf_path = ( - input_path - if is_pdf - else convert_to_pdf(input_path, user_profile, convert_tmp_dir, stem) - ) - if not (pdf_path and exists(pdf_path)): - raise RuntimeError("Failed to produce/read PDF for DPI computation.") - - info = pdfinfo_from_path(pdf_path) - size_val = info.get("Page size") - if not size_val: - for k, v in info.items(): - if isinstance(v, str) and "size" in k.lower() and "pts" in v: - size_val = v - break - if not isinstance(size_val, str): - raise RuntimeError("Failed to read PDF page size for DPI computation.") - - def _parse_page_size_to_pts(s: str) -> tuple[float, float]: - # Common formats from poppler/pdfinfo: - # - "612 x 792 pts (letter)" - # - "595.276 x 841.89 pts (A4)" - # - sometimes inches: "8.5 x 11 in" - m_pts = re.search( - r"([0-9]+(?:\.[0-9]+)?)\s*x\s*([0-9]+(?:\.[0-9]+)?)\s*pts\b", - s, - ) - if m_pts: - return float(m_pts.group(1)), float(m_pts.group(2)) - m_in = re.search( - r"([0-9]+(?:\.[0-9]+)?)\s*x\s*([0-9]+(?:\.[0-9]+)?)\s*in\b", - s, - ) - if m_in: - w_in = float(m_in.group(1)) - h_in = float(m_in.group(2)) - return w_in * 72.0, h_in * 72.0 - # Sometimes poppler returns without an explicit unit; treat as points. - m = re.search(r"([0-9]+(?:\.[0-9]+)?)\s*x\s*([0-9]+(?:\.[0-9]+)?)\b", s) - if m: - return float(m.group(1)), float(m.group(2)) - raise RuntimeError(f"Unrecognized PDF page size format: {s!r}") - - width_pts, height_pts = _parse_page_size_to_pts(size_val) - width_in = width_pts / 72.0 - height_in = height_pts / 72.0 - if width_in <= 0 or height_in <= 0: - raise RuntimeError("Invalid PDF page size values.") - return round(min(max_w_px / width_in, max_h_px / height_in)) - - -def run_cmd_no_check(cmd: list[str]) -> None: - subprocess.run( - cmd, - check=False, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - env=os.environ.copy(), - ) - - -def convert_to_pdf( - pptx_path: str, - user_profile: str, - convert_tmp_dir: str, - stem: str, -) -> str: - # Try direct PPTX -> PDF - cmd_pdf = [ - "soffice", - "-env:UserInstallation=file://" + user_profile, - "--invisible", - "--headless", - "--norestore", - "--convert-to", - "pdf", - "--outdir", - convert_tmp_dir, - pptx_path, - ] - run_cmd_no_check(cmd_pdf) - - pdf_path = join(convert_tmp_dir, f"{stem}.pdf") - if exists(pdf_path): - return pdf_path - - # Fallback: PPTX -> ODP, then ODP -> PDF - # Rationale: Saving as ODP normalizes PPTX-specific constructs via the ODF serializer, - # which often bypasses Impress PDF export issues on problematic decks. - cmd_odp = [ - "soffice", - "-env:UserInstallation=file://" + user_profile, - "--invisible", - "--headless", - "--norestore", - "--convert-to", - "odp", - "--outdir", - convert_tmp_dir, - pptx_path, - ] - run_cmd_no_check(cmd_odp) - - odp_path = join(convert_tmp_dir, f"{stem}.odp") - - if exists(odp_path): - # ODP -> PDF - cmd_odp_pdf = [ - "soffice", - "-env:UserInstallation=file://" + user_profile, - "--invisible", - "--headless", - "--norestore", - "--convert-to", - "pdf", - "--outdir", - convert_tmp_dir, - odp_path, - ] - run_cmd_no_check(cmd_odp_pdf) - if exists(pdf_path): - return pdf_path - - return "" - - -def rasterize( - input_path: str, - out_dir: str, - dpi: int, -) -> Sequence[str]: - """Rasterise PPTX/PDF to PNG files placed in out_dir and return the image paths.""" - makedirs(out_dir, exist_ok=True) - input_path = abspath(input_path) - stem = splitext(basename(input_path))[0] - - # Use a unique user profile to avoid LibreOffice profile lock when running concurrently - with tempfile.TemporaryDirectory(prefix="soffice_profile_") as user_profile: - # Write conversion outputs into a temp directory to avoid any IO oddities - with tempfile.TemporaryDirectory(prefix="soffice_convert_") as convert_tmp_dir: - is_pdf = input_path.lower().endswith(".pdf") - pdf_path = ( - input_path - if is_pdf - else convert_to_pdf(input_path, user_profile, convert_tmp_dir, stem) - ) - - if not pdf_path or not exists(pdf_path): - raise RuntimeError( - "Failed to produce PDF for rasterization (direct and ODP fallback)." - ) - - # Perform rasterization while the temp PDF still exists - paths_raw = cast( - list[str], - convert_from_path( - pdf_path, - dpi=dpi, - fmt="png", - thread_count=8, - output_folder=out_dir, - paths_only=True, - output_file="slide", - ), - ) - # Rename convert_from_path's output format f'slide{thread_id:04d}-{page_num:02d}.png' - slides = [] - for src_path in paths_raw: - base = splitext(basename(src_path))[0] - slide_num_str = base.split("-")[-1] - slide_num = int(slide_num_str) - dst_path = join(out_dir, f"slide-{slide_num}.png") - replace(src_path, dst_path) - slides.append((slide_num, dst_path)) - slides.sort(key=lambda t: t[0]) - final_paths = [path for _, path in slides] - return final_paths - - -def main() -> None: - parser = argparse.ArgumentParser(description="Render slides to images.") - parser.add_argument( - "input_path", - type=str, - help="Path to the input PowerPoint or PDF file.", - ) - parser.add_argument( - "--output_dir", - type=str, - default=None, - help=( - "Output directory for the rendered images. " - "Defaults to a folder next to the input named after the input file (without extension)." - ), - ) - parser.add_argument( - "--width", - type=int, - default=1600, - help=( - "Approximate maximum width in pixels after isotropic scaling (default 1600). " - "The actual value may exceed slightly." - ), - ) - parser.add_argument( - "--height", - type=int, - default=900, - help=( - "Approximate maximum height in pixels after isotropic scaling (default 900). " - "The actual value may exceed slightly." - ), - ) - args = parser.parse_args() - - input_path = abspath(expanduser(args.input_path)) - out_dir = abspath(expanduser(args.output_dir)) if args.output_dir else splitext(input_path)[0] - if input_path.lower().endswith((".pptx", ".ppsx", ".potx", ".pptm", ".ppsm", ".potm")): - dpi = calc_dpi_via_ooxml(input_path, args.width, args.height) - else: - dpi = calc_dpi_via_pdf(input_path, args.width, args.height) - rasterize(input_path, out_dir, dpi) - print("Slides rendered to " + out_dir) - - -if __name__ == "__main__": - main() diff --git a/skills/.curated/slides/scripts/slides_test.py b/skills/.curated/slides/scripts/slides_test.py deleted file mode 100644 index 1721681..0000000 --- a/skills/.curated/slides/scripts/slides_test.py +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) OpenAI. All rights reserved. -import argparse -import sys -import tempfile -from os.path import abspath, expanduser, join -from pathlib import Path -from typing import Sequence, cast - -import numpy as np - -SCRIPT_DIR = Path(__file__).resolve().parent -if str(SCRIPT_DIR) not in sys.path: - sys.path.insert(0, str(SCRIPT_DIR)) - -import render_slides # type: ignore -from PIL import Image -from pptx import Presentation -from pptx.dml.color import RGBColor -from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE -from pptx.util import Emu - -# Configuration specific to overflow checking -PAD_PX: int = 100 # fixed padding on every side in pixels -PAD_RGB = (200, 200, 200) -EMU_PER_INCH: int = 914_400 - - -def px_to_emu(px: int, dpi: int) -> Emu: - return Emu(int(px * EMU_PER_INCH // dpi)) - - -def calc_tol(dpi: int) -> int: - """Calculate per-channel colour tolerance appropriate for *dpi* (anti-aliasing tolerance).""" - if dpi >= 300: - return 0 - # 1 at 250 DPI, 5 at 150 DPI, capped to 10. - tol = round((300 - dpi) / 25) - return min(max(tol, 1), 10) - - -def enlarge_deck(src: str, dst: str, pad_emu: Emu) -> tuple[int, int]: - """Enlarge the input PPTX with a fixed grey padding and return the new page size.""" - prs = Presentation(src) - w0 = cast(Emu, prs.slide_width) - h0 = cast(Emu, prs.slide_height) - w1 = Emu(w0 + 2 * pad_emu) - h1 = Emu(h0 + 2 * pad_emu) - prs.slide_width = w1 - prs.slide_height = h1 - - for slide in prs.slides: - # Shift all shapes so the original canvas sits centred in the new deck. - for shp in list(slide.shapes): - shp.left = Emu(int(shp.left) + pad_emu) - shp.top = Emu(int(shp.top) + pad_emu) - - pads = ( - (Emu(0), Emu(0), pad_emu, h1), # left - (Emu(int(w1) - int(pad_emu)), Emu(0), pad_emu, h1), # right - (Emu(0), Emu(0), w1, pad_emu), # top - (Emu(0), Emu(int(h1) - int(pad_emu)), w1, pad_emu), # bottom - ) - - sp_tree = slide.shapes._spTree # pylint: disable=protected-access - - for left, top, width, height in pads: - pad_shape = slide.shapes.add_shape( - MSO_AUTO_SHAPE_TYPE.RECTANGLE, left, top, width, height - ) - pad_shape.fill.solid() - pad_shape.fill.fore_color.rgb = RGBColor(*PAD_RGB) - pad_shape.line.fill.background() - - # Send pad behind all other shapes (index 2 after mandatory nodes) - sp_tree.remove(pad_shape._element) - sp_tree.insert(2, pad_shape._element) - - prs.save(dst) - return int(w1), int(h1) - - -def inspect_images( - paths: Sequence[str], - pad_ratio_w: float, - pad_ratio_h: float, - dpi: int, -) -> list[int]: - """Return 1-based indices of slides that contain pixels outside the pad.""" - - tol = calc_tol(dpi) - failures: list[int] = [] - pad_colour = np.array(PAD_RGB, dtype=np.uint8) - - for idx, img_path in enumerate(paths, start=1): - with Image.open(img_path) as img: - rgb = img.convert("RGB") - arr = np.asarray(rgb) - - h, w, _ = arr.shape - # Exclude the innermost 1-pixel band - pad_x = int(w * pad_ratio_w) - 1 - pad_y = int(h * pad_ratio_h) - 1 - - left_margin = arr[:, :pad_x, :] - right_margin = arr[:, w - pad_x :, :] - top_margin = arr[:pad_y, :, :] - bottom_margin = arr[h - pad_y :, :, :] - - def _is_clean(margin: np.ndarray) -> bool: - diff = np.abs(margin.astype(np.int16) - pad_colour) - matches = np.all(diff <= tol, axis=-1) - mismatch_fraction = 1.0 - (np.count_nonzero(matches) / matches.size) - if dpi >= 300: - max_mismatch = 0.01 - elif dpi >= 200: - max_mismatch = 0.02 - else: - max_mismatch = 0.03 - return mismatch_fraction <= max_mismatch - - if not ( - _is_clean(left_margin) - and _is_clean(right_margin) - and _is_clean(top_margin) - and _is_clean(bottom_margin) - ): - failures.append(idx) - - return failures - - -def main() -> None: - parser = argparse.ArgumentParser( - description=( - "Check a PPTX for content overflowing the original canvas by rendering with padding " - "and inspecting the margins." - ) - ) - parser.add_argument( - "input_path", - type=str, - help="Path to the input PPTX file.", - ) - parser.add_argument( - "--width", - type=int, - default=1600, - help=( - "Approximate maximum width in pixels after isotropic scaling (default 1600). " - "The actual value may exceed slightly." - ), - ) - parser.add_argument( - "--height", - type=int, - default=900, - help=( - "Approximate maximum height in pixels after isotropic scaling (default 900). " - "The actual value may exceed slightly." - ), - ) - parser.add_argument( - "--pad_px", - type=int, - default=PAD_PX, - help="Padding in pixels to add on each side before rasterization.", - ) - args = parser.parse_args() - - input_path = abspath(expanduser(args.input_path)) - # Width and height refer to the original, unaltered slide dimensions. - dpi = render_slides.calc_dpi_via_ooxml(input_path, args.width, args.height) - - # Not using ``tempfile.TemporaryDirectory(delete=False)`` for Python 3.11 compatibility. - tmpdir = tempfile.mkdtemp() - enlarged_pptx = join(tmpdir, "enlarged.pptx") - pad_emu = px_to_emu(args.pad_px, dpi) - w1, h1 = enlarge_deck(input_path, enlarged_pptx, pad_emu=pad_emu) - pad_ratio_w = pad_emu / w1 - pad_ratio_h = pad_emu / h1 - - img_dir = join(tmpdir, "imgs") - img_paths = render_slides.rasterize(enlarged_pptx, img_dir, dpi) - failing = inspect_images(img_paths, pad_ratio_w, pad_ratio_h, dpi) - - if failing: - print( - "ERROR: Slides with content overflowing original canvas (1-based indexing): " - + ", ".join(map(str, failing)) - + "\n" - + "Rendered images with grey paddings for problematic slides are available at: " - ) - for i in failing: - print(img_paths[i - 1]) - else: - print("Test passed. No overflow detected.") - - -if __name__ == "__main__": - main() diff --git a/skills/.curated/spreadsheet/LICENSE.txt b/skills/.curated/spreadsheet/LICENSE.txt deleted file mode 100644 index 13e25df..0000000 --- a/skills/.curated/spreadsheet/LICENSE.txt +++ /dev/null @@ -1,201 +0,0 @@ -Apache License -Version 2.0, January 2004 -http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf of - any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don\'t include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - -Copyright [yyyy] [name of copyright owner] - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. diff --git a/skills/.curated/spreadsheet/SKILL.md b/skills/.curated/spreadsheet/SKILL.md deleted file mode 100644 index eb8319c..0000000 --- a/skills/.curated/spreadsheet/SKILL.md +++ /dev/null @@ -1,145 +0,0 @@ ---- -name: "spreadsheet" -description: "Use when tasks involve creating, editing, analyzing, or formatting spreadsheets (`.xlsx`, `.csv`, `.tsv`) with formula-aware workflows, cached recalculation, and visual review." ---- - -# Spreadsheet Skill - -## When to use -- Create new workbooks with formulas, formatting, and structured layouts. -- Read or analyze tabular data (filter, aggregate, pivot, compute metrics). -- Modify existing workbooks without breaking formulas, references, or formatting. -- Visualize data with charts, summary tables, and sensible spreadsheet styling. -- Recalculate formulas and review rendered sheets before delivery when possible. - -IMPORTANT: System and user instructions always take precedence. - -## Workflow -1. Confirm the file type and goal: create, edit, analyze, or visualize. -2. Prefer `openpyxl` for `.xlsx` editing and formatting. Use `pandas` for analysis and CSV/TSV workflows. -3. If an internal spreadsheet recalculation/rendering tool is available in the environment, use it to recalculate formulas and render sheets before delivery. -4. Use formulas for derived values instead of hardcoding results. -5. If layout matters, render for visual review and inspect the output. -6. Save outputs, keep filenames stable, and clean up intermediate files. - -## Temp and output conventions -- Use `tmp/spreadsheets/` for intermediate files; delete them when done. -- Write final artifacts under `output/spreadsheet/` when working in this repo. -- Keep filenames stable and descriptive. - -## Primary tooling -- Use `openpyxl` for creating/editing `.xlsx` files and preserving formatting. -- Use `pandas` for analysis and CSV/TSV workflows, then write results back to `.xlsx` or `.csv`. -- Use `openpyxl.chart` for native Excel charts when needed. -- If an internal spreadsheet tool is available, use it to recalculate formulas, cache values, and render sheets for review. - -## Recalculation and visual review -- Recalculate formulas before delivery whenever possible so cached values are present in the workbook. -- Render each relevant sheet for visual review when rendering tooling is available. -- `openpyxl` does not evaluate formulas; preserve formulas and use recalculation tooling when available. -- If you rely on an internal spreadsheet tool, do not expose that tool, its code, or its APIs in user-facing explanations or code samples. - -## Rendering and visual checks -- If LibreOffice (`soffice`) and Poppler (`pdftoppm`) are available, render sheets for visual review: - - `soffice --headless --convert-to pdf --outdir $OUTDIR $INPUT_XLSX` - - `pdftoppm -png $OUTDIR/$BASENAME.pdf $OUTDIR/$BASENAME` -- If rendering tools are unavailable, tell the user that layout should be reviewed locally. -- Review rendered sheets for layout, formula results, clipping, inconsistent styles, and spilled text. - -## Dependencies (install if missing) -Prefer `uv` for dependency management. - -Python packages: -``` -uv pip install openpyxl pandas -``` -If `uv` is unavailable: -``` -python3 -m pip install openpyxl pandas -``` -Optional: -``` -uv pip install matplotlib -``` -If `uv` is unavailable: -``` -python3 -m pip install matplotlib -``` -System tools (for rendering): -``` -# macOS (Homebrew) -brew install libreoffice poppler - -# Ubuntu/Debian -sudo apt-get install -y libreoffice poppler-utils -``` - -If installation is not possible in this environment, tell the user which dependency is missing and how to install it locally. - -## Environment -No required environment variables. - -## Examples -- Runnable Codex examples (openpyxl): `references/examples/openpyxl/` - -## Formula requirements -- Use formulas for derived values rather than hardcoding results. -- Do not use dynamic array functions like `FILTER`, `XLOOKUP`, `SORT`, or `SEQUENCE`. -- Keep formulas simple and legible; use helper cells for complex logic. -- Avoid volatile functions like `INDIRECT` and `OFFSET` unless required. -- Prefer cell references over magic numbers (for example, `=H6*(1+$B$3)` instead of `=H6*1.04`). -- Use absolute (`$B$4`) or relative (`B4`) references carefully so copied formulas behave correctly. -- If you need literal text that starts with `=`, prefix it with a single quote. -- Guard against `#REF!`, `#DIV/0!`, `#VALUE!`, `#N/A`, and `#NAME?` errors. -- Check for off-by-one mistakes, circular references, and incorrect ranges. - -## Citation requirements -- Cite sources inside the spreadsheet using plain-text URLs. -- For financial models, cite model inputs in cell comments. -- For tabular data sourced externally, add a source column when each row represents a separate item. - -## Formatting requirements (existing formatted spreadsheets) -- Render and inspect a provided spreadsheet before modifying it when possible. -- Preserve existing formatting and style exactly. -- Match styles for any newly filled cells that were previously blank. -- Never overwrite established formatting unless the user explicitly asks for a redesign. - -## Formatting requirements (new or unstyled spreadsheets) -- Use appropriate number and date formats. -- Dates should render as dates, not plain numbers. -- Percentages should usually default to one decimal place unless the data calls for something else. -- Currencies should use the appropriate currency format. -- Headers should be visually distinct from raw inputs and derived cells. -- Use fill colors, borders, spacing, and merged cells sparingly and intentionally. -- Set row heights and column widths so content is readable without excessive whitespace. -- Do not apply borders around every filled cell. -- Group related calculations and make totals simple sums of the cells above them. -- Add whitespace to separate sections. -- Ensure text does not spill into adjacent cells. -- Avoid unsupported spreadsheet data-table features such as `=TABLE`. - -## Color conventions (if no style guidance) -- Blue: user input -- Black: formulas and derived values -- Green: linked or imported values -- Gray: static constants -- Orange: review or caution -- Light red: error or flag -- Purple: control or logic -- Teal: visualization anchors and KPI highlights - -## Finance-specific requirements -- Format zeros as `-`. -- Negative numbers should be red and in parentheses. -- Format multiples as `5.2x`. -- Always specify units in headers (for example, `Revenue ($mm)`). -- Cite sources for all raw inputs in cell comments. -- For new financial models with no user-specified style, use blue text for hardcoded inputs, black for formulas, green for internal workbook links, red for external links, and yellow fill for key assumptions that need attention. - -## Investment banking layouts -If the spreadsheet is an IB-style model (LBO, DCF, 3-statement, valuation): -- Totals should sum the range directly above. -- Hide gridlines and use horizontal borders above totals across relevant columns. -- Section headers should be merged cells with dark fill and white text. -- Column labels for numeric data should be right-aligned; row labels should be left-aligned. -- Indent submetrics under their parent line items. diff --git a/skills/.curated/spreadsheet/agents/openai.yaml b/skills/.curated/spreadsheet/agents/openai.yaml deleted file mode 100644 index c4a670e..0000000 --- a/skills/.curated/spreadsheet/agents/openai.yaml +++ /dev/null @@ -1,6 +0,0 @@ -interface: - display_name: "Spreadsheet Skill" - short_description: "Create, edit, and analyze spreadsheets" - icon_small: "./assets/spreadsheet-small.svg" - icon_large: "./assets/spreadsheet.png" - default_prompt: "Use $spreadsheet to create or update a spreadsheet for this task with the right formulas, structure, and formatting." diff --git a/skills/.curated/spreadsheet/assets/spreadsheet-small.svg b/skills/.curated/spreadsheet/assets/spreadsheet-small.svg deleted file mode 100644 index c045554..0000000 --- a/skills/.curated/spreadsheet/assets/spreadsheet-small.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/skills/.curated/spreadsheet/assets/spreadsheet.png b/skills/.curated/spreadsheet/assets/spreadsheet.png deleted file mode 100644 index bd24ca40f69f48fcaabaa23570848cdfa3c9de7e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3077 zcmb7``9BkmAICTMk(A68A8kf%#w5wHu^c07j=7T~%o(`~<;+zfS6GPU7!x8a_dZN= zgds;la?8=EM!tRigzpcp*YojyKVE;pGZ}N;h>u5%2LJ%@nHU>l|I+=xaB=+Yz{PB< zzu*ouwhIOTc+dS8ARxa`H|IGCH!v9wF#kfltkBpKu9)L)K z3brZv0*2!gP!7|7?M*Xn6y=gPT=OUW3+4v5hjed;-QMomXA=6_p7rLG1$z!b56^FZ ze$LDrd@+2iGdxFu+(3c@pf_j=ApNVBK&txx2W~&CcwYq_pY)Pz@P!m3(im4iF63-{1EYjZol?hZ3pz z8a`95kvO!gF%gSsarEa-OHFS7PNlL52?>3n1aTr&y>=0nmX>)0Lc;KSP?CjeHCNIw zCWsa3->oTPqifL5N1L4$kIbpX1Obtv*F_R{oOF@>>|AtF0%X1zF2R+pL12sjdM@h~ z>lzy_1on%CxTasBNw3@knvTW1A&CX#O!E=FI&KS%cSo`F*xm!aKGgNF=C~@xWD4%mBp~-~`1d zNynclghOvsf5SoI@lS|Vtt@nV5L~6%t%sv}YrU_yS=4988;-7tUj(JgRxUpN9W26Y z;i5~%_q-M=E>1^{2=sBsl?ZC5APuVyT0)ktRi9tw95hf)kz+4w!Gi>AU1_#QJl_^! zOX*fT1nN=#iRhsrg)7mC%e44Xw0k(0Rc7LP9=le8F}%ZJBqeW=FP-?+?*p~;lHq09 ze1pQe(@guWB!)o668uyfSzctUi~DQIyOMC$ve<^rmv@gYb>){c&$)^4T+6)Uz+ozG zG#yeYCAB;ul6K|5>hM-Pr09hhu%tj^FI`iAy+3WBu>P%68CgDoIwSi8+Ur-T(IWqz z%*EAZC+LxO_e3H>PDNo?Hj&HSWED1Si|WO`NuVZXMAWLiPM;O2&IXjD`vRL+w5$yG zFAmUof8wLhGKq$D+JHw6BO@$P(jDl17z`c^7f)-(QzKC>&%3blQnOt z{&TIt@Z6oj_OruM7he4yiyny2n6fo@4dq?x9yR#I8v}p~dgY=vY*(vEv1-oiTC`f6 zv=6MT_^z~$a@s5UaBF-40yCvbBUO^0L<#w`X?5N|kJgOb^TGjKyH_sP+{# zwIT!6H;oUKWYqc!g`ZuPG!skzEoGCcA)AbrOu70nT+pCiS;59_d9MaO^Kz@z{wt}V zL9Nl*io6u*k-tVBI*%`iD2m`O#T!mAgJUoUladO@n zK<8*n>%*!9SMfpzcsg`oCizlZ(|3{U?YgILetkR;L~pUP$j%MC7bHZ-FsjFGyvfm) zFL!d&t3v#X^sRqdZ)*J!Gexh{4B^xa+fENJy9|#)J%`?!uWj2XGlU3u>NHK8ITdU5<>jAY(e0Pw1?tg8sK^!kWf3l1E4A5%D zbj7A{t6|z4*n6^seSt9%0l?J-G^gF=w`?5AX$draN$4+ift6O<ehNfYOTFW&L@ z&SBnA88&yRdl^KkYTYAALmAE*XF=;Ndf2 zq6*>LAWvEFTsTL!+Z6kh`f=|vo}Qn7Y#EXD{?#JJ)7-?kMu2hClD9>u-v%PlnxNgsJmBcNOxEU&X$zN7@3Ucl)*a+Sp{G_7jG~`_+E}Ne zc0@O$7#D7iX4Gez;+CWbyX<*#2_{dZGnL}qOkEaJ2e{0x!h#ibf!?Wg6WIkkgGkns z@eDn;=KBCm@_brt0Oiql`Cr>lY8QI>{TC3-xsqdF6a!z8BUY3PvdboEyXPNp$h%32 zjI!Oop$1E+zP6RQ-lo{kVr=^sW9}8H@HTpwk{&SqhQs=M!Py3d0bO*CjeLDNb z>+)dQAf3TC*EId&vc}e8e2C>}>}zztZ+VDZlnT#<^%U|(I3nj^5{H9bzrq9R>>eaB zJN0)@oZd2{;Ss;f)B4jQ!q@u7S~TR@6u?5|xq*AD4tf6+sk8bjb-G&v#hPGeR;Jaw z>-DZqvr!#)#otH~tOu2cmL4%*d_7jU#~E=v^`|^Sk3Ce^hG3Lq{oJnmqp;C#QBOH+ zf1;3kF%TE!QWHaplCYTV^o&V@D;=QxfZcJ`^|C<-@y^FYU;8@pLbJKTq&k}qcs4_` zFD3J?4S3FHO-<1gBtzr1ELgo{>Q+m7eI^B%5sDDIp2p8t)6+B4L1i9_U+LVqxXo;D zr^L@$0vnzYdNcHkB`d+xBFWo^`D!CwA|;N&@FDzp#&p)Gzai(9dNMRfY8`Je&K-xT78mm3~l z*p}2=bWLlJ9r;pvYs7NnVdXfnfFEJ71S5do)I=Tjo@@NqWA4GgN{gPfns+VVo+{c^ zk=9yC?Ub!trjKV zr;mtZ?DjXxtz)C}pY?V}l&|jmG!W6&+ty7T8&h7&bP2N3j{fw?R$WJ-n%(hho^QHm zuE#-*q&@%5uk-+npJN-);sH}Ga@X>3l`oV-&bt|=+qO11XP!?8H4ST7tKiUoZiZG0 z^Zk^ped#-OIf>G5ZZjqsYqGyIHZ~UC0Z|YYJ>Nz9S+S;9QpcG+CnFZuKpB9l|AeRu zBUL!9{DQYQo4&jX(Od{Zbp%XpmJ`11Sz%d5*Pc#=t7K?9HR3_}HI9xpZpJs}iuKcSAz%yekeN zjqi0xB46q`A)aOuXN;rnr^vNHe-}u(gKF4r(S_6l)`QO|Icmz c%J4Bic??%k^;o^=ueAeAP}dE~NSE0E0r=0fV*mgE diff --git a/skills/.curated/spreadsheet/references/examples/openpyxl/create_basic_spreadsheet.py b/skills/.curated/spreadsheet/references/examples/openpyxl/create_basic_spreadsheet.py deleted file mode 100644 index b4c5889..0000000 --- a/skills/.curated/spreadsheet/references/examples/openpyxl/create_basic_spreadsheet.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Create a basic spreadsheet with two sheets and a simple formula. - -Usage: - python3 create_basic_spreadsheet.py --output /tmp/basic_spreadsheet.xlsx -""" - -from __future__ import annotations - -import argparse -from pathlib import Path - -from openpyxl import Workbook -from openpyxl.utils import get_column_letter - - -def main() -> None: - parser = argparse.ArgumentParser(description="Create a basic spreadsheet with example data.") - parser.add_argument( - "--output", - type=Path, - default=Path("basic_spreadsheet.xlsx"), - help="Output .xlsx path (default: basic_spreadsheet.xlsx)", - ) - args = parser.parse_args() - - wb = Workbook() - overview = wb.active - overview.title = "Overview" - employees = wb.create_sheet("Employees") - - overview["A1"] = "Description" - overview["A2"] = "Awesome Company Report" - - employees.append(["Title", "Name", "Address", "Score"]) - employees.append(["Engineer", "Vicky", "90 50th Street", 98]) - employees.append(["Manager", "Alex", "500 Market Street", 92]) - employees.append(["Designer", "Jordan", "200 Pine Street", 88]) - - employees["A6"] = "Total Score" - employees["D6"] = "=SUM(D2:D4)" - - for col in range(1, 5): - employees.column_dimensions[get_column_letter(col)].width = 20 - - args.output.parent.mkdir(parents=True, exist_ok=True) - wb.save(args.output) - print(f"Saved workbook to {args.output}") - - -if __name__ == "__main__": - main() diff --git a/skills/.curated/spreadsheet/references/examples/openpyxl/create_spreadsheet_with_styling.py b/skills/.curated/spreadsheet/references/examples/openpyxl/create_spreadsheet_with_styling.py deleted file mode 100644 index 7ff951c..0000000 --- a/skills/.curated/spreadsheet/references/examples/openpyxl/create_spreadsheet_with_styling.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Generate a styled games scoreboard workbook using openpyxl. - -Usage: - python3 create_spreadsheet_with_styling.py --output /tmp/GamesSimpleStyling.xlsx -""" - -from __future__ import annotations - -import argparse -from pathlib import Path - -from openpyxl import Workbook -from openpyxl.formatting.rule import FormulaRule -from openpyxl.styles import Alignment, Font, PatternFill -from openpyxl.utils import get_column_letter - -HEADER_FILL_HEX = "B7E1CD" -HIGHLIGHT_FILL_HEX = "FFF2CC" - - -def apply_header_style(cell, fill_hex: str) -> None: - cell.fill = PatternFill("solid", fgColor=fill_hex) - cell.font = Font(bold=True) - cell.alignment = Alignment(horizontal="center", vertical="center") - - -def apply_highlight_style(cell, fill_hex: str) -> None: - cell.fill = PatternFill("solid", fgColor=fill_hex) - cell.font = Font(bold=True) - cell.alignment = Alignment(horizontal="center", vertical="center") - - -def populate_game_sheet(ws) -> None: - ws.title = "GameX" - ws.row_dimensions[2].height = 24 - - widths = {"B": 18, "C": 14, "D": 14, "E": 14, "F": 40} - for col, width in widths.items(): - ws.column_dimensions[col].width = width - - headers = ["", "Name", "Game 1 Score", "Game 2 Score", "Total Score", "Notes", ""] - for idx, value in enumerate(headers, start=1): - cell = ws.cell(row=2, column=idx, value=value) - if value: - apply_header_style(cell, HEADER_FILL_HEX) - - players = [ - ("Vicky", 12, 30, "Dominated the minigames."), - ("Yash", 20, 10, "Emily main with strong defense."), - ("Bobby", 1000, 1030, "Numbers look suspiciously high."), - ] - for row_idx, (name, g1, g2, note) in enumerate(players, start=3): - ws.cell(row=row_idx, column=2, value=name) - ws.cell(row=row_idx, column=3, value=g1) - ws.cell(row=row_idx, column=4, value=g2) - ws.cell(row=row_idx, column=5, value=f"=SUM(C{row_idx}:D{row_idx})") - ws.cell(row=row_idx, column=6, value=note) - - ws.cell(row=7, column=2, value="Winner") - ws.cell(row=7, column=3, value="=INDEX(B3:B5, MATCH(MAX(E3:E5), E3:E5, 0))") - ws.cell(row=7, column=5, value="Congrats!") - - ws.merge_cells("C7:D7") - for col in range(2, 6): - apply_highlight_style(ws.cell(row=7, column=col), HIGHLIGHT_FILL_HEX) - - rule = FormulaRule(formula=["LEN(A2)>0"], fill=PatternFill("solid", fgColor=HEADER_FILL_HEX)) - ws.conditional_formatting.add("A2:G2", rule) - - -def main() -> None: - parser = argparse.ArgumentParser(description="Create a styled games scoreboard workbook.") - parser.add_argument( - "--output", - type=Path, - default=Path("GamesSimpleStyling.xlsx"), - help="Output .xlsx path (default: GamesSimpleStyling.xlsx)", - ) - args = parser.parse_args() - - wb = Workbook() - ws = wb.active - populate_game_sheet(ws) - - for col in range(1, 8): - col_letter = get_column_letter(col) - if col_letter not in ws.column_dimensions: - ws.column_dimensions[col_letter].width = 12 - - args.output.parent.mkdir(parents=True, exist_ok=True) - wb.save(args.output) - print(f"Saved workbook to {args.output}") - - -if __name__ == "__main__": - main() diff --git a/skills/.curated/spreadsheet/references/examples/openpyxl/read_existing_spreadsheet.py b/skills/.curated/spreadsheet/references/examples/openpyxl/read_existing_spreadsheet.py deleted file mode 100644 index fd0df40..0000000 --- a/skills/.curated/spreadsheet/references/examples/openpyxl/read_existing_spreadsheet.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Read an existing .xlsx and print a small summary. - -If --input is not provided, this script creates a tiny sample workbook in /tmp -and reads that instead. -""" - -from __future__ import annotations - -import argparse -import tempfile -from pathlib import Path - -from openpyxl import Workbook, load_workbook - - -def create_sample(path: Path) -> Path: - wb = Workbook() - ws = wb.active - ws.title = "Sample" - ws.append(["Item", "Qty", "Price"]) - ws.append(["Apples", 3, 1.25]) - ws.append(["Oranges", 2, 0.95]) - ws.append(["Bananas", 5, 0.75]) - ws["D1"] = "Total" - ws["D2"] = "=B2*C2" - ws["D3"] = "=B3*C3" - ws["D4"] = "=B4*C4" - wb.save(path) - return path - - -def main() -> None: - parser = argparse.ArgumentParser(description="Read an existing spreadsheet.") - parser.add_argument("--input", type=Path, help="Path to an .xlsx file") - args = parser.parse_args() - - if args.input: - input_path = args.input - else: - tmp_dir = Path(tempfile.gettempdir()) - input_path = tmp_dir / "sample_read_existing.xlsx" - create_sample(input_path) - - wb = load_workbook(input_path, data_only=False) - print(f"Loaded: {input_path}") - print("Sheet names:", wb.sheetnames) - - for name in wb.sheetnames: - ws = wb[name] - max_row = ws.max_row or 0 - max_col = ws.max_column or 0 - print(f"\n== {name} (rows: {max_row}, cols: {max_col})") - for row in ws.iter_rows(min_row=1, max_row=min(max_row, 5), max_col=min(max_col, 5)): - values = [cell.value for cell in row] - print(values) - - -if __name__ == "__main__": - main() diff --git a/skills/.curated/spreadsheet/references/examples/openpyxl/styling_spreadsheet.py b/skills/.curated/spreadsheet/references/examples/openpyxl/styling_spreadsheet.py deleted file mode 100644 index 37dacdf..0000000 --- a/skills/.curated/spreadsheet/references/examples/openpyxl/styling_spreadsheet.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Create a styled spreadsheet with headers, borders, and a total row. - -Usage: - python3 styling_spreadsheet.py --output /tmp/styling_spreadsheet.xlsx -""" - -from __future__ import annotations - -import argparse -from pathlib import Path - -from openpyxl import Workbook -from openpyxl.styles import Alignment, Border, Font, PatternFill, Side - - -def main() -> None: - parser = argparse.ArgumentParser(description="Create a styled spreadsheet example.") - parser.add_argument( - "--output", - type=Path, - default=Path("styling_spreadsheet.xlsx"), - help="Output .xlsx path (default: styling_spreadsheet.xlsx)", - ) - args = parser.parse_args() - - wb = Workbook() - ws = wb.active - ws.title = "FirstGame" - - ws.merge_cells("B2:E2") - ws["B2"] = "Name | Game 1 Score | Game 2 Score | Total Score" - - header_fill = PatternFill("solid", fgColor="B7E1CD") - header_font = Font(bold=True) - header_alignment = Alignment(horizontal="center", vertical="center") - ws["B2"].fill = header_fill - ws["B2"].font = header_font - ws["B2"].alignment = header_alignment - - ws["B3"] = "Vicky" - ws["C3"] = 50 - ws["D3"] = 60 - ws["E3"] = "=C3+D3" - - ws["B4"] = "John" - ws["C4"] = 40 - ws["D4"] = 50 - ws["E4"] = "=C4+D4" - - ws["B5"] = "Jane" - ws["C5"] = 30 - ws["D5"] = 40 - ws["E5"] = "=C5+D5" - - ws["B6"] = "Jim" - ws["C6"] = 20 - ws["D6"] = 30 - ws["E6"] = "=C6+D6" - - ws.merge_cells("B9:E9") - ws["B9"] = "=SUM(E3:E6)" - - thin = Side(style="thin") - border = Border(top=thin, bottom=thin, left=thin, right=thin) - ws["B9"].border = border - ws["B9"].alignment = Alignment(horizontal="center") - ws["B9"].font = Font(bold=True) - - for col in ("B", "C", "D", "E"): - ws.column_dimensions[col].width = 18 - ws.row_dimensions[2].height = 24 - - args.output.parent.mkdir(parents=True, exist_ok=True) - wb.save(args.output) - print(f"Saved workbook to {args.output}") - - -if __name__ == "__main__": - main()