diff --git a/agents/.agents/skills/doc/LICENSE.txt b/agents/.agents/skills/doc/LICENSE.txt new file mode 100644 index 0000000..13e25df --- /dev/null +++ b/agents/.agents/skills/doc/LICENSE.txt @@ -0,0 +1,201 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf of + any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don\'t include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [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/agents/.agents/skills/doc/SKILL.md b/agents/.agents/skills/doc/SKILL.md new file mode 100644 index 0000000..c6d9612 --- /dev/null +++ b/agents/.agents/skills/doc/SKILL.md @@ -0,0 +1,80 @@ +--- +name: "doc" +description: "Use when the task involves reading, creating, or editing `.docx` documents, especially when formatting or layout fidelity matters; prefer `python-docx` plus the bundled `scripts/render_docx.py` for visual checks." +--- + + +# DOCX Skill + +## When to use +- Read or review DOCX content where layout matters (tables, diagrams, pagination). +- Create or edit DOCX files with professional formatting. +- Validate visual layout before delivery. + +## Workflow +1. Prefer visual review (layout, tables, diagrams). + - If `soffice` and `pdftoppm` are available, convert DOCX -> PDF -> PNGs. + - Or use `scripts/render_docx.py` (requires `pdf2image` and Poppler). + - If these tools are missing, install them or ask the user to review rendered pages locally. +2. Use `python-docx` for edits and structured creation (headings, styles, tables, lists). +3. After each meaningful change, re-render and inspect the pages. +4. If visual review is not possible, extract text with `python-docx` as a fallback and call out layout risk. +5. Keep intermediate outputs organized and clean up after final approval. + +## Temp and output conventions +- Use `tmp/docs/` for intermediate files; delete when done. +- Write final artifacts under `output/doc/` when working in this repo. +- Keep filenames stable and descriptive. + +## Dependencies (install if missing) +Prefer `uv` for dependency management. + +Python packages: +``` +uv pip install python-docx pdf2image +``` +If `uv` is unavailable: +``` +python3 -m pip install python-docx pdf2image +``` +System tools (for rendering): +``` +# macOS (Homebrew) +brew install libreoffice poppler + +# Ubuntu/Debian +sudo apt-get install -y libreoffice poppler-utils +``` + +If installation isn't possible in this environment, tell the user which dependency is missing and how to install it locally. + +## Environment +No required environment variables. + +## Rendering commands +DOCX -> PDF: +``` +soffice -env:UserInstallation=file:///tmp/lo_profile_$$ --headless --convert-to pdf --outdir $OUTDIR $INPUT_DOCX +``` + +PDF -> PNGs: +``` +pdftoppm -png $OUTDIR/$BASENAME.pdf $OUTDIR/$BASENAME +``` + +Bundled helper: +``` +python3 scripts/render_docx.py /path/to/file.docx --output_dir /tmp/docx_pages +``` + +## Quality expectations +- Deliver a client-ready document: consistent typography, spacing, margins, and clear hierarchy. +- Avoid formatting defects: clipped/overlapping text, broken tables, unreadable characters, or default-template styling. +- Charts, tables, and visuals must be legible in rendered pages with correct alignment. +- Use ASCII hyphens only. Avoid U+2011 (non-breaking hyphen) and other Unicode dashes. +- Citations and references must be human-readable; never leave tool tokens or placeholder strings. + +## Final checks +- Re-render and inspect every page at 100% zoom before final delivery. +- Fix any spacing, alignment, or pagination issues and repeat the render loop. +- Confirm there are no leftovers (temp files, duplicate renders) unless the user asks to keep them. diff --git a/agents/.agents/skills/doc/agents/openai.yaml b/agents/.agents/skills/doc/agents/openai.yaml new file mode 100644 index 0000000..27ce451 --- /dev/null +++ b/agents/.agents/skills/doc/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Word Docs" + short_description: "Edit and review docx files" + icon_small: "./assets/doc-small.svg" + icon_large: "./assets/doc.png" + default_prompt: "Edit or review this .docx file and return the updated file plus a concise change summary." diff --git a/agents/.agents/skills/doc/assets/doc-small.svg b/agents/.agents/skills/doc/assets/doc-small.svg new file mode 100644 index 0000000..97289eb --- /dev/null +++ b/agents/.agents/skills/doc/assets/doc-small.svg @@ -0,0 +1,3 @@ + + + diff --git a/agents/.agents/skills/doc/assets/doc.png b/agents/.agents/skills/doc/assets/doc.png new file mode 100644 index 0000000..e165178 Binary files /dev/null and b/agents/.agents/skills/doc/assets/doc.png differ diff --git a/agents/.agents/skills/doc/scripts/render_docx.py b/agents/.agents/skills/doc/scripts/render_docx.py new file mode 100755 index 0000000..907ec89 --- /dev/null +++ b/agents/.agents/skills/doc/scripts/render_docx.py @@ -0,0 +1,296 @@ +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 shutil import which +import sys +from typing import Sequence, cast +from zipfile import ZipFile + +from pdf2image import convert_from_path, pdfinfo_from_path + +TWIPS_PER_INCH: int = 1440 + + +def ensure_system_tools() -> None: + missing: list[str] = [] + for tool in ("soffice", "pdftoppm"): + if which(tool) is None: + missing.append(tool) + if missing: + tools = ", ".join(missing) + raise RuntimeError( + f"Missing required system tool(s): {tools}. Install LibreOffice and Poppler, then retry." + ) + + +def calc_dpi_via_ooxml_docx(input_path: str, max_w_px: int, max_h_px: int) -> int: + """Calculate DPI from OOXML `word/document.xml` page size (w:pgSz in twips). + + DOCX stores page dimensions in section properties as twips (1/1440 inch). + We read the first encountered section's page size and compute an isotropic DPI + that fits within the target max pixel dimensions. + """ + with ZipFile(input_path, "r") as zf: + xml = zf.read("word/document.xml") + root = ET.fromstring(xml) + ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"} + + # Common placements: w:body/w:sectPr or w:body/w:p/w:pPr/w:sectPr + sect_pr = root.find(".//w:sectPr", ns) + if sect_pr is None: + raise RuntimeError("Section properties not found in document.xml") + pg_sz = sect_pr.find("w:pgSz", ns) + if pg_sz is None: + raise RuntimeError("Page size not found in section properties") + + # Values are in twips + w_twips_str = pg_sz.get( + "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}w" + ) or pg_sz.get("w") + h_twips_str = pg_sz.get( + "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}h" + ) or pg_sz.get("h") + + if not w_twips_str or not h_twips_str: + raise RuntimeError("Page size attributes missing in pgSz") + + width_in = int(w_twips_str) / TWIPS_PER_INCH + height_in = int(h_twips_str) / TWIPS_PER_INCH + if width_in <= 0 or height_in <= 0: + raise RuntimeError("Invalid page size values in document.xml") + 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: + """Convert input to PDF and compute DPI from its page size.""" + 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 = convert_to_pdf(input_path, user_profile, convert_tmp_dir, stem) + if not (pdf_path and exists(pdf_path)): + raise RuntimeError("Failed to convert input to 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.") + + m = re.search(r"(\d+)\s*x\s*(\d+)\s*pts", size_val) + if not m: + raise RuntimeError("Unrecognized PDF page size format.") + width_pts = int(m.group(1)) + height_pts = int(m.group(2)) + 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( + doc_path: str, + user_profile: str, + convert_tmp_dir: str, + stem: str, +) -> str: + # Try direct DOC(X) -> PDF + cmd_pdf = [ + "soffice", + "-env:UserInstallation=file://" + user_profile, + "--invisible", + "--headless", + "--norestore", + "--convert-to", + "pdf", + "--outdir", + convert_tmp_dir, + doc_path, + ] + run_cmd_no_check(cmd_pdf) + + pdf_path = join(convert_tmp_dir, f"{stem}.pdf") + if exists(pdf_path): + return pdf_path + + # Fallback: DOCX -> ODT, then ODT -> PDF + cmd_odt = [ + "soffice", + "-env:UserInstallation=file://" + user_profile, + "--invisible", + "--headless", + "--norestore", + "--convert-to", + "odt", + "--outdir", + convert_tmp_dir, + doc_path, + ] + run_cmd_no_check(cmd_odt) + + odt_path = join(convert_tmp_dir, f"{stem}.odt") + + if exists(odt_path): + cmd_odt_pdf = [ + "soffice", + "-env:UserInstallation=file://" + user_profile, + "--invisible", + "--headless", + "--norestore", + "--convert-to", + "pdf", + "--outdir", + convert_tmp_dir, + odt_path, + ] + run_cmd_no_check(cmd_odt_pdf) + if exists(pdf_path): + return pdf_path + + return "" + + +def rasterize( + doc_path: str, + out_dir: str, + dpi: int, +) -> Sequence[str]: + """Rasterise DOCX (or similar) to images placed in out_dir and return their paths. + + Images are named as page-. with pages starting at 1. + """ + makedirs(out_dir, exist_ok=True) + doc_path = abspath(doc_path) + stem = splitext(basename(doc_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: + pdf_path = convert_to_pdf( + doc_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 ODT fallback)." + ) + 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="page", + ), + ) + + # Rename convert_from_path's output format f'page{thread_id:04d}-{page_num:02d}.' to 'page-.' + pages: list[tuple[int, str]] = [] + for src_path in paths_raw: + base = splitext(basename(src_path))[0] + page_num_str = base.split("-")[-1] + page_num = int(page_num_str) + dst_path = join(out_dir, f"page-{page_num}.png") + replace(src_path, dst_path) + pages.append((page_num, dst_path)) + pages.sort(key=lambda t: t[0]) + final_paths = [path for _, path in pages] + return final_paths + + +def main() -> None: + parser = argparse.ArgumentParser(description="Render DOCX-like file to PNG images.") + parser.add_argument( + "input_path", + type=str, + help="Path to the input DOCX file (or compatible).", + ) + 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=2000, + help=( + "Approximate maximum height in pixels after isotropic scaling (default 2000). " + "The actual value may exceed slightly." + ), + ) + parser.add_argument( + "--dpi", + type=int, + default=None, + help=("Override computed DPI. If provided, skips DOCX/PDF-based DPI calculation."), + ) + args = parser.parse_args() + + try: + ensure_system_tools() + + input_path = abspath(expanduser(args.input_path)) + out_dir = ( + abspath(expanduser(args.output_dir)) if args.output_dir else splitext(input_path)[0] + ) + + if args.dpi is not None: + dpi = int(args.dpi) + else: + try: + if input_path.lower().endswith((".docx", ".docm", ".dotx", ".dotm")): + dpi = calc_dpi_via_ooxml_docx(input_path, args.width, args.height) + else: + raise RuntimeError("Skip OOXML DPI; not a DOCX container") + except Exception: + dpi = calc_dpi_via_pdf(input_path, args.width, args.height) + + rasterize(input_path, out_dir, dpi) + print("Pages rendered to " + out_dir) + except RuntimeError as exc: + print(f"Error: {exc}", file=sys.stderr) + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/agents/.agents/skills/jupyter-notebook/LICENSE.txt b/agents/.agents/skills/jupyter-notebook/LICENSE.txt new file mode 100644 index 0000000..13e25df --- /dev/null +++ b/agents/.agents/skills/jupyter-notebook/LICENSE.txt @@ -0,0 +1,201 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf of + any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don\'t include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [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/agents/.agents/skills/jupyter-notebook/SKILL.md b/agents/.agents/skills/jupyter-notebook/SKILL.md new file mode 100644 index 0000000..e1d1748 --- /dev/null +++ b/agents/.agents/skills/jupyter-notebook/SKILL.md @@ -0,0 +1,107 @@ +--- +name: "jupyter-notebook" +description: "Use when the user asks to create, scaffold, or edit Jupyter notebooks (`.ipynb`) for experiments, explorations, or tutorials; prefer the bundled templates and run the helper script `new_notebook.py` to generate a clean starting notebook." +--- + + +# Jupyter Notebook Skill + +Create clean, reproducible Jupyter notebooks for two primary modes: + +- Experiments and exploratory analysis +- Tutorials and teaching-oriented walkthroughs + +Prefer the bundled templates and the helper script for consistent structure and fewer JSON mistakes. + +## When to use +- Create a new `.ipynb` notebook from scratch. +- Convert rough notes or scripts into a structured notebook. +- Refactor an existing notebook to be more reproducible and skimmable. +- Build experiments or tutorials that will be read or re-run by other people. + +## Decision tree +- If the request is exploratory, analytical, or hypothesis-driven, choose `experiment`. +- If the request is instructional, step-by-step, or audience-specific, choose `tutorial`. +- If editing an existing notebook, treat it as a refactor: preserve intent and improve structure. + +## Skill path (set once) + +```bash +export CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" +export JUPYTER_NOTEBOOK_CLI="$CODEX_HOME/skills/jupyter-notebook/scripts/new_notebook.py" +``` + +User-scoped skills install under `$CODEX_HOME/skills` (default: `~/.codex/skills`). + +## Workflow +1. Lock the intent. +Identify the notebook kind: `experiment` or `tutorial`. +Capture the objective, audience, and what "done" looks like. + +2. Scaffold from the template. +Use the helper script to avoid hand-authoring raw notebook JSON. + +```bash +uv run --python 3.12 python "$JUPYTER_NOTEBOOK_CLI" \ + --kind experiment \ + --title "Compare prompt variants" \ + --out output/jupyter-notebook/compare-prompt-variants.ipynb +``` + +```bash +uv run --python 3.12 python "$JUPYTER_NOTEBOOK_CLI" \ + --kind tutorial \ + --title "Intro to embeddings" \ + --out output/jupyter-notebook/intro-to-embeddings.ipynb +``` + +3. Fill the notebook with small, runnable steps. +Keep each code cell focused on one step. +Add short markdown cells that explain the purpose and expected result. +Avoid large, noisy outputs when a short summary works. + +4. Apply the right pattern. +For experiments, follow `references/experiment-patterns.md`. +For tutorials, follow `references/tutorial-patterns.md`. + +5. Edit safely when working with existing notebooks. +Preserve the notebook structure; avoid reordering cells unless it improves the top-to-bottom story. +Prefer targeted edits over full rewrites. +If you must edit raw JSON, review `references/notebook-structure.md` first. + +6. Validate the result. +Run the notebook top-to-bottom when the environment allows. +If execution is not possible, say so explicitly and call out how to validate locally. +Use the final pass checklist in `references/quality-checklist.md`. + +## Templates and helper script +- Templates live in `assets/experiment-template.ipynb` and `assets/tutorial-template.ipynb`. +- The helper script loads a template, updates the title cell, and writes a notebook. + +Script path: +- `$JUPYTER_NOTEBOOK_CLI` (installed default: `$CODEX_HOME/skills/jupyter-notebook/scripts/new_notebook.py`) + +## Temp and output conventions +- Use `tmp/jupyter-notebook/` for intermediate files; delete when done. +- Write final artifacts under `output/jupyter-notebook/` when working in this repo. +- Use stable, descriptive filenames (for example, `ablation-temperature.ipynb`). + +## Dependencies (install only when needed) +Prefer `uv` for dependency management. + +Optional Python packages for local notebook execution: + +```bash +uv pip install jupyterlab ipykernel +``` + +The bundled scaffold script uses only the Python standard library and does not require extra dependencies. + +## Environment +No required environment variables. + +## Reference map +- `references/experiment-patterns.md`: experiment structure and heuristics. +- `references/tutorial-patterns.md`: tutorial structure and teaching flow. +- `references/notebook-structure.md`: notebook JSON shape and safe editing rules. +- `references/quality-checklist.md`: final validation checklist. diff --git a/agents/.agents/skills/jupyter-notebook/agents/openai.yaml b/agents/.agents/skills/jupyter-notebook/agents/openai.yaml new file mode 100644 index 0000000..8832dea --- /dev/null +++ b/agents/.agents/skills/jupyter-notebook/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Jupyter Notebooks" + short_description: "Create Jupyter notebooks for experiments and tutorials" + icon_small: "./assets/jupyter-small.svg" + icon_large: "./assets/jupyter.png" + default_prompt: "Create a Jupyter notebook for this task with clear sections, runnable cells, and concise takeaways." diff --git a/agents/.agents/skills/jupyter-notebook/assets/experiment-template.ipynb b/agents/.agents/skills/jupyter-notebook/assets/experiment-template.ipynb new file mode 100644 index 0000000..a4a0323 --- /dev/null +++ b/agents/.agents/skills/jupyter-notebook/assets/experiment-template.ipynb @@ -0,0 +1,110 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Experiment: TITLE\n", + "\n", + "Objective:\n", + "- State the question you want to answer.\n", + "- Define the success criteria.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Setup: imports and reproducibility\n", + "from __future__ import annotations\n", + "\n", + "import random\n", + "import statistics\n", + "\n", + "SEED = 7\n", + "random.seed(SEED)\n", + "SEED\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Plan\n", + "\n", + "- Hypothesis:\n", + "- Variables to sweep:\n", + "- Metrics to record:\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Define parameters and lightweight helpers\n", + "sample_size = 20\n", + "values = [random.random() for _ in range(sample_size)]\n", + "summary = {\n", + " \"count\": len(values),\n", + " \"mean\": statistics.fmean(values),\n", + " \"min\": min(values),\n", + " \"max\": max(values),\n", + "}\n", + "summary\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Results\n", + "\n", + "- Key observations:\n", + "- Surprises or failure modes:\n", + "- Decision: continue, pivot, or stop:\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Record findings in a minimal, copy-pasteable structure\n", + "result = {\n", + " \"seed\": SEED,\n", + " \"mean\": summary[\"mean\"],\n", + " \"range\": summary[\"max\"] - summary[\"min\"],\n", + "}\n", + "result\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Next steps\n", + "\n", + "- What to try next:\n", + "- What to document elsewhere (PRD, notes, issue):\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/agents/.agents/skills/jupyter-notebook/assets/jupyter-small.svg b/agents/.agents/skills/jupyter-notebook/assets/jupyter-small.svg new file mode 100644 index 0000000..472d07f --- /dev/null +++ b/agents/.agents/skills/jupyter-notebook/assets/jupyter-small.svg @@ -0,0 +1,3 @@ + + + diff --git a/agents/.agents/skills/jupyter-notebook/assets/jupyter.png b/agents/.agents/skills/jupyter-notebook/assets/jupyter.png new file mode 100644 index 0000000..51cdf7d Binary files /dev/null and b/agents/.agents/skills/jupyter-notebook/assets/jupyter.png differ diff --git a/agents/.agents/skills/jupyter-notebook/assets/tutorial-template.ipynb b/agents/.agents/skills/jupyter-notebook/assets/tutorial-template.ipynb new file mode 100644 index 0000000..7001309 --- /dev/null +++ b/agents/.agents/skills/jupyter-notebook/assets/tutorial-template.ipynb @@ -0,0 +1,107 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Tutorial: TITLE\n", + "\n", + "Audience:\n", + "- Describe who this is for.\n", + "\n", + "Prerequisites:\n", + "- List required concepts or setup.\n", + "\n", + "Learning goals:\n", + "- By the end, the reader can...\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Outline\n", + "\n", + "1. Setup\n", + "2. A minimal working example\n", + "3. Variations and pitfalls\n", + "4. Exercises\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Setup cell: keep it short and deterministic\n", + "from __future__ import annotations\n", + "\n", + "import math\n", + "import random\n", + "\n", + "SEED = 21\n", + "random.seed(SEED)\n", + "SEED\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 1 - Start with a tiny example\n", + "\n", + "Explain what the next cell does in plain language.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Minimal working example\n", + "angles = [0, math.pi / 4, math.pi / 2]\n", + "sines = [math.sin(a) for a in angles]\n", + "list(zip(angles, sines))\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Exercises\n", + "\n", + "- Try a different input.\n", + "- Predict the output before running the code.\n", + "- Note one common mistake and how to fix it.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Exercise answer scaffold\n", + "def describe(values: list[float]) -> dict[str, float]:\n", + " return {\"min\": min(values), \"max\": max(values)}\n", + "\n", + "describe(sines)\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/agents/.agents/skills/jupyter-notebook/references/experiment-patterns.md b/agents/.agents/skills/jupyter-notebook/references/experiment-patterns.md new file mode 100644 index 0000000..2d5093b --- /dev/null +++ b/agents/.agents/skills/jupyter-notebook/references/experiment-patterns.md @@ -0,0 +1,10 @@ +# Experiment Patterns + +Use this structure for exploratory and experimental work: + +- Title and objective: state the question and the success criteria. +- Setup and reproducibility: import only what you need, set a seed early, and keep configuration in one short cell. +- Plan: list hypotheses, sweeps, and metrics before running code. +- Minimal baseline: start with the smallest runnable example and confirm it runs end-to-end before adding complexity. +- Results and notes: summarize findings in markdown near the relevant code and record key metrics in a small dictionary or table-like structure. +- Next steps: decide whether to continue, pivot, or stop, and capture follow-up ideas as short bullets. diff --git a/agents/.agents/skills/jupyter-notebook/references/notebook-structure.md b/agents/.agents/skills/jupyter-notebook/references/notebook-structure.md new file mode 100644 index 0000000..681d36f --- /dev/null +++ b/agents/.agents/skills/jupyter-notebook/references/notebook-structure.md @@ -0,0 +1,17 @@ +# Notebook Structure + +Jupyter notebooks are JSON documents with this high-level shape: + +- `nbformat` and `nbformat_minor` +- `metadata` +- `cells` (a list of markdown and code cells) + +When editing `.ipynb` files programmatically: + +- Preserve `nbformat` and `nbformat_minor` from the template. +- Keep `cells` as an ordered list; do not reorder unless intentional. +- For code cells, set `execution_count` to `null` when unknown. +- For code cells, set `outputs` to an empty list when scaffolding. +- For markdown cells, keep `cell_type="markdown"` and `metadata={}`. + +Prefer scaffolding from the bundled templates or `new_notebook.py` (for example, `$CODEX_HOME/skills/jupyter-notebook/scripts/new_notebook.py`) instead of hand-authoring raw notebook JSON. diff --git a/agents/.agents/skills/jupyter-notebook/references/quality-checklist.md b/agents/.agents/skills/jupyter-notebook/references/quality-checklist.md new file mode 100644 index 0000000..5482f28 --- /dev/null +++ b/agents/.agents/skills/jupyter-notebook/references/quality-checklist.md @@ -0,0 +1,11 @@ +# Quality Checklist + +Before delivering a notebook: + +- Run it top-to-bottom at least once (or as much as the environment allows). +- Ensure early cells set all required state; avoid hidden state from prior runs. +- Keep outputs tidy. Avoid giant outputs when a short summary works. +- Prefer small tables, key metrics, or short printouts. +- Keep the narrative skimmable. Use headings and short bullets, and avoid long paragraphs. +- Leave helpful TODOs only when necessary, and label them clearly. +- If execution is not possible, call out the risk and how to validate locally. diff --git a/agents/.agents/skills/jupyter-notebook/references/tutorial-patterns.md b/agents/.agents/skills/jupyter-notebook/references/tutorial-patterns.md new file mode 100644 index 0000000..2a360b0 --- /dev/null +++ b/agents/.agents/skills/jupyter-notebook/references/tutorial-patterns.md @@ -0,0 +1,9 @@ +# Tutorial Patterns + +Use this structure for teaching and walkthroughs: + +- Audience, prerequisites, and learning goals: say who it is for, list what they should already know, and state what they will be able to do by the end. +- Outline: provide a short numbered outline so readers can skim. +- Step-by-step flow: pair a short markdown explanation with a small code cell that runs on its own and a brief interpretation of the result. +- Exercises: include at least one exercise that reinforces the key concept and provide an answer scaffold in the next cell. +- Pitfalls and extensions: call out one common mistake and how to fix it, and suggest one optional extension for curious readers. diff --git a/agents/.agents/skills/jupyter-notebook/scripts/new_notebook.py b/agents/.agents/skills/jupyter-notebook/scripts/new_notebook.py new file mode 100644 index 0000000..0aba209 --- /dev/null +++ b/agents/.agents/skills/jupyter-notebook/scripts/new_notebook.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + + +def slugify(text: str) -> str: + lowered = text.strip().lower() + cleaned = re.sub(r"[^a-z0-9]+", "-", lowered) + collapsed = re.sub(r"-+", "-", cleaned).strip("-") + return collapsed or "notebook" + + +def find_repo_root(start: Path) -> Path: + for candidate in (start, *start.parents): + if (candidate / ".git").exists(): + return candidate + return start + + +def load_template(skill_dir: Path, kind: str) -> dict[str, Any]: + asset_name = "experiment-template.ipynb" if kind == "experiment" else "tutorial-template.ipynb" + template_path = skill_dir / "assets" / asset_name + if not template_path.exists(): + raise SystemExit(f"Missing template: {template_path}") + with template_path.open("r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + raise SystemExit(f"Unexpected template shape: {template_path}") + return data + + +def update_title(notebook: dict[str, Any], kind: str, title: str) -> None: + prefix = "Experiment" if kind == "experiment" else "Tutorial" + expected = f"# {prefix}: {title}\n" + + cells = notebook.get("cells") + if not isinstance(cells, list) or not cells: + raise SystemExit("Template notebook has no cells") + + first_cell = cells[0] + if not isinstance(first_cell, dict) or first_cell.get("cell_type") != "markdown": + raise SystemExit("Template notebook must start with a markdown title cell") + + source = first_cell.get("source", []) + if isinstance(source, str): + source_lines = [source] + elif isinstance(source, list): + source_lines = [str(line) for line in source] + else: + source_lines = [] + + if source_lines: + source_lines[0] = expected + else: + source_lines = [expected] + + first_cell["source"] = source_lines + + metadata = notebook.setdefault("metadata", {}) + if not isinstance(metadata, dict): + raise SystemExit("Notebook metadata must be a mapping") + + language_info = metadata.setdefault("language_info", {}) + if isinstance(language_info, dict): + language_info.setdefault("name", "python") + language_info.setdefault("version", "3.12") + + +def default_output(repo_root: Path, title: str) -> Path: + filename = f"{slugify(title)}.ipynb" + return repo_root / "output" / "jupyter-notebook" / filename + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Scaffold a Jupyter notebook for experiments or tutorials.") + parser.add_argument( + "--kind", + choices=["experiment", "tutorial"], + default="experiment", + help="Notebook style to scaffold (default: experiment).", + ) + parser.add_argument( + "--title", + required=True, + help="Human-readable notebook title used in the first markdown cell.", + ) + parser.add_argument( + "--out", + type=Path, + default=None, + help="Output path for the notebook. Defaults to output/jupyter-notebook/.ipynb.", + ) + parser.add_argument( + "--force", + action="store_true", + help="Overwrite the output file if it already exists.", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + + script_path = Path(__file__).resolve() + skill_dir = script_path.parents[1] + repo_root = find_repo_root(skill_dir) + + notebook = load_template(skill_dir, args.kind) + update_title(notebook, args.kind, args.title) + + out_path = args.out or default_output(repo_root, args.title) + out_path = out_path.resolve() + + if out_path.exists() and not args.force: + raise SystemExit(f"Refusing to overwrite existing file without --force: {out_path}") + + out_path.parent.mkdir(parents=True, exist_ok=True) + with out_path.open("w", encoding="utf-8") as f: + json.dump(notebook, f, indent=2) + f.write("\n") + + print(f"Wrote {out_path} using kind={args.kind}.") + + +if __name__ == "__main__": + main() diff --git a/agents/.agents/skills/pdf/LICENSE.txt b/agents/.agents/skills/pdf/LICENSE.txt new file mode 100644 index 0000000..13e25df --- /dev/null +++ b/agents/.agents/skills/pdf/LICENSE.txt @@ -0,0 +1,201 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf of + any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don\'t include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [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/agents/.agents/skills/pdf/SKILL.md b/agents/.agents/skills/pdf/SKILL.md new file mode 100644 index 0000000..fd5d560 --- /dev/null +++ b/agents/.agents/skills/pdf/SKILL.md @@ -0,0 +1,67 @@ +--- +name: "pdf" +description: "Use when tasks involve reading, creating, or reviewing PDF files where rendering and layout matter; prefer visual checks by rendering pages (Poppler) and use Python tools such as `reportlab`, `pdfplumber`, and `pypdf` for generation and extraction." +--- + + +# PDF Skill + +## When to use +- Read or review PDF content where layout and visuals matter. +- Create PDFs programmatically with reliable formatting. +- Validate final rendering before delivery. + +## Workflow +1. Prefer visual review: render PDF pages to PNGs and inspect them. + - Use `pdftoppm` if available. + - If unavailable, install Poppler or ask the user to review the output locally. +2. Use `reportlab` to generate PDFs when creating new documents. +3. Use `pdfplumber` (or `pypdf`) for text extraction and quick checks; do not rely on it for layout fidelity. +4. After each meaningful update, re-render pages and verify alignment, spacing, and legibility. + +## Temp and output conventions +- Use `tmp/pdfs/` for intermediate files; delete when done. +- Write final artifacts under `output/pdf/` when working in this repo. +- Keep filenames stable and descriptive. + +## Dependencies (install if missing) +Prefer `uv` for dependency management. + +Python packages: +``` +uv pip install reportlab pdfplumber pypdf +``` +If `uv` is unavailable: +``` +python3 -m pip install reportlab pdfplumber pypdf +``` +System tools (for rendering): +``` +# macOS (Homebrew) +brew install poppler + +# Ubuntu/Debian +sudo apt-get install -y poppler-utils +``` + +If installation isn't possible in this environment, tell the user which dependency is missing and how to install it locally. + +## Environment +No required environment variables. + +## Rendering command +``` +pdftoppm -png $INPUT_PDF $OUTPUT_PREFIX +``` + +## Quality expectations +- Maintain polished visual design: consistent typography, spacing, margins, and section hierarchy. +- Avoid rendering issues: clipped text, overlapping elements, broken tables, black squares, or unreadable glyphs. +- Charts, tables, and images must be sharp, aligned, and clearly labeled. +- Use ASCII hyphens only. Avoid U+2011 (non-breaking hyphen) and other Unicode dashes. +- Citations and references must be human-readable; never leave tool tokens or placeholder strings. + +## Final checks +- Do not deliver until the latest PNG inspection shows zero visual or formatting defects. +- Confirm headers/footers, page numbering, and section transitions look polished. +- Keep intermediate files organized or remove them after final approval. diff --git a/agents/.agents/skills/pdf/agents/openai.yaml b/agents/.agents/skills/pdf/agents/openai.yaml new file mode 100644 index 0000000..fe2876a --- /dev/null +++ b/agents/.agents/skills/pdf/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "PDF Skill" + short_description: "Create, edit, and review PDFs" + icon_large: "./assets/pdf.png" + default_prompt: "Create, edit, or review this PDF and summarize the key output or changes." diff --git a/agents/.agents/skills/pdf/assets/pdf.png b/agents/.agents/skills/pdf/assets/pdf.png new file mode 100644 index 0000000..dd16ba2 Binary files /dev/null and b/agents/.agents/skills/pdf/assets/pdf.png differ diff --git a/agents/.agents/skills/slides/LICENSE.txt b/agents/.agents/skills/slides/LICENSE.txt new file mode 100644 index 0000000..cefe596 --- /dev/null +++ b/agents/.agents/skills/slides/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/agents/.agents/skills/slides/SKILL.md b/agents/.agents/skills/slides/SKILL.md new file mode 100644 index 0000000..f64a86f --- /dev/null +++ b/agents/.agents/skills/slides/SKILL.md @@ -0,0 +1,71 @@ +--- +name: slides +description: Create and edit presentation slide decks (`.pptx`) with PptxGenJS, bundled layout helpers, and render/validation utilities. Use when tasks involve building a new PowerPoint deck, recreating slides from screenshots/PDFs/reference decks, modifying slide content while preserving editable output, adding charts/diagrams/visuals, or diagnosing layout issues such as overflow, overlaps, and font substitution. +--- + +# Slides + +## Overview + +Use PptxGenJS for slide authoring. Do not use `python-pptx` for deck generation unless the task is inspection-only; keep editable output in JavaScript and deliver both the `.pptx` and the source `.js`. + +Keep work in a task-local directory. Only copy final artifacts to the requested destination after rendering and validation pass. + +## Bundled Resources + +- `assets/pptxgenjs_helpers/`: Copy this folder into the deck workspace and import it locally instead of reimplementing helper logic. +- `scripts/render_slides.py`: Rasterize a `.pptx` or `.pdf` to per-slide PNGs. +- `scripts/slides_test.py`: Detect content that overflows the slide canvas. +- `scripts/create_montage.py`: Build a contact-sheet style montage of rendered slides. +- `scripts/detect_font.py`: Report missing or substituted fonts as LibreOffice resolves them. +- `scripts/ensure_raster_image.py`: Convert SVG/EMF/HEIC/PDF-like assets into PNGs for quick inspection. +- `references/pptxgenjs-helpers.md`: Load only when you need API details or dependency notes. + +## Workflow + +1. Inspect the request and determine whether you are creating a new deck, recreating an existing deck, or editing one. +2. Set the slide size up front. Default to 16:9 (`LAYOUT_WIDE`) unless the source material clearly uses another aspect ratio. +3. Copy `assets/pptxgenjs_helpers/` into the working directory and import the helpers from there. +4. Build the deck in JavaScript with an explicit theme font, stable spacing, and editable PowerPoint-native elements when practical. +5. Run the bundled scripts from this skill directory or copy the needed ones into the task workspace. Render the result with `render_slides.py`, review the PNGs, and fix layout issues before delivery. +6. Run `slides_test.py` for overflow checks when slide edges are tight or the deck is dense. +7. Deliver the `.pptx`, the authoring `.js`, and any generated assets that are required to rebuild the deck. + +## Authoring Rules + +- Set theme fonts explicitly. Do not rely on PowerPoint defaults if typography matters. +- Use `autoFontSize`, `calcTextBox`, and related helpers to size text boxes; do not use PptxGenJS `fit` or `autoFit`. +- Use bullet options, not literal `•` characters. +- Use `imageSizingCrop` or `imageSizingContain` instead of PptxGenJS built-in image sizing. +- Use `latexToSvgDataUri()` for equations and `codeToRuns()` for syntax-highlighted code blocks. +- Prefer native PowerPoint charts for simple bar/line/pie/histogram style visuals so reviewers can edit them later. +- For charts or diagrams that PptxGenJS cannot express well, render SVG externally and place the SVG in the slide. +- Include both `warnIfSlideHasOverlaps(slide, pptx)` and `warnIfSlideElementsOutOfBounds(slide, pptx)` in the submitted JavaScript whenever you generate or substantially edit slides. +- Fix all unintentional overlap and out-of-bounds warnings before delivering. If an overlap is intentional, leave a short code comment near the relevant element. + +## Recreate Or Edit Existing Slides + +- Render the source deck or reference PDF first so you can compare slide geometry visually. +- Match the original aspect ratio before rebuilding layout. +- Preserve editability where possible: text should stay text, and simple charts should stay native charts. +- If a reference slide uses raster artwork, use `ensure_raster_image.py` to generate debug PNGs from vector or odd image formats before placing them. + +## Validation Commands + +Examples below assume you copied the needed scripts into the working directory. If not, invoke the same script paths relative to this skill folder. + +```bash +# Render slides to PNGs for review +python3 scripts/render_slides.py deck.pptx --output_dir rendered + +# Build a montage for quick scanning +python3 scripts/create_montage.py --input_dir rendered --output_file montage.png + +# Check for overflow beyond the original slide canvas +python3 scripts/slides_test.py deck.pptx + +# Detect missing or substituted fonts +python3 scripts/detect_font.py deck.pptx --json +``` + +Load `references/pptxgenjs-helpers.md` if you need the helper API summary or dependency details. diff --git a/agents/.agents/skills/slides/agents/openai.yaml b/agents/.agents/skills/slides/agents/openai.yaml new file mode 100644 index 0000000..6f77e49 --- /dev/null +++ b/agents/.agents/skills/slides/agents/openai.yaml @@ -0,0 +1,6 @@ +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/agents/.agents/skills/slides/assets/pptxgenjs_helpers/code.js b/agents/.agents/skills/slides/assets/pptxgenjs_helpers/code.js new file mode 100644 index 0000000..951ce13 --- /dev/null +++ b/agents/.agents/skills/slides/assets/pptxgenjs_helpers/code.js @@ -0,0 +1,104 @@ +// Copyright (c) OpenAI. All rights reserved. +"use strict"; + +const fs = require("fs"); +const Prism = require("prismjs"); +let THEME_MAP; + +function loadPrismLanguage(lang) { + const normalized = String(lang || "plaintext").toLowerCase(); + const known = new Set([ + "markup", + "html", + "xml", + "svg", + "mathml", + "css", + "clike", + "javascript", + "js", + "typescript", + "ts", + "python", + "py", + "bash", + "sh", + "json", + "yaml", + "yml", + ]); + const map = { + js: "javascript", + ts: "typescript", + py: "python", + sh: "bash", + yml: "yaml", + html: "markup", + xml: "markup", + }; + const id = map[normalized] || normalized; + if (!Prism.languages[id]) { + try { + require(`prismjs/components/prism-${id}`); + } catch (_e) {} + } + return Prism.languages[id] || Prism.languages.plain || {}; +} + +function buildThemeMap(themeCssModule = "prismjs/themes/prism-okaidia.css") { + try { + const css = fs.readFileSync(require.resolve(themeCssModule), "utf8"); + return Object.fromEntries( + [ + ...css.matchAll( + /\.token\.([\w-]+)[^{]*\{[^}]*color:\s*([^;\s]+)[^}]*\}/g + ), + ].map(([, t, c]) => [t, c.replace(/#|!important/g, "").trim()]) + ); + } catch (err) { + return { plain: "FFFFFF", comment: "999999" }; + } +} + +function getThemeMap() { + if (!THEME_MAP) THEME_MAP = buildThemeMap(); + return THEME_MAP; +} + +function run(text, type = "plain") { + const theme = getThemeMap(); + return { + text, + options: { + fontFace: "Consolas", + color: theme[type] || theme.plain || "FFFFFF", + fontSize: 14, + }, + }; +} + +function tokensToRuns(tokens) { + return tokens.flatMap((t) => + typeof t === "string" + ? [run(t)] + : Array.isArray(t.content) + ? tokensToRuns(t.content) + : [run(t.content, t.type)] + ); +} + +function codeToRuns(code, lang) { + const grammar = loadPrismLanguage(lang); + const lines = String(code || "").split("\n"); + const pad = lines.length.toString().length; + return lines.flatMap((line, i) => [ + run(`${(i + 1).toString().padStart(pad, " ")} `, "comment"), + ...tokensToRuns(Prism.tokenize(line, grammar)), + ...(i < lines.length - 1 ? [run("\n")] : []), + ]); +} + +module.exports = { + codeToRuns, + buildThemeMap, +}; diff --git a/agents/.agents/skills/slides/assets/pptxgenjs_helpers/image.js b/agents/.agents/skills/slides/assets/pptxgenjs_helpers/image.js new file mode 100644 index 0000000..16fdc16 --- /dev/null +++ b/agents/.agents/skills/slides/assets/pptxgenjs_helpers/image.js @@ -0,0 +1,333 @@ +// Copyright (c) OpenAI. All rights reserved. +"use strict"; + +const fs = require("fs"); + +// Accept either a filesystem path, a data URI, raw SVG string, or a Buffer +// and normalize to a Buffer for type/size probing. +function readInputAsBuffer(source) { + if (!source) throw new Error("Image source is empty"); + if (Buffer.isBuffer(source)) return { buffer: source, type: "buffer" }; + if (typeof source === "string") { + // data URI (we primarily emit base64 data URIs for SVG via helpers) + if (source.startsWith("data:")) { + const type = "dataUri"; + const comma = source.indexOf(","); + const payload = comma !== -1 ? source.slice(comma + 1) : source; + // Our helpers use base64; if not, try URI decode then treat as raw text + try { + return { buffer: Buffer.from(payload, "base64"), type: type }; + } catch (_e) { + try { + return { + buffer: Buffer.from(decodeURIComponent(payload), "utf8"), + type: type, + }; + } catch (_e2) { + return { buffer: Buffer.from(payload, "utf8"), type: type }; + } + } + } + // Raw inline SVG string + if (source.includes("= 24 && + buf[0] === 0x89 && + buf[1] === 0x50 && + buf[2] === 0x4e && + buf[3] === 0x47 && + buf[4] === 0x0d && + buf[5] === 0x0a && + buf[6] === 0x1a && + buf[7] === 0x0a + ); +} + +function isJpeg(buf) { + return ( + buf.length > 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff + ); +} + +function isGif(buf) { + return ( + buf.length >= 10 && + buf[0] === 0x47 && + buf[1] === 0x49 && + buf[2] === 0x46 && + buf[3] === 0x38 && + (buf[4] === 0x39 || buf[4] === 0x37) && + buf[5] === 0x61 + ); +} + +function isWebp(buf) { + return ( + buf.length >= 16 && + buf[0] === 0x52 && + buf[1] === 0x49 && + buf[2] === 0x46 && + buf[3] === 0x46 && + buf[8] === 0x57 && + buf[9] === 0x45 && + buf[10] === 0x42 && + buf[11] === 0x50 + ); +} + +function isSvg(buf) { + const head = buf.slice(0, 200).toString("utf8"); + return head.includes("> 6)); + return { width, height, type: "webp" }; + } + } + offset += 8 + ((chunkSize + 1) & ~1); // chunks are padded to even size + } + throw new Error("Unsupported WEBP variant for size detection"); +} + +function readJpegSize(buf) { + let offset = 2; + while (offset < buf.length) { + if (buf[offset] !== 0xff) { + offset++; + continue; + } + const marker = buf[offset + 1]; + // SOF0..SOF3, SOF5..SOF7, SOF9..SOF11, SOF13..SOF15 + if ( + (marker >= 0xc0 && marker <= 0xc3) || + (marker >= 0xc5 && marker <= 0xc7) || + (marker >= 0xc9 && marker <= 0xcb) || + (marker >= 0xcd && marker <= 0xcf) + ) { + const blockLength = buf.readUInt16BE(offset + 2); + const height = buf.readUInt16BE(offset + 5); + const width = buf.readUInt16BE(offset + 7); + return { width, height, type: "jpeg" }; + } + const blockLength = buf.readUInt16BE(offset + 2); + if (!Number.isFinite(blockLength) || blockLength < 2) break; + offset += 2 + blockLength; + } + throw new Error("JPEG size not found"); +} + +function parseSvgSize(buf) { + const text = buf.toString("utf8"); + const a = text.indexOf(""); + const inner = a !== -1 && b !== -1 ? text.slice(a, b + 6) : text; + const widthMatch = inner.match(/\bwidth\s*=\s*"([^"]+)"/i); + const heightMatch = inner.match(/\bheight\s*=\s*"([^"]+)"/i); + const viewBoxMatch = inner.match(/\bviewBox\s*=\s*"([^"]+)"/i); + + function toPx(v) { + if (!v) return null; + const m = String(v) + .trim() + .match(/([0-9.]+)\s*(px|pt|em|ex|cm|mm|in|%)?/i); + if (!m) return null; + const n = parseFloat(m[1]); + const unit = (m[2] || "px").toLowerCase(); + const dpi = 96; + switch (unit) { + case "px": + return n; + case "pt": + return (n * dpi) / 72; + case "in": + return n * dpi; + case "cm": + return (n * dpi) / 2.54; + case "mm": + return (n * dpi) / 25.4; + case "em": + case "ex": + return n * 16; // rough fallback + default: + return null; + } + } + + let widthPx = widthMatch ? toPx(widthMatch[1]) : null; + let heightPx = heightMatch ? toPx(heightMatch[1]) : null; + if ((widthPx == null || heightPx == null) && viewBoxMatch) { + const parts = viewBoxMatch[1].trim().split(/\s+/).map(Number); + if (parts.length === 4) { + const vbw = parts[2]; + const vbh = parts[3]; + if (!widthPx && vbh) widthPx = vbw; + if (!heightPx && vbw) heightPx = vbh; + } + } + if (!widthPx || !heightPx) { + // Fallback if sizes missing + widthPx = widthPx || 100; + heightPx = heightPx || 100; + } + return { width: widthPx, height: heightPx, type: "svg" }; +} + +function getImageDimensions(pathOrData) { + const { buffer: buf, type } = readInputAsBuffer(pathOrData); + let meta; + if (isPng(buf)) meta = readPngSize(buf); + else if (isJpeg(buf)) meta = readJpegSize(buf); + else if (isGif(buf)) meta = readGifSize(buf); + else if (isWebp(buf)) meta = readWebpSize(buf); + else if (isSvg(buf)) meta = parseSvgSize(buf); + else { + const suffix = + type === "path" && typeof pathOrData === "string" + ? ` (path: ${pathOrData})` + : ""; + throw new Error("Unsupported image format for provided source" + suffix); + } + + const aspectRatio = + meta.width > 0 && meta.height > 0 ? meta.width / meta.height : 1; + return { + width: meta.width, + height: meta.height, + aspectRatio, + type: meta.type, + }; +} + +function imageSizingCrop(source, x, y, w, h, cx, cy, cw, ch) { + const { aspectRatio } = getImageDimensions(source); + const boxAspect = w / h; + + if ( + cx === undefined || + cy === undefined || + cw === undefined || + ch === undefined + ) { + let cropXFrac, cropYFrac, cropWFrac, cropHFrac; + if (aspectRatio >= boxAspect) { + cropHFrac = 1; + cropWFrac = boxAspect / aspectRatio; + cropXFrac = (1 - cropWFrac) / 2; + cropYFrac = 0; + } else { + cropWFrac = 1; + cropHFrac = aspectRatio / boxAspect; + cropXFrac = 0; + cropYFrac = (1 - cropHFrac) / 2; + } + cx = cropXFrac; + cy = cropYFrac; + cw = cropWFrac; + ch = cropHFrac; + } + + let virtualW = w / cw; + let virtualH = virtualW / aspectRatio; + const eps = 1e-6; + if (Math.abs(virtualH * ch - h) > eps) { + virtualH = h / ch; + virtualW = virtualH * aspectRatio; + } + + const cropXIn = cx * virtualW; + const cropYIn = cy * virtualH; + return { + x, + y, + w: virtualW, + h: virtualH, + sizing: { + type: "crop", + x: cropXIn, + y: cropYIn, + w: w, + h: h, + }, + }; +} + +function imageSizingContain(source, x, y, w, h) { + const { aspectRatio } = getImageDimensions(source); + let w2, h2; + const boxAspect = w / h; + if (aspectRatio >= boxAspect) { + w2 = w; + h2 = w2 / aspectRatio; + } else { + h2 = h; + w2 = h2 * aspectRatio; + } + return { + x: x + (w - w2) / 2, + y: y + (h - h2) / 2, + w: w2, + h: h2, + }; +} + +module.exports = { + getImageDimensions, + imageSizingCrop, + imageSizingContain, +}; diff --git a/agents/.agents/skills/slides/assets/pptxgenjs_helpers/index.js b/agents/.agents/skills/slides/assets/pptxgenjs_helpers/index.js new file mode 100644 index 0000000..07805a2 --- /dev/null +++ b/agents/.agents/skills/slides/assets/pptxgenjs_helpers/index.js @@ -0,0 +1,33 @@ +// Copyright (c) OpenAI. All rights reserved. +"use strict"; + +const VERSION = "1.2.0"; + +const text = require("./text"); +const image = require("./image"); +const svg = require("./svg"); +const latex = require("./latex"); +const code = require("./code"); +const layout = require("./layout"); +const layoutBuilders = require("./layout_builders"); +const util = require("./util"); + +module.exports = { + VERSION, + // text layout + ...text, + // images + ...image, + // svg helpers + ...svg, + // LaTeX -> SVG + ...latex, + // code block -> pptx text runs + ...code, + // slide layout analyzers + ...layout, + // slide layout builders + ...layoutBuilders, + // text layout helpers and utilities + ...util, +}; diff --git a/agents/.agents/skills/slides/assets/pptxgenjs_helpers/latex.js b/agents/.agents/skills/slides/assets/pptxgenjs_helpers/latex.js new file mode 100644 index 0000000..03c1e22 --- /dev/null +++ b/agents/.agents/skills/slides/assets/pptxgenjs_helpers/latex.js @@ -0,0 +1,51 @@ +// Copyright (c) OpenAI. All rights reserved. +"use strict"; + +let _mathjax; +let _adaptor; +let _doc; + +function ensureMathJax() { + if (_mathjax && _adaptor && _doc) return; + try { + const { mathjax } = require("mathjax-full/js/mathjax.js"); + const { TeX } = require("mathjax-full/js/input/tex.js"); + const { SVG } = require("mathjax-full/js/output/svg.js"); + const { liteAdaptor } = require("mathjax-full/js/adaptors/liteAdaptor.js"); + const { RegisterHTMLHandler } = require("mathjax-full/js/handlers/html.js"); + const { AllPackages } = require("mathjax-full/js/input/tex/AllPackages.js"); + + _adaptor = liteAdaptor(); + RegisterHTMLHandler(_adaptor); + const tex = new TeX({ packages: AllPackages }); + const out = new SVG({ fontCache: "local" }); + _doc = mathjax.document("", { InputJax: tex, OutputJax: out }); + _mathjax = mathjax; + } catch (err) { + throw new Error( + "mathjax-full is not installed. Run `npm i mathjax-full` or avoid latexToSvgDataUri()." + ); + } +} + +function latexToSvgDataUri(latex, display = true) { + ensureMathJax(); + const html = _adaptor.outerHTML(_doc.convert(latex, { display })); + const a = html.indexOf(""); + let svg = a !== -1 && b !== -1 ? html.slice(a, b + 6) : html; + svg = svg.replace(/<\?xml[^>]*>/g, ""); + if (!/xmlns=\"http:\/\/www\.w3\.org\/2000\/svg\"/.test(svg)) { + svg = svg.replace(/ { + const px = Math.round(parseFloat(num) * 8.5); + return `${attr}="${px}px"`; + }); + svg = svg.replace(/currentColor/g, "#000000"); + return "data:image/svg+xml;base64," + Buffer.from(svg).toString("base64"); +} + +module.exports = { + latexToSvgDataUri, +}; diff --git a/agents/.agents/skills/slides/assets/pptxgenjs_helpers/layout.js b/agents/.agents/skills/slides/assets/pptxgenjs_helpers/layout.js new file mode 100644 index 0000000..2ce70f3 --- /dev/null +++ b/agents/.agents/skills/slides/assets/pptxgenjs_helpers/layout.js @@ -0,0 +1,643 @@ +// Copyright (c) OpenAI. All rights reserved. +"use strict"; + +function inferElementType(obj) { + if (!obj) return "unknown"; + const data = obj.data || obj.options || {}; + // Distinguish lines explicitly via type only. Many objects have a 'line' style; don't misclassify those. + if (obj.type === "line") return "line"; + if (obj.type && typeof obj.type === "string") return obj.type; + if (obj.text || typeof data.text === "string") return "text"; + if (data.path || obj.image) return "image"; + if (data.chartType) return "chart"; + if (data.shape || data.line) return "shape"; + if (data.mediaType) return "media"; + if (data.table || Array.isArray(data.rows)) return "table"; + if (data.smartArt) return "smartart"; + return "unknown"; +} + +const TEXT_OVERLAP_ERROR_THRESHOLD = 0.1; +const RECTIFY_DIRECTION_EQUALITY_TOLERANCE = 0.15; + +function warnIfSlideHasOverlaps(slide, pptx, options = {}) { + if (!slide || !Array.isArray(slide._slideObjects)) { + console.warn("Invalid slide object passed to warnIfSlideOverlaps()"); + return; + } + const opts = { + // By default, containment cases are very common (e.g., full-slide backgrounds) + // and usually not actionable. Mute them unless explicitly requested. + muteContainment: + options.muteContainment !== undefined ? options.muteContainment : true, + // Do NOT ignore lines or decorative shapes by default; users want true overlaps. + ignoreLines: + options.ignoreLines !== undefined ? options.ignoreLines : false, + ignoreDecorativeShapes: + options.ignoreDecorativeShapes !== undefined + ? options.ignoreDecorativeShapes + : false, + }; + const slideIndex = + pptx && Array.isArray(pptx._slides) ? pptx._slides.indexOf(slide) : -1; + const slideLabel = + slideIndex >= 0 ? `Slide ${slideIndex + 1}` : "(Unknown slide index)"; + const formatElement = (el) => { + const cx = (el.x + el.w / 2).toFixed(3); + const cy = (el.y + el.h / 2).toFixed(3); + return `element ${el.index} (${el.type}, center_x=${cx}, center_y=${cy})`; + }; + const elements = slide._slideObjects.map((obj, i) => { + const { + x = 0, + y = 0, + w = 0, + h = 0, + fill, + line, + } = obj.data || obj.options || {}; + const type = inferElementType(obj); + const isDecorative = (() => { + if (!opts.ignoreDecorativeShapes) return false; + // Border rectangles used as frames: transparent fill (or fully transparent) with a stroke + const transparency = + typeof fill?.transparency === "number" ? fill.transparency : null; + const hasOnlyBorder = !!line && (!fill || transparency !== null); + const fullyTransparent = transparency !== null && transparency >= 99; + return type === "shape" && hasOnlyBorder && fullyTransparent; + })(); + const ignorable = (opts.ignoreLines && type === "line") || isDecorative; + return { index: i, type, x, y, w, h, ignorable }; + }); + let overlapCount = 0; + let containmentCount = 0; + for (let i = 0; i < elements.length; i++) { + const a = elements[i]; + if (a.ignorable) continue; + for (let j = i + 1; j < elements.length; j++) { + const b = elements[j]; + if (b.ignorable) continue; + const comparison = compareElementPosition(slide, a.index, b.index); + if (comparison.relation === "overlapping") { + // Special-case: diagonal line's bounding box overlapping a rectangle is often a false positive. + const EPS = 1e-6; + const getBounds = (e) => ({ + x: e.x, + y: e.y, + x2: e.x + e.w, + y2: e.y + e.h, + }); + const lineRectFalsePositive = (() => { + const oneIsLine = (a.type === "line") ^ (b.type === "line"); + if (!oneIsLine) return false; + const line = a.type === "line" ? a : b; + const rect = a.type === "line" ? b : a; + // If line is diagonal, verify actual segment intersects rect; if not, ignore. + const isDiagonal = line.w > EPS && line.h > EPS; + const lineSeg = { + x1: line.x, + y1: line.y, + x2: line.x + line.w, + y2: line.y + line.h, + }; + const rectB = getBounds(rect); + const pointInRect = (px, py, rb) => + px >= rb.x - EPS && + px <= rb.x2 + EPS && + py >= rb.y - EPS && + py <= rb.y2 + EPS; + const segsIntersect = (p1, p2, q1, q2) => { + const cross = (ax, ay, bx, by) => ax * by - ay * bx; + const d1x = p2.x - p1.x, + d1y = p2.y - p1.y; + const d2x = q2.x - q1.x, + d2y = q2.y - q1.y; + const denom = cross(d1x, d1y, d2x, d2y); + if (Math.abs(denom) < EPS) { + // Parallel: check colinearity and overlapping projections + const crossCol = cross(q1.x - p1.x, q1.y - p1.y, d1x, d1y); + if (Math.abs(crossCol) > EPS) return false; + const proj = (a, b, c) => + Math.min(Math.max(a, b), Math.max(Math.min(a, b), c)); + const overlapX = !( + Math.max(p1.x, p2.x) < Math.min(q1.x, q2.x) - EPS || + Math.max(q1.x, q2.x) < Math.min(p1.x, p2.x) - EPS + ); + const overlapY = !( + Math.max(p1.y, p2.y) < Math.min(q1.y, q2.y) - EPS || + Math.max(q1.y, q2.y) < Math.min(p1.y, p2.y) - EPS + ); + return overlapX && overlapY; + } + const t = cross(q1.x - p1.x, q1.y - p1.y, d2x, d2y) / denom; + const u = cross(q1.x - p1.x, q1.y - p1.y, d1x, d1y) / denom; + return t >= -EPS && t <= 1 + EPS && u >= -EPS && u <= 1 + EPS; + }; + const intersectsRect = (seg, rb) => { + if ( + pointInRect(seg.x1, seg.y1, rb) || + pointInRect(seg.x2, seg.y2, rb) + ) + return true; + const r1 = { x: rb.x, y: rb.y }, + r2 = { x: rb.x2, y: rb.y }, + r3 = { x: rb.x2, y: rb.y2 }, + r4 = { x: rb.x, y: rb.y2 }; + const p1 = { x: seg.x1, y: seg.y1 }, + p2 = { x: seg.x2, y: seg.y2 }; + return ( + segsIntersect(p1, p2, r1, r2) || + segsIntersect(p1, p2, r2, r3) || + segsIntersect(p1, p2, r3, r4) || + segsIntersect(p1, p2, r4, r1) + ); + }; + return isDiagonal && !intersectsRect(lineSeg, rectB); + })(); + if (!lineRectFalsePositive) { + overlapCount++; + + const severeTextOverlap = (() => { + if (!comparison.intersection) return false; + const exceedsThreshold = (element) => + element.type === "text" && + comparison.intersection.w >= TEXT_OVERLAP_ERROR_THRESHOLD && + comparison.intersection.h >= TEXT_OVERLAP_ERROR_THRESHOLD; + return exceedsThreshold(a) || exceedsThreshold(b); + })(); + if (severeTextOverlap) { + const overlapW = comparison.intersection.w; + const overlapH = comparison.intersection.h; + let rectificationSuggestion = ""; + if (overlapW > EPS && overlapH > EPS) { + const maxOverlap = Math.max(overlapW, overlapH); + const diffRatio = Math.abs(overlapW - overlapH) / maxOverlap; + const directions = []; + // Attempt to determine the primary direction of the overlap. This is the direction + // in which the overlap is smaller (and so requires the smallest adjustment to rectify). + if (diffRatio <= RECTIFY_DIRECTION_EQUALITY_TOLERANCE) { + directions.push("horizontally", "vertically"); + } else if (overlapW < overlapH) { + directions.push("horizontally"); + } else { + directions.push("vertically"); + } + rectificationSuggestion = `Suggestion: reposition elements ${directions.join( + " and " + )}.`; + } + + console.error( + `❌ ${slideLabel}: Severe text overlap detected between ${formatElement( + a + )} and ${formatElement( + b + )} (overlap_horizontal=${comparison.intersection.w.toFixed( + 3 + )}, overlap_vertical=${comparison.intersection.h.toFixed( + 3 + )}). THIS MUST BE FIXED. ${rectificationSuggestion}` + ); + } else { + console.warn( + `⚠️ ${slideLabel}: Overlap detected between ${formatElement( + a + )} and ${formatElement(b)}.` + ); + } + } + } else if (comparison.relation === "contained") { + if (!opts.muteContainment) { + containmentCount++; + const container = elements[comparison.containerIndex]; + const contained = elements[comparison.containedIndex]; + console.warn( + `⚠️ ${slideLabel}: ${formatElement( + contained + )} is fully contained within ${formatElement(container)}` + ); + } else { + // Still count internally when muted? We keep for summary only when un-muted + } + } + } + } + if (!(overlapCount === 0 && (!containmentCount || opts.muteContainment))) { + const issues = []; + if (overlapCount > 0) issues.push(`${overlapCount} overlapping pair(s)`); + if (!opts.muteContainment && containmentCount > 0) + issues.push(`${containmentCount} containment case(s)`); + console.log(`⚠️ ${slideLabel}: Found ${issues.join(" and ")}.`); + } +} + +function compareElementPosition(slide, firstIndex, secondIndex) { + if (!slide || !Array.isArray(slide._slideObjects)) { + throw new Error("Invalid slide object passed to compareElementPosition()"); + } + if ( + typeof firstIndex !== "number" || + typeof secondIndex !== "number" || + !Number.isInteger(firstIndex) || + !Number.isInteger(secondIndex) + ) { + throw new Error("Element indices must be integer values."); + } + const elements = slide._slideObjects; + if ( + firstIndex < 0 || + firstIndex >= elements.length || + secondIndex < 0 || + secondIndex >= elements.length + ) { + throw new Error( + "Element index out of bounds for compareElementPosition()." + ); + } + const EPS = 1e-4; + const getBounds = (obj) => { + const source = obj?.data || obj?.options || {}; + let x = typeof source.x === "number" ? source.x : 0; + let y = typeof source.y === "number" ? source.y : 0; + let w = typeof source.w === "number" ? source.w : 0; + let h = typeof source.h === "number" ? source.h : 0; + if (source.sizing && source.sizing.type === "crop") { + if (typeof source.sizing.w === "number") w = source.sizing.w; + if (typeof source.sizing.h === "number") h = source.sizing.h; + } + return { x, y, w, h, x2: x + w, y2: y + h }; + }; + const boundsA = getBounds(elements[firstIndex]); + const boundsB = getBounds(elements[secondIndex]); + const separated = + boundsA.x2 < boundsB.x - EPS || + boundsB.x2 < boundsA.x - EPS || + boundsA.y2 < boundsB.y - EPS || + boundsB.y2 < boundsA.y - EPS; + if (separated) { + return { + relation: "disjoint", + containerIndex: null, + containedIndex: null, + aBounds: boundsA, + bBounds: boundsB, + intersection: null, + }; + } + const aContainsB = + boundsA.x <= boundsB.x + EPS && + boundsA.y <= boundsB.y + EPS && + boundsA.x2 >= boundsB.x2 - EPS && + boundsA.y2 >= boundsB.y2 - EPS; + const bContainsA = + boundsB.x <= boundsA.x + EPS && + boundsB.y <= boundsA.y + EPS && + boundsB.x2 >= boundsA.x2 - EPS && + boundsB.y2 >= boundsA.y2 - EPS; + const ix1 = Math.max(boundsA.x, boundsB.x); + const iy1 = Math.max(boundsA.y, boundsB.y); + const ix2 = Math.min(boundsA.x2, boundsB.x2); + const iy2 = Math.min(boundsA.y2, boundsB.y2); + const intersectionWidth = Math.max(0, ix2 - ix1); + const intersectionHeight = Math.max(0, iy2 - iy1); + const intersection = + intersectionWidth > EPS && intersectionHeight > EPS + ? { x: ix1, y: iy1, w: intersectionWidth, h: intersectionHeight } + : null; + if (aContainsB && !bContainsA) { + return { + relation: "contained", + containerIndex: firstIndex, + containedIndex: secondIndex, + aBounds: boundsA, + bBounds: boundsB, + intersection, + }; + } + if (bContainsA && !aContainsB) { + return { + relation: "contained", + containerIndex: secondIndex, + containedIndex: firstIndex, + aBounds: boundsA, + bBounds: boundsB, + intersection, + }; + } + if (intersection) { + return { + relation: "overlapping", + containerIndex: null, + containedIndex: null, + aBounds: boundsA, + bBounds: boundsB, + intersection, + }; + } + return { + relation: "touching", + containerIndex: null, + containedIndex: null, + aBounds: boundsA, + bBounds: boundsB, + intersection: null, + }; +} + +const VALID_ALIGNMENTS = new Set([ + "left", + "right", + "top", + "bottom", + "verticallyCenter", + "horizontallyCenter", +]); + +const getElementBounds = (obj) => { + const source = obj?.data || obj?.options || {}; + let x = typeof source.x === "number" ? source.x : 0; + let y = typeof source.y === "number" ? source.y : 0; + let w = typeof source.w === "number" ? source.w : 0; + let h = typeof source.h === "number" ? source.h : 0; + // If an image is placed with crop sizing, pptxgenjs stores a larger virtual image w/h + // and a viewport in source.sizing.{w,h}. For visual overlap purposes, use the viewport. + if (source.sizing && source.sizing.type === "crop") { + if (typeof source.sizing.w === "number") w = source.sizing.w; + if (typeof source.sizing.h === "number") h = source.sizing.h; + } + return { x, y, w, h, x2: x + w, y2: y + h }; +}; + +const setElementPosition = (obj, coords) => { + const ensureTarget = (targetObj) => { + if (!targetObj || typeof targetObj !== "object") return null; + return targetObj; + }; + const targets = []; + const dataTarget = ensureTarget(obj.data); + if (dataTarget) targets.push(dataTarget); + const optionsTarget = + obj.options && obj.options !== obj.data ? ensureTarget(obj.options) : null; + if (optionsTarget) targets.push(optionsTarget); + if (targets.length === 0) { + obj.data = obj.data && typeof obj.data === "object" ? obj.data : {}; + targets.push(obj.data); + } + targets.forEach((target) => { + if (coords.x !== undefined) target.x = coords.x; + if (coords.y !== undefined) target.y = coords.y; + }); +}; + +const dimensionKeyPairs = [ + ["width", "height"], + ["w", "h"], + ["cx", "cy"], + ["slideWidth", "slideHeight"], + ["slideWidthInches", "slideHeightInches"], + ["widthInches", "heightInches"], +]; + +const toNumber = (value) => { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = parseFloat(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +}; + +const readDimensionsFromObject = (candidate, seen = new Set()) => { + if (!candidate || typeof candidate !== "object") return null; + if (seen.has(candidate)) return null; + seen.add(candidate); + for (const [wKey, hKey] of dimensionKeyPairs) { + const width = toNumber(candidate[wKey]); + const height = toNumber(candidate[hKey]); + if (width !== null && height !== null && width > 0 && height > 0) { + return { width, height }; + } + } + const nestedKeys = ["size", "slideSize", "layout", "slideLayout"]; + for (const key of nestedKeys) { + const nested = readDimensionsFromObject(candidate[key], seen); + if (nested) return nested; + } + return null; +}; + +const getSlideDimensions = (slide, pptx) => { + const candidates = [ + slide?._presLayout, + slide?._slideLayout, + slide?._pres?.layout, + slide?._parent?.layout, + slide?._layout, + pptx?._presLayout, + pptx?._layout, + pptx?.layout, + pptx?.presLayout, + ]; + for (const candidate of candidates) { + const dims = readDimensionsFromObject(candidate); + if (dims) { + // Some internals are in EMUs; convert if values look too large for inches + const EMU_PER_IN = 914400; + const looksEmu = dims.width > 1000 || dims.height > 1000; + if (looksEmu) { + return { + width: dims.width / EMU_PER_IN, + height: dims.height / EMU_PER_IN, + source: "emu_converted", + }; + } + return { ...dims, source: "detected" }; + } + } + throw new Error( + "getSlideDimensions(): Unable to determine slide dimensions from pptxgenjs internals." + ); +}; + +function alignSlideElements(slide, indices, alignment) { + if (!slide || !Array.isArray(slide._slideObjects)) { + throw new Error("Invalid slide object passed to alignSlideElements()"); + } + if (!Array.isArray(indices) || indices.length === 0) { + throw new Error("indices must be a non-empty array."); + } + if (!VALID_ALIGNMENTS.has(alignment)) { + throw new Error(`Unsupported alignment option: ${alignment}`); + } + const uniqueIndices = [...new Set(indices)]; + const elements = slide._slideObjects; + const selected = uniqueIndices.map((idx) => { + if (typeof idx !== "number" || !Number.isInteger(idx)) { + throw new Error("Element indices must be integers."); + } + if (idx < 0 || idx >= elements.length) { + throw new Error("Element index out of bounds for alignSlideElements()."); + } + const obj = elements[idx]; + const bounds = getElementBounds(obj); + return { index: idx, obj, bounds }; + }); + if (selected.length < 2) return; + const minX = Math.min(...selected.map((item) => item.bounds.x)); + const maxX2 = Math.max(...selected.map((item) => item.bounds.x2)); + const minY = Math.min(...selected.map((item) => item.bounds.y)); + const maxY2 = Math.max(...selected.map((item) => item.bounds.y2)); + const centerX = (minX + maxX2) / 2; + const centerY = (minY + maxY2) / 2; + selected.forEach(({ obj, bounds }) => { + const { w, h } = bounds; + switch (alignment) { + case "left": + setElementPosition(obj, { x: minX }); + break; + case "right": + setElementPosition(obj, { x: maxX2 - w }); + break; + case "top": + setElementPosition(obj, { y: minY }); + break; + case "bottom": + setElementPosition(obj, { y: maxY2 - h }); + break; + case "horizontallyCenter": + setElementPosition(obj, { x: centerX - w / 2 }); + break; + case "verticallyCenter": + setElementPosition(obj, { y: centerY - h / 2 }); + break; + default: + throw new Error(`Unhandled alignment option: ${alignment}`); + } + }); +} + +function distributeSlideElements(slide, indices, direction) { + if (!slide || !Array.isArray(slide._slideObjects)) { + throw new Error("Invalid slide object passed to distributeSlideElements()"); + } + if (!Array.isArray(indices) || indices.length === 0) { + throw new Error("indices must be a non-empty array."); + } + if (direction !== "horizontal" && direction !== "vertical") { + throw new Error(`Unsupported distribution direction: ${direction}`); + } + const uniqueIndices = [...new Set(indices)]; + if (uniqueIndices.length < 2) return; + const elements = slide._slideObjects; + const selected = uniqueIndices.map((idx) => { + if (typeof idx !== "number" || !Number.isInteger(idx)) { + throw new Error("Element indices must be integers."); + } + if (idx < 0 || idx >= elements.length) { + throw new Error( + "Element index out of bounds for distributeSlideElements()." + ); + } + const obj = elements[idx]; + const bounds = getElementBounds(obj); + return { index: idx, obj, bounds }; + }); + const axisStartKey = direction === "horizontal" ? "x" : "y"; + const axisEndKey = direction === "horizontal" ? "x2" : "y2"; + const sizeKey = direction === "horizontal" ? "w" : "h"; + selected.sort((a, b) => { + const delta = a.bounds[axisStartKey] - b.bounds[axisStartKey]; + return Math.abs(delta) > 1e-6 ? delta : a.index - b.index; + }); + const minCoord = Math.min( + ...selected.map((item) => item.bounds[axisStartKey]) + ); + const maxCoord = Math.max(...selected.map((item) => item.bounds[axisEndKey])); + const totalSpan = maxCoord - minCoord; + const gaps = selected.length - 1; + const totalSize = selected.reduce( + (sum, item) => sum + item.bounds[sizeKey], + 0 + ); + const gapSize = gaps > 0 ? (totalSpan - totalSize) / gaps : 0; + let cursor = minCoord; + selected.forEach(({ obj, bounds }) => { + if (direction === "horizontal") { + setElementPosition(obj, { x: cursor }); + cursor += bounds.w + gapSize; + } else { + setElementPosition(obj, { y: cursor }); + cursor += bounds.h + gapSize; + } + }); +} + +function warnIfSlideElementsOutOfBounds(slide, pptx) { + if (!slide || !Array.isArray(slide._slideObjects)) { + console.warn( + "Invalid slide object passed to warnIfSlideElementsOutOfBounds()" + ); + return; + } + const { + width: slideWidth, + height: slideHeight, + source, + } = getSlideDimensions(slide, pptx); + const slideIndex = + pptx && Array.isArray(pptx._slides) ? pptx._slides.indexOf(slide) : -1; + const slideLabel = + slideIndex >= 0 ? `Slide ${slideIndex + 1}` : "(Unknown slide index)"; + if (source === "default") { + console.warn( + `⚠️ ${slideLabel}: Unable to determine slide dimensions from pptxgenjs internals; assuming width=${slideWidth}, height=${slideHeight}.` + ); + } + const EPS = 1e-4; + let outOfBoundsCount = 0; + const formatElement = (idx, type, bounds) => { + const cx = (bounds.x + bounds.w / 2).toFixed(3); + const cy = (bounds.y + bounds.h / 2).toFixed(3); + return `Element ${idx} (${type}, center_x=${cx}, center_y=${cy})`; + }; + slide._slideObjects.forEach((obj, index) => { + const bounds = getElementBounds(obj); + const type = inferElementType(obj); + const violations = []; + if (bounds.x < -EPS) violations.push(`left=${bounds.x.toFixed(3)} < 0`); + if (bounds.y < -EPS) violations.push(`top=${bounds.y.toFixed(3)} < 0`); + if (bounds.x2 > slideWidth + EPS) + violations.push( + `right=${bounds.x2.toFixed(3)} > width=${slideWidth.toFixed(3)}` + ); + if (bounds.y2 > slideHeight + EPS) + violations.push( + `bottom=${bounds.y2.toFixed(3)} > height=${slideHeight.toFixed(3)}` + ); + if (violations.length > 0) { + outOfBoundsCount++; + console.warn( + `⚠️ ${slideLabel}: ${formatElement( + index, + type, + bounds + )} exceeds slide bounds (${violations.join(", ")}).` + ); + } + }); + if (outOfBoundsCount > 0) { + console.log( + `⚠️ ${slideLabel}: Found ${outOfBoundsCount} element(s) extending beyond the slide bounds.` + ); + } +} + +module.exports = { + inferElementType, + compareElementPosition, + warnIfSlideHasOverlaps, + alignSlideElements, + distributeSlideElements, + warnIfSlideElementsOutOfBounds, + getSlideDimensions, +}; diff --git a/agents/.agents/skills/slides/assets/pptxgenjs_helpers/layout_builders.js b/agents/.agents/skills/slides/assets/pptxgenjs_helpers/layout_builders.js new file mode 100644 index 0000000..f26990b --- /dev/null +++ b/agents/.agents/skills/slides/assets/pptxgenjs_helpers/layout_builders.js @@ -0,0 +1,358 @@ +// Copyright (c) OpenAI. All rights reserved. +"use strict"; + +const { calcTextBox, autoFontSize } = require("./text"); +const { imageSizingCrop, imageSizingContain } = require("./image"); +const { getSlideDimensions } = require("./layout"); + +module.exports = { + addImageTextCard, + addCardRow, + addThreeLevelTree, +}; + +function addImageTextCard(slide, opts = {}) { + const x = toNumberOr(opts.x, 0); + const y = toNumberOr(opts.y, 0); + const w = toNumberOr(opts.width, 3.0); + const gap = toNumberOr(opts.gap, 0.15); + const image = opts.image || {}; + const text = opts.text || ""; + const textBox = opts.textBox || {}; + + const boxH = toNumberOr(image.boxHeight, 2.2); + const sizing = (image.sizing || "crop").toLowerCase(); + let imgPlacement; + if (image.path || image.data) { + const base = image.path ? { path: image.path } : { data: image.data }; + if (sizing === "contain") { + imgPlacement = imageSizingContain( + image.path || image.data, + x, + y, + w, + boxH + ); + slide.addImage({ ...base, ...imgPlacement }); + } else { + const c = image.crop || {}; + imgPlacement = imageSizingCrop( + image.path || image.data, + x, + y, + w, + boxH, + c.cx, + c.cy, + c.cw, + c.ch + ); + slide.addImage({ ...base, ...imgPlacement }); + } + } + + const textY = y + boxH + gap; + const fontSize = toNumberOr(textBox.fontSize, 14); + const fontFaceRaw = textBox.fontFace; + const fontFace = + typeof fontFaceRaw === "string" && fontFaceRaw.trim().length > 0 + ? fontFaceRaw.trim() + : null; + if (!fontFace) { + throw new Error( + "addImageTextCard(): textBox.fontFace is required for text measurement." + ); + } + let hText; + let textOptions; + + if (textBox.h != null && Number.isFinite(toNumberOr(textBox.h, NaN))) { + // Layout-first: caller fixed the box height, so adjust font size to fit via autoFontSize. + const fixedH = toNumberOr(textBox.h, 0); + const baseOpts = { + x, + y: textY, + w, + h: fixedH, + mode: textBox.mode || "auto", + fontSize, + minFontSize: textBox.minFontSize, + maxFontSize: textBox.maxFontSize, + margin: textBox.margin, + paraSpaceAfter: textBox.paraSpaceAfter, + }; + const autoOpts = autoFontSize(text, fontFace, baseOpts); + hText = fixedH; + textOptions = { + ...autoOpts, + fontFace, + color: textBox.color, + align: textBox.align, + valign: textBox.valign || "top", + fill: opts.background, + }; + } else { + // Content-first: fixed font size, let calcTextBox derive the required height. + const layout = calcTextBox(fontSize, { + text, + w, + fontFace, + margin: textBox.margin, + paraSpaceAfter: textBox.paraSpaceAfter, + }); + hText = layout.h; + textOptions = { + x, + y: textY, + w, + h: hText, + fontFace, + fontSize, + color: textBox.color, + align: textBox.align, + valign: textBox.valign || "top", + paraSpaceAfter: textBox.paraSpaceAfter, + margin: textBox.margin, + fill: opts.background, + }; + } + + slide.addText(text, textOptions); + + return { + x, + y, + w, + image: { + x: imgPlacement?.x ?? x, + y, + w: imgPlacement?.w ?? w, + h: imgPlacement?.h ?? boxH, + }, + text: { x, y: textY, w, h: hText }, + }; +} + +function addCardRow(slide, region, cards = [], options = {}) { + const rx = toNumberOr(region.x, 0.4); + const ry = toNumberOr(region.y, 1.6); + const slideWidth = getSlideDimensions(slide).width; + const rw = toNumberOr(region.w, slideWidth - rx * 2); + const gap = toNumberOr(options.gap, 0.25); + const count = cards.length; + if (count === 0) return []; + + let cardW; + if (options.widthStrategy === "fixed") { + cardW = toNumberOr( + options.cardWidth, + rw / count - (gap * (count - 1)) / count + ); + } else { + cardW = (rw - gap * (count - 1)) / count; + } + + const totalWidth = cardW * count + gap * (count - 1); + const align = options.align || "left"; + const ox = + align === "center" + ? (rw - totalWidth) / 2 + : align === "right" + ? rw - totalWidth + : 0; + + const placements = []; + for (let i = 0; i < count; i++) { + const x = rx + ox + i * (cardW + gap); + placements.push( + addImageTextCard(slide, { ...cards[i], x, y: ry, width: cardW }) + ); + } + return placements; +} + +function addThreeLevelTree(slide, opts = {}) { + const slideWidth = getSlideDimensions(slide).width; + const cx = toNumberOr(opts.centerX, slideWidth / 2); + const topY = toNumberOr(opts.topY, 1.6); + + const rootW = toNumberOr(opts.root?.w, 3.3333333); + const rootH = toNumberOr(opts.root?.h, 0.93333333); + const rootX = cx - rootW / 2; + const rootFontFaceRaw = opts.root?.fontFace; + const rootFontFace = + typeof rootFontFaceRaw === "string" && rootFontFaceRaw.trim().length > 0 + ? rootFontFaceRaw.trim() + : null; + if (!rootFontFace) { + throw new Error( + "addThreeLevelTree(): opts.root.fontFace is required for text measurement." + ); + } + const rootFontSize = toNumberOr(opts.root?.fontSize, 16); + const rootText = opts.root?.text || ""; + const rootTextOpts = autoFontSize(rootText, rootFontFace, { + x: rootX, + y: topY, + w: rootW, + h: rootH, + mode: opts.root?.mode || "shrink", + fontSize: rootFontSize, + minFontSize: opts.root?.minFontSize, + maxFontSize: opts.root?.maxFontSize, + }); + slide.addText(rootText, { + ...rootTextOpts, + align: "center", + valign: "mid", + fontFace: rootFontFace, + color: opts.root?.color || "FFFFFF", + fill: { color: opts.root?.fill || "0B0F1A" }, + line: { color: opts.root?.line || opts.root?.fill || "0B0F1A" }, + }); + + const midLabels = Array.isArray(opts.mid?.labels) ? opts.mid.labels : []; + const midFontFaceRaw = opts.mid?.fontFace; + const midFontFace = + typeof midFontFaceRaw === "string" && midFontFaceRaw.trim().length > 0 + ? midFontFaceRaw.trim() + : null; + if (!midFontFace) { + throw new Error( + "addThreeLevelTree(): opts.mid.fontFace is required for text measurement." + ); + } + let midW = toNumberOr(opts.mid?.w, NaN); + const midH = toNumberOr(opts.mid?.h, rootH); + const midY = toNumberOr(opts.mid?.y, topY + rootH + 1.2); + const requestedSpacing = toNumberOr(opts.mid?.spacing, NaN); // center-to-center distance if provided + const leftRightMargin = toNumberOr(opts.mid?.marginX, 0.6); + const availableRowWidth = slideWidth - leftRightMargin * 2; + const countMid = midLabels.length; + const minGap = 0.4; + if (!Number.isFinite(midW) && Number.isFinite(requestedSpacing)) { + // Derive midW from spacing and available width + const totalSpan = requestedSpacing * (countMid - 1) + 0; // span between first and last centers + const maxW = Math.min(rootW, (availableRowWidth - totalSpan) / countMid); + midW = Math.max(0.8, maxW); + } + if (!Number.isFinite(midW)) { + // Fit equally within available width with minimum gaps + midW = Math.max( + 0.8, + (availableRowWidth - minGap * (countMid - 1)) / countMid + ); + } + // Compute gap to center-group horizontally without overlap + let gap = Math.max( + minGap, + (availableRowWidth - midW * countMid) / Math.max(1, countMid - 1) + ); + const totalWidth = midW * countMid + gap * (countMid - 1); + const startLeft = cx - totalWidth / 2; + for (let i = 0; i < midLabels.length; i++) { + const x = startLeft + i * (midW + gap); + const midText = midLabels[i] || ""; + const midFontSize = toNumberOr(opts.mid?.fontSize, 16); + const midTextOpts = autoFontSize(midText, midFontFace, { + x, + y: midY, + w: midW, + h: midH, + mode: opts.mid?.mode || "shrink", + fontSize: midFontSize, + minFontSize: opts.mid?.minFontSize, + maxFontSize: opts.mid?.maxFontSize, + }); + slide.addText(midText, { + ...midTextOpts, + align: "center", + valign: "mid", + fontFace: midFontFace, + color: opts.mid?.color || "000000", + fill: { color: opts.mid?.fill || "A0BEC2" }, + line: { color: opts.mid?.line || opts.mid?.fill || "A0BEC2" }, + }); + addConnector(slide, cx, topY + rootH, x + midW / 2, midY, opts.line); + } + + const leavesPerMid = Array.isArray(opts.leaf?.labelsPerMid) + ? opts.leaf.labelsPerMid + : []; + const leafFontFaceRaw = opts.leaf?.fontFace; + const leafFontFace = + typeof leafFontFaceRaw === "string" && leafFontFaceRaw.trim().length > 0 + ? leafFontFaceRaw.trim() + : null; + if (!leafFontFace) { + throw new Error( + "addThreeLevelTree(): opts.leaf.fontFace is required for text measurement." + ); + } + const leafW = toNumberOr(opts.leaf?.w, 1.05); + const leafH = toNumberOr(opts.leaf?.h, 1.0666667); + const leafY = toNumberOr(opts.leaf?.y, midY + midH + 1.0); + const minLeafGap = 0.2; + for (let i = 0; i < midLabels.length; i++) { + const xBase = startLeft + i * (midW + gap); + const childLabels = Array.isArray(leavesPerMid[i]) ? leavesPerMid[i] : []; + const childCount = childLabels.length || 3; + // Compute per-mid gap to fit children within midW without overlap + const leafGap = Math.max( + minLeafGap, + (midW - childCount * leafW) / Math.max(1, childCount - 1) + ); + const totalWidth = childCount * leafW + (childCount - 1) * leafGap; + const leftX = xBase + (midW - totalWidth) / 2; + for (let j = 0; j < childCount; j++) { + const x = leftX + j * (leafW + leafGap); + const leafText = childLabels[j] || ""; + const leafFontSize = toNumberOr(opts.leaf?.fontSize, 16); + const leafTextOpts = autoFontSize(leafText, leafFontFace, { + x, + y: leafY, + w: leafW, + h: leafH, + mode: opts.leaf?.mode || "shrink", + fontSize: leafFontSize, + minFontSize: opts.leaf?.minFontSize, + maxFontSize: opts.leaf?.maxFontSize, + }); + slide.addText(leafText, { + ...leafTextOpts, + align: "center", + valign: "mid", + fontFace: leafFontFace, + color: opts.leaf?.color || "000000", + fill: { color: opts.leaf?.fill || "A6C1EE" }, + line: { color: opts.leaf?.line || opts.leaf?.fill || "A6C1EE" }, + }); + addConnector( + slide, + xBase + midW / 2, + midY + midH, + x + leafW / 2, + leafY, + opts.line + ); + } + } +} + +function addConnector(slide, x1, y1, x2, y2, line = {}) { + const x = Math.min(x1, x2); + const y = Math.min(y1, y2); + slide.addShape("line", { + x, + y, + w: Math.abs(x2 - x1), + h: Math.abs(y2 - y1), + line: { color: line.color || "000000", pt: line.pt || 1 }, + flipH: x2 < x1 ? true : undefined, + }); +} + +function toNumberOr(v, fallback) { + const n = typeof v === "string" ? parseFloat(v) : v; + return Number.isFinite(n) ? n : fallback; +} diff --git a/agents/.agents/skills/slides/assets/pptxgenjs_helpers/svg.js b/agents/.agents/skills/slides/assets/pptxgenjs_helpers/svg.js new file mode 100644 index 0000000..2207994 --- /dev/null +++ b/agents/.agents/skills/slides/assets/pptxgenjs_helpers/svg.js @@ -0,0 +1,36 @@ +// Copyright (c) OpenAI. All rights reserved. +"use strict"; + +function toDataUri(svg) { + return "data:image/svg+xml;base64," + Buffer.from(svg).toString("base64"); +} + +function sanitizeSvg(svg) { + let inner = svg; + const a = inner.indexOf(""); + if (a !== -1 && b !== -1) inner = inner.slice(a, b + 6); + inner = inner.replace(/<\?xml[^>]*>/g, ""); + if (!/xmlns=\"http:\/\/www\.w3\.org\/2000\/svg\"/.test(inner)) { + inner = inner.replace(/ { + const px = Math.round(parseFloat(num) * 8.5); + return `${attr}="${px}px"`; + } + ); + inner = inner.replace(/currentColor/g, "#000000"); + return inner; +} + +function svgToDataUri(svg) { + return toDataUri(sanitizeSvg(svg)); +} + +module.exports = { + toDataUri, + sanitizeSvg, + svgToDataUri, +}; diff --git a/agents/.agents/skills/slides/assets/pptxgenjs_helpers/text.js b/agents/.agents/skills/slides/assets/pptxgenjs_helpers/text.js new file mode 100644 index 0000000..22bd8d0 --- /dev/null +++ b/agents/.agents/skills/slides/assets/pptxgenjs_helpers/text.js @@ -0,0 +1,789 @@ +// Copyright (c) OpenAI. All rights reserved. +"use strict"; + +const { spawnSync } = require("child_process"); +const { Canvas } = require("skia-canvas"); +// Unicode line-break iterator (UAX #14) so we mimic PPT/LibreOffice wrapping rules. +const LineBreaker = require("linebreak"); +const fontkit = require("fontkit"); +const TEXT_MEASURER = getTextMeasurer(); +const registeredFontVariants = new Set(); +const fontPathCache = new Map(); +const fontKitCache = new Map(); + +// Estimate the text box height for a given font size and line count. +// NOTE: This is an analytical approximation, not an exact reproduction of +// PowerPoint/LibreOffice layout. Always verify visually and adjust based on +// actual rendering if precise fit is required. +function calcTextBoxHeightSimple( + fontSize, + lines = 1, + leading = 1.15, + padding = 0.3 +) { + const lineHeightIn = (fontSize / 72) * leading; + return lines * lineHeightIn + padding; +} + +// Compute font size that fits given text within a fixed box. +// NOTE: autoFontSize uses skia-canvas measurement stack to approximate the font size +// that will fit in a given box. Rendering engines may differ slightly, so +// treat the result as an estimate and tweak as needed after visual inspection. +// Signature: +// autoFontSize(textOrRuns, fontFace, opts?) +// - fontFace must be provided as the 2nd positional argument and cannot be in opts. +// - All modes always respect [minFontSize, maxFontSize] as a CLOSED interval when provided. +// Modes: +// - mode: "shrink" => shrink only (search [minFontSize, min(maxFontSize, fontSize)]) +// - mode: "enlarge" => enlarge only (search [max(minFontSize, fontSize), maxFontSize]) +// - mode: "auto" => shrink + enlarge (search [minFontSize, maxFontSize]); fontSize optional. +// In "auto" mode fontSize is not required; when omitted we simply search the whole [minFontSize, maxFontSize] range. +// Returns a cloned options object with computed fontSize. fit: "shrink" is appended only when mode === "shrink". +function autoFontSize(textOrRuns, fontFace, opts = {}) { + const x = toNumber(opts.x, 0); + const y = toNumber(opts.y, 0); + const w = toNumber(opts.w, 0); + const h = toNumber(opts.h, 0); + if (!(w > 0 && h > 0)) throw new Error("autoFontSize(): non-positive w or h"); + + const face = typeof fontFace === "string" ? fontFace.trim() : ""; + if (face.length === 0) { + throw new Error( + "autoFontSize(): fontFace is required as the 2nd positional argument." + ); + } + + // Fast-path: if there is no visible text content, just return the + // (optionally clamped) reference fontSize; there is nothing to fit. + const hasAnyText = + normalizeText(textOrRuns).trim().length > 0 || + (Array.isArray(textOrRuns) && + textOrRuns.some( + (run) => run && typeof run.text === "string" && run.text.trim().length + )); + + const fontStyle = + opts.italic === true || opts.fontStyle === "italic" ? "italic" : "normal"; + const fontWeight = + opts.bold === true || String(opts.fontWeight || "").toLowerCase() === "bold" + ? "bold" + : "normal"; + const leading = toNumber(opts.leading, 1.15) || 1.15; + + const modeRaw = typeof opts.mode === "string" ? opts.mode : "auto"; // 'auto' (default) | 'shrink' | 'enlarge' + const mode = modeRaw.toLowerCase(); + const isShrink = mode === "shrink"; + const isEnlarge = mode === "enlarge"; + const isAuto = mode === "auto"; + + const refPtRaw = toNumber(opts.fontSize, NaN); + const hasRefPt = Number.isFinite(refPtRaw); + const refPt = hasRefPt ? refPtRaw : NaN; + + // Base bounds (closed interval). Defaults: + // - minFontSize: 1pt + // - maxFontSize: 1000pt (unless the caller provided a tighter bound) + let minPt = toNumber(opts.minFontSize, NaN); + let maxPt = toNumber(opts.maxFontSize, NaN); + const userProvidedMax = Number.isFinite(maxPt); + if (!Number.isFinite(minPt)) { + minPt = 1; + } + if (!Number.isFinite(maxPt)) { + maxPt = 1000; + } + + if (isShrink || isEnlarge) { + if (!hasRefPt) { + throw new Error( + "autoFontSize(): mode 'shrink' or 'enlarge' requires fontSize" + ); + } + } + + if (isShrink) { + // Shrink only: never exceed the requested size (and respect maxFontSize). + maxPt = Math.min(maxPt, refPt); + } else if (isEnlarge) { + // Enlarge only: never go below the requested size (and respect minFontSize). + minPt = Math.max(minPt, refPt); + } else if (isAuto && hasRefPt && userProvidedMax) { + // Auto mode with an explicit maxFontSize: honor [minFontSize, maxFontSize] + // as the search band while allowing both shrink and enlarge within it. + } else if (!isAuto) { + throw new Error( + `autoFontSize(): unsupported mode "${modeRaw}", expected "auto" | "shrink" | "enlarge"` + ); + } + + if (!(maxPt > 0 && maxPt >= minPt)) { + throw new Error( + "autoFontSize(): invalid minFontSize/maxFontSize bounds after normalization" + ); + } + + // If there is no actual text, we can skip measurement entirely and just + // clamp the reference size to [minPt, maxPt]. + if (!hasAnyText) { + const chosen = + (hasRefPt && Math.max(minPt, Math.min(maxPt, refPt))) || minPt; + const out = { ...opts, x, y, w, h, fontSize: chosen }; + if (isShrink) out.fit = "shrink"; + return out; + } + + // Search the space of candidate font sizes with a small step and a safety + // bias baked into the fit test: + // - precision: 0.05pt (~1/20pt) so we land very close to the true max-fit. + // - safetyFactor: we require that the calcTextBox()-measured height is + // within a small margin of the caller-provided box height, so that the + // same layout engine used by calcTextBox drives autoFontSize decisions. + const precision = 0.05; // point precision for search (~1/20pt) + const safetyFactor = 0.97; + + let lo = minPt; + let hi = maxPt; + let best = lo; + while (hi - lo > precision) { + const mid = (lo + hi) / 2; + // Delegate measurement to calcTextBox so that autoFontSize and + // calcTextBox share the exact same layout pipeline (paragraph modeling, + // bullet handling, margins, padding, width scaling, etc.). + const layout = calcTextBox(mid, { + text: textOrRuns, + w, + fontFace: face, + fontStyle, + fontWeight, + leading, + margin: opts.margin, + padding: opts.padding, + paraSpaceAfter: opts.paraSpaceAfter, + }); + const fits = layout.h <= h * safetyFactor + 1e-6; + if (fits) { + best = mid; + lo = mid; // try larger + } else { + hi = mid; // shrink + } + } + // Closed interval: clamp to [minPt, maxPt]. + const finalPt = Math.max(minPt, Math.min(maxPt, best)); + + // Pass through all original options, override fontSize and append fit: "shrink" + const out = { ...opts, x, y, w, h, fontSize: finalPt }; + if (isShrink) out.fit = "shrink"; + return out; +} + +// Calculate text box metrics using skia-canvas measurement (lines, height, +// width) for a given font size and text payload. +// NOTE: calcTextBox approximates how many lines and how much space text will +// occupy using our JS measurement pipeline. It is designed to be close to +// PowerPoint/LibreOffice but is not guaranteed pixel-perfect—always adjust +// based on actual slide rendering when precision matters. +// Signature: +// calcTextBox(fontSizePt, opts) +// - fontSizePt: number (points) +// - opts (keywords): { +// text?: string | runs[], +// w?: number (inches), +// h?: number (inches), +// lines?: number, +// fontFace?: string, // required when measuring by width/height with text +// fontStyle?: 'normal' | 'italic', italic?: boolean, +// fontWeight?: 'normal' | 'bold', bold?: boolean, +// leading?: number (line height multiplier, default 1.15), +// padding?: number (inches, default 0.3), +// paraSpaceAfter?: number (points, default 0) +// } +// Modes (auto-detected): +// a) Given lines -> compute height +// b) Given width + text -> compute height and lines +// c) Given height + text -> compute width and lines +// Throws when insufficient info is provided. +function calcTextBox(fontSizePt, opts = {}) { + const textInput = opts.text ?? ""; + const text = normalizeText(textInput || ""); + const face = + typeof opts.fontFace === "string" && opts.fontFace.trim().length > 0 + ? opts.fontFace.trim() + : ""; + const fontStyle = + opts.italic === true || opts.fontStyle === "italic" ? "italic" : "normal"; + const fontWeight = + opts.bold === true || String(opts.fontWeight || "").toLowerCase() === "bold" + ? "bold" + : "normal"; + const leading = toNumber(opts.leading, 1.15) || 1.15; + const padding = toNumber(opts.padding, 0.3); // inches (allow 0) + const paraSpaceAfterPt = toNumber(opts.paraSpaceAfter, 0) || 0; // points + const lineHeightIn = (fontSizePt / 72) * leading; + const margins = normalizeMargins(opts.margin); + const measurer = TEXT_MEASURER; + + const hasLines = Number.isFinite(toNumber(opts.lines, NaN)); + const hasWidth = Number.isFinite(toNumber(opts.w, NaN)); + const hasHeight = Number.isFinite(toNumber(opts.h, NaN)); + const paragraphs = buildParagraphModels(textInput, { + fontSizePt, + // Do not silently substitute a default font here; callers measuring by + // width/height are required to pass an explicit fontFace so that our + // metrics match the actual slide theme. + fontFace: face, + fontStyle, + fontWeight, + leading, + paraSpaceAfterPt, + }); + const hasAnyText = paragraphs.some((p) => p.text.length > 0); + + // Empirical top inset: PPT text frames render a small gutter above the first line + // even with zero margins. Model it as a fraction of the font size so callers can + // visually trim by shifting y up and growing h by the same amount. + const topInsetIn = (fontSizePt / 72) * 0.2; // ~20% of font size (inches) + + if (hasLines) { + // Mode (a): Given lines -> compute height only + const lines = toNumber(opts.lines, 1); + const contentH = Math.max(0, lines * lineHeightIn + padding); + const h = contentH + margins.top + margins.bottom; + const passthrough = buildPassthroughOptions(opts, fontSizePt, margins); + return { + ...passthrough, + w: toNumber(opts.w, NaN) || null, + h, + lines, + contentH, + margins, + topInset: topInsetIn, + }; + } + + if (hasWidth && hasAnyText) { + // Mode (b): Given width + text -> compute height and lines + if (face.length === 0) { + throw new Error( + "calcTextBox(): opts.fontFace is required when measuring by width." + ); + } + const boxW = toNumber(opts.w, 0); + if (!(boxW > 0)) + throw new Error("calcTextBox(): width must be > 0 in mode 'width'"); + const innerW = Math.max(0, boxW - margins.left - margins.right); + const { lines, heightIn } = layoutGivenWidth(paragraphs, innerW); + const contentH = Math.max(0, heightIn + padding); + const h = contentH + margins.top + margins.bottom; + const passthrough = buildPassthroughOptions(opts, fontSizePt, margins); + return { + ...passthrough, + w: boxW, + h, + lines, + contentH, + margins, + topInset: topInsetIn, + }; + } + + if (hasHeight && hasAnyText) { + // Mode (c): Given height + text -> compute minimal width and lines to fit + if (face.length === 0) { + throw new Error( + "calcTextBox(): opts.fontFace is required when measuring by height." + ); + } + const boxH = toNumber(opts.h, 0); + if (!(boxH > 0)) + throw new Error("calcTextBox(): height must be > 0 in mode 'height'"); + const innerH = Math.max(0, boxH - margins.top - margins.bottom); + // Upper bound: single-line width across paragraphs + const singleLineWidth = paragraphs.reduce((mx, p) => { + const width = measureRunWidth(p, p.text) + p.textIndentIn; + return Math.max(mx, width); + }, 0); + const minHeightOneLine = Math.max( + 0, + paragraphs.reduce((sum, p, idx) => { + const lineHeight = (p.fontSizePt / 72) * p.leading; + sum += lineHeight; + if (idx !== paragraphs.length - 1) sum += p.paraSpaceAfterIn; + return sum; + }, 0) + ); + if (minHeightOneLine + padding - innerH > 1e-6) { + throw new Error( + "calcTextBox(): height too small for one-line layout at this font size" + ); + } + // Lower bound: longest token width + const longestTokenWidth = paragraphs.reduce((mx, p) => { + const tokens = splitTextIntoTokens(p.text); + for (const tk of tokens) { + if (tk.length === 0) continue; + const wIn = measureRunWidth(p, tk) + p.textIndentIn; + if (wIn > mx) mx = wIn; + } + return mx; + }, 0); + let lo = Math.max(0.01, longestTokenWidth); + let hi = Math.max(lo, singleLineWidth); + let best = hi; + for (let iter = 0; iter < 32; iter++) { + const mid = (lo + hi) / 2; + const { lines, heightIn } = layoutGivenWidth(paragraphs, mid); + const totalH = heightIn + padding; + if (totalH <= innerH + 1e-6) { + best = mid; + hi = mid; + } else { + lo = mid; + } + } + const { lines, heightIn } = layoutGivenWidth(paragraphs, best); + const contentH = heightIn + padding; + const passthrough = buildPassthroughOptions(opts, fontSizePt, margins); + return { + ...passthrough, + w: best + margins.left + margins.right, + h: contentH + margins.top + margins.bottom, + lines, + contentH, + margins, + topInset: topInsetIn, + }; + } + + throw new Error( + "calcTextBox(): insufficient information. Provide {lines} or ({w,text}) or ({h,text})." + ); +} + +function layoutGivenWidth(paragraphs, boxW) { + let totalLines = 0; + let heightIn = 0; + for (let i = 0; i < paragraphs.length; i++) { + const para = paragraphs[i]; + const widthScale = getWidthScaleForParagraph(para); + const usableWidth = Math.max(0.01, boxW - para.textIndentIn) * widthScale; + const lines = greedyWrap(para, usableWidth); + const count = Math.max(1, lines.length); + totalLines += count; + const lineHeightIn = (para.fontSizePt / 72) * para.leading; + heightIn += count * lineHeightIn; + if (i !== paragraphs.length - 1) heightIn += para.paraSpaceAfterIn; + } + return { lines: totalLines, heightIn }; +} + +function greedyWrap(paragraph, maxWidthIn) { + const text = paragraph.text || ""; + if (text.length === 0) return [""]; + const breaker = new LineBreaker(text); + const breakpoints = []; + let bk; + while ((bk = breaker.nextBreak())) { + breakpoints.push({ pos: bk.position, required: bk.required }); + } + const lines = []; + let start = skipTextWhitespace(text, 0); + let idx = 0; + while (start < text.length) { + while (idx < breakpoints.length && breakpoints[idx].pos <= start) idx++; + let chosen = null; + let probe = idx; + while (probe < breakpoints.length) { + const br = breakpoints[probe]; + const slice = text.slice(start, br.pos); + const width = measureRunWidth(paragraph, trimLineEnd(slice)); + if (width <= maxWidthIn + 1e-6) { + chosen = br; + probe++; + if (br.required) break; + } else { + break; + } + } + if (!chosen) { + const forced = forceBreakSegment(text, start, maxWidthIn, paragraph); + if (forced.segment.length === 0) break; + lines.push(trimLineEnd(forced.segment)); + start = skipTextWhitespace(text, forced.nextIndex); + continue; + } + const lineText = text.slice(start, chosen.pos); + lines.push(trimLineEnd(lineText)); + start = skipTextWhitespace(text, chosen.pos); + } + if (!lines.length) lines.push(""); + return lines; +} + +function splitTextIntoTokens(text) { + if (typeof text !== "string") return [""]; + const tokens = text.split(/(\s+)/); + return tokens.length ? tokens : [""]; +} + +function trimLineEnd(value) { + return typeof value === "string" ? value.replace(/\s+$/u, "") : ""; +} + +function measureRunWidth(paragraph, text) { + if (!text || text.length === 0) return 0; + const fontData = getFontData( + paragraph.fontFace, + paragraph.fontStyle, + paragraph.fontWeight + ); + if (fontData && fontData.font) { + const layout = fontData.font.layout(text); + const widthPts = + (layout.advanceWidth / fontData.font.unitsPerEm) * paragraph.fontSizePt; + return Math.max(0, widthPts / 72); + } + return TEXT_MEASURER( + text, + paragraph.fontSizePt, + paragraph.fontFace, + paragraph.fontStyle, + paragraph.fontWeight + ); +} + +function forceBreakSegment(text, start, maxWidthIn, paragraph) { + const chars = Array.from(text.slice(start)); + if (chars.length === 0) return { segment: "", nextIndex: text.length }; + let buffer = ""; + let consumedUnits = 0; + for (let i = 0; i < chars.length; i++) { + const candidate = buffer + chars[i]; + const width = measureRunWidth(paragraph, trimLineEnd(candidate)); + if (width <= maxWidthIn + 1e-6) { + buffer = candidate; + consumedUnits += chars[i].length; + continue; + } + if (buffer.length === 0) { + buffer = chars[i]; + consumedUnits += chars[i].length; + } + break; + } + if (buffer.length === 0) { + buffer = chars[0] || ""; + consumedUnits = buffer.length; + } + return { segment: buffer, nextIndex: start + consumedUnits }; +} + +function skipTextWhitespace(text, index) { + let idx = index; + while (idx < text.length && /\s/.test(text[idx])) idx++; + return idx; +} + +function buildParagraphModels(textOrRuns, baseStyle) { + const entries = collectParagraphEntries(textOrRuns); + if (entries.length === 0) { + return [resolveParagraphStyle({ text: "" }, baseStyle)]; + } + return entries.map((entry) => resolveParagraphStyle(entry, baseStyle)); +} + +function collectParagraphEntries(textOrRuns) { + const result = []; + if (Array.isArray(textOrRuns)) { + for (const entry of textOrRuns) { + if (typeof entry === "string") { + pushParagraphSegments(entry, undefined, result); + } else if (entry && typeof entry === "object") { + pushParagraphSegments(entry.text ?? "", entry.options || {}, result); + } + } + return result; + } + pushParagraphSegments(textOrRuns ?? "", undefined, result); + return result; +} + +function pushParagraphSegments(text, options, target) { + const normalized = String(text ?? ""); + const parts = normalized.split(/\r?\n/); + if (parts.length === 0) { + target.push({ text: "", options }); + return; + } + for (const part of parts) { + target.push({ text: part, options }); + } +} + +function resolveParagraphStyle(entry, baseStyle) { + const opts = entry.options || {}; + const fontFace = + (opts.fontFace && String(opts.fontFace).trim()) || + baseStyle.fontFace || + "Arial"; + const fontStyle = + opts.italic === true || opts.fontStyle === "italic" + ? "italic" + : baseStyle.fontStyle || "normal"; + const fontWeight = + opts.bold === true || String(opts.fontWeight || "").toLowerCase() === "bold" + ? "bold" + : baseStyle.fontWeight || "normal"; + const fontSizePt = + toNumber(opts.fontSize, baseStyle.fontSizePt) || baseStyle.fontSizePt; + const leading = + toNumber(opts.leading, baseStyle.leading) || baseStyle.leading || 1.15; + const paraSpaceAfterPt = + toNumber(opts.paraSpaceAfter, baseStyle.paraSpaceAfterPt) || + baseStyle.paraSpaceAfterPt || + 0; + const hasBullet = !!opts.bullet; + let indentPt = toNumber(opts.indent, NaN); + if (!Number.isFinite(indentPt) && hasBullet) { + indentPt = toNumber(opts.bullet.indent, NaN); + } + if (!Number.isFinite(indentPt)) indentPt = 0; + const hangingPt = toNumber(opts.hanging, 0) || 0; + let textIndentIn = 0; + if (indentPt > 0) { + if (hasBullet) { + // PowerPoint-style bullets: "indent" is the distance from the left edge + // of the text box to the start of the text (the bullet itself is hung + // using the hanging value). This means the available width for the text + // is boxWidth - indent, not boxWidth - (indent - hanging). Modeling it + // this way matches the manual line counts from PowerPoint/LibreOffice. + textIndentIn = indentPt / 72; + } else { + // Non-bullet paragraphs keep the prior behavior where hanging reduces + // the effective indent (similar to CSS text-indent). + textIndentIn = Math.max(0, (indentPt - hangingPt) / 72); + } + } + return { + text: entry.text || "", + fontFace, + fontStyle, + fontWeight, + fontSizePt, + leading, + paraSpaceAfterIn: paraSpaceAfterPt / 72, + textIndentIn, + }; +} + +function getFontData(face, fontStyle, fontWeight) { + const key = makeFontCacheKey(face, fontStyle, fontWeight); + if (fontKitCache.has(key)) return fontKitCache.get(key); + const fontPath = findFontPath(face, fontStyle, fontWeight); + if (!fontPath) { + fontKitCache.set(key, null); + return null; + } + try { + let font = fontkit.openSync(fontPath); + if (font && typeof font.fonts === "object") { + font = selectCollectionFont(font, fontStyle, fontWeight); + } + if (!font || typeof font.layout !== "function") { + fontKitCache.set(key, null); + return null; + } + registerCanvasFontVariant(fontPath, face, fontStyle, fontWeight, key); + const payload = { font, path: fontPath }; + fontKitCache.set(key, payload); + return payload; + } catch (err) { + fontKitCache.set(key, null); + return null; + } +} + +function makeFontCacheKey(face, fontStyle, fontWeight) { + const family = (face || "Arial").trim(); + const style = (fontStyle || "normal").toLowerCase(); + const weight = (fontWeight || "normal").toLowerCase(); + return `${family}::${style}::${weight}`; +} + +function registerCanvasFontVariant( + fontPath, + face, + fontStyle, + fontWeight, + cacheKey +) { + if (registeredFontVariants.has(cacheKey)) return; + try { + Canvas.registerFont(fontPath, { + family: face, + style: fontStyle || "normal", + weight: fontWeight || "normal", + }); + registeredFontVariants.add(cacheKey); + } catch (err) { + // ignore registration failure; measurement will fall back to Skia default + } +} + +function findFontPath(face, fontStyle, fontWeight) { + const family = (face || "").trim(); + if (family.length === 0) return null; + const key = makeFontCacheKey(family, fontStyle, fontWeight); + if (fontPathCache.has(key)) return fontPathCache.get(key); + const styleParts = []; + if ((fontWeight || "").toLowerCase() === "bold") styleParts.push("Bold"); + if ((fontStyle || "").toLowerCase() === "italic") styleParts.push("Italic"); + const styleQuery = + styleParts.length > 0 ? `:style=${styleParts.join(" ")}` : ""; + const query = `${family}${styleQuery}`; + const result = spawnSync("fc-match", ["-f", "%{file}", query], { + encoding: "utf8", + }); + if (result.status === 0) { + const output = String(result.stdout || "").trim(); + if (output.length > 0) { + fontPathCache.set(key, output); + return output; + } + } + fontPathCache.set(key, null); + return null; +} + +function selectCollectionFont(collection, fontStyle, fontWeight) { + const fonts = collection.fonts || []; + if (fonts.length === 0) return null; + const wantItalic = (fontStyle || "").toLowerCase() === "italic"; + const wantBold = (fontWeight || "").toLowerCase() === "bold"; + let best = fonts[0]; + let bestScore = scoreFontVariant(best, wantItalic, wantBold); + for (let i = 1; i < fonts.length; i++) { + const candidate = fonts[i]; + const score = scoreFontVariant(candidate, wantItalic, wantBold); + if (score > bestScore) { + best = candidate; + bestScore = score; + } + } + return best; +} + +function scoreFontVariant(font, wantItalic, wantBold) { + if (!font) return -1; + const name = String(font.fullName || font.postscriptName || "").toLowerCase(); + const isItalic = /italic|oblique/.test(name); + const isBold = /bold|black|heavy|semibold|extrabold/.test(name); + let score = 0; + if (isItalic === wantItalic) score += 1; + if (isBold === wantBold) score += 1; + return score; +} + +// Empirical width scaling to better match PowerPoint/LibreOffice line breaks. +// A tiny global shrink (about -1.5%) nudges borderline words to wrap the same +// way Office does, with per-script tweaks for cases where our measurer +// systematically under- or over-estimates glyph widths. We intentionally avoid +// per-font calibration so this helper generalizes beyond the regression deck. +function getWidthScaleForParagraph(paragraph) { + if (!paragraph || typeof paragraph.text !== "string") return 1; + const text = paragraph.text; + // Thai script: our measurer tends to slightly over-estimate, which can cause + // extra wraps. Give it a bit more room horizontally. + if (/[ก-๛]/u.test(text)) { + return 1.2; + } + + // Arabic: we usually underestimate, so shrink available width a bit more to + // encourage earlier breaks. + if (/[\u0600-\u06FF]/u.test(text)) { + return 0.97; + } + + // Base shrink for most Latin and other scripts. + return 0.985; +} + +// Build options to pass directly to pptx.addText. We exclude measurement-only +// fields and fill sensible defaults (e.g., fontSize) so callers can spread +// the result into addText just like the image sizing helpers. +function buildPassthroughOptions(opts, fontSizePt, margins) { + const exclude = new Set([ + "text", + "lines", + "w", // will be set by calcTextBox + "h", // will be set by calcTextBox + // fontFace/style/weight are useful for addText; allow passthrough + "leading", + "padding", + ]); + const out = {}; + for (const k of Object.keys(opts)) { + if (!exclude.has(k)) out[k] = opts[k]; + } + if (out.fontSize == null) out.fontSize = fontSizePt; + if (opts.margin != null) out.margin = margins; + return out; +} + +function getTextMeasurer() { + // Skia-canvas only for accurate shaping and Fontconfig-based resolution. + // Throws if skia-canvas is not available. + const canvas = new Canvas(2, 2); + const ctx = canvas.getContext("2d"); + const PX_PER_IN = 96; + return (text, fontSizePt, fontFace, fontStyle, fontWeight) => { + const px = (fontSizePt / 72) * PX_PER_IN; + const style = fontStyle || "normal"; + const weight = fontWeight || "normal"; + // CSS shorthand: style weight size family + ctx.font = `${style} ${weight} ${px}px ${fontFace || "Arial"}`; + const metrics = ctx.measureText(text); + return (metrics.width || 0) / PX_PER_IN; + }; +} + +function normalizeMargins(m) { + const toInches = (value) => + typeof value === "number" && Number.isFinite(value) ? value / 72 : 0; + if (m && typeof m === "object") { + if (Number.isFinite(m.left) || Number.isFinite(m.top)) { + return { + left: toInches(m.left), + right: toInches(m.right), + top: toInches(m.top), + bottom: toInches(m.bottom), + }; + } + } + const all = toInches(m); + return { left: all, right: all, top: all, bottom: all }; +} + +function normalizeText(textOrRuns) { + if (Array.isArray(textOrRuns)) { + return textOrRuns + .map((item) => { + if (typeof item === "string") return item; + if (item && typeof item.text === "string") return item.text; + return ""; + }) + .join(""); + } + return typeof textOrRuns === "string" ? textOrRuns : String(textOrRuns ?? ""); +} + +function toNumber(v, fallback) { + const n = typeof v === "string" ? parseFloat(v) : v; + return Number.isFinite(n) ? n : fallback; +} + +module.exports = { + calcTextBoxHeightSimple, + calcTextBox, + autoFontSize, +}; diff --git a/agents/.agents/skills/slides/assets/pptxgenjs_helpers/util.js b/agents/.agents/skills/slides/assets/pptxgenjs_helpers/util.js new file mode 100644 index 0000000..8a6e1fc --- /dev/null +++ b/agents/.agents/skills/slides/assets/pptxgenjs_helpers/util.js @@ -0,0 +1,24 @@ +// Copyright (c) OpenAI. All rights reserved. +"use strict"; + +// Safe outer shadow helper (avoid inner/outer mix and XML pitfalls) +function safeOuterShadow( + color = "000000", + opacity = 0.25, + angle = 45, + blur = 3, + offset = 2 +) { + return { + type: "outer", + color, + opacity, + angle, + blur, + offset, + }; +} + +module.exports = { + safeOuterShadow, +}; diff --git a/agents/.agents/skills/slides/assets/slides-small.svg b/agents/.agents/skills/slides/assets/slides-small.svg new file mode 100644 index 0000000..8afd52d --- /dev/null +++ b/agents/.agents/skills/slides/assets/slides-small.svg @@ -0,0 +1,3 @@ + + + diff --git a/agents/.agents/skills/slides/assets/slides.png b/agents/.agents/skills/slides/assets/slides.png new file mode 100644 index 0000000..c05e309 Binary files /dev/null and b/agents/.agents/skills/slides/assets/slides.png differ diff --git a/agents/.agents/skills/slides/references/pptxgenjs-helpers.md b/agents/.agents/skills/slides/references/pptxgenjs-helpers.md new file mode 100644 index 0000000..1a564de --- /dev/null +++ b/agents/.agents/skills/slides/references/pptxgenjs-helpers.md @@ -0,0 +1,61 @@ +# PptxGenJS Helpers + +## When To Read This + +Read this file when you need helper API details, command examples for the bundled Python scripts, or dependency notes for a slide-generation task. + +## Helper Modules + +- `autoFontSize(textOrRuns, fontFace, opts)`: Pick a font size that fits a fixed box. +- `calcTextBox(fontSizePt, opts)`: Estimate text-box geometry from font size and content. +- `calcTextBoxHeightSimple(fontSizePt, numLines, leading?, padding?)`: Quick text height estimate. +- `imageSizingCrop(pathOrData, x, y, w, h)`: Center-crop an image into a target box. +- `imageSizingContain(pathOrData, x, y, w, h)`: Fit an image fully inside a target box. +- `svgToDataUri(svgString)`: Convert an SVG string into an embeddable data URI. +- `latexToSvgDataUri(texString)`: Render LaTeX to SVG for crisp equations. +- `getImageDimensions(pathOrData)`: Read image width, height, type, and aspect ratio. +- `safeOuterShadow(...)`: Build a safe outer-shadow config for PowerPoint output. +- `codeToRuns(source, language)`: Convert source code into rich-text runs for `addText`. +- `warnIfSlideHasOverlaps(slide, pptx)`: Emit overlap warnings for diagnostics. +- `warnIfSlideElementsOutOfBounds(slide, pptx)`: Emit boundary warnings for diagnostics. +- `alignSlideElements(slide, indices, alignment)`: Align selected elements precisely. +- `distributeSlideElements(slide, indices, direction)`: Evenly space selected elements. + +## Dependency Notes + +JavaScript helpers expect these packages when you use the corresponding features: + +- Core authoring: `pptxgenjs` +- Text measurement: `skia-canvas`, `linebreak`, `fontkit` +- Syntax highlighting: `prismjs` +- LaTeX rendering: `mathjax-full` + +Python scripts expect these packages: + +- `Pillow` +- `pdf2image` +- `python-pptx` +- `numpy` + +System tools used by the Python scripts: + +- `soffice` / LibreOffice for PPTX to PDF conversion +- Poppler tools for PDF size/raster support used by `pdf2image` +- `fc-list` for font inspection +- Optional rasterization tools for `ensure_raster_image.py`: Inkscape, ImageMagick, Ghostscript, `heif-convert`, `JxrDecApp` + +## Script Notes + +- `render_slides.py`: Convert a deck to PNGs. Good for visual review and diffing. +- `slides_test.py`: Add a gray border outside the original canvas, render, and check whether any content leaks into the border. +- `create_montage.py`: Combine multiple rendered slide images into a single overview image. +- `detect_font.py`: Distinguish between fonts that are missing entirely and fonts that are installed but substituted during rendering. +- `ensure_raster_image.py`: Produce a PNG from common vector or unusual raster formats so you can inspect or place the asset easily. + +## Practical Rules + +- Default to `LAYOUT_WIDE` unless the source material says otherwise. +- Set font families explicitly before measuring text. +- Use `valign: "top"` for content boxes that may grow. +- Prefer native PowerPoint charts over rendered images when the chart is simple and likely to be edited later. +- Use SVG instead of PNG for diagrams whenever possible. diff --git a/agents/.agents/skills/slides/scripts/__pycache__/ensure_raster_image.cpython-312.pyc b/agents/.agents/skills/slides/scripts/__pycache__/ensure_raster_image.cpython-312.pyc new file mode 100644 index 0000000..3d74d45 Binary files /dev/null and b/agents/.agents/skills/slides/scripts/__pycache__/ensure_raster_image.cpython-312.pyc differ diff --git a/agents/.agents/skills/slides/scripts/create_montage.py b/agents/.agents/skills/slides/scripts/create_montage.py new file mode 100644 index 0000000..8c385c7 --- /dev/null +++ b/agents/.agents/skills/slides/scripts/create_montage.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +# Copyright (c) OpenAI. All rights reserved. +import argparse +import re +import sys +import tempfile +from math import ceil +from os import listdir +from os.path import basename, expanduser, isfile, join, splitext +from pathlib import Path +from typing import Literal + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from ensure_raster_image import SUPPORTED_EXTS, ensure_raster_image # type: ignore +from PIL import Image, ImageDraw, ImageFont, ImageOps + + +def _make_placeholder(w: int, h: int) -> Image.Image: + """Create a visible placeholder tile with a light gray fill and a red X cross.""" + ph = Image.new("RGBA", (w, h), (220, 220, 220, 255)) + ph_draw = ImageDraw.Draw(ph) + line_color = (180, 0, 0, 255) + ph_draw.line([(0, 0), (ph.width - 1, ph.height - 1)], fill=line_color, width=3) + ph_draw.line([(ph.width - 1, 0), (0, ph.height - 1)], fill=line_color, width=3) + return ph + + +def _load_images_with_placeholders( + input_files: list[str], retain_converted_files: bool, fail_on_image_error: bool = False +) -> tuple[list[str], list[Image.Image | None]]: + labels = [basename(p) for p in input_files] + images: list[Image.Image | None] = [] + if retain_converted_files: + for p in input_files: + try: + images.append(Image.open(ensure_raster_image(p))) + except Exception as e: + if fail_on_image_error: + raise + print(f'Warning: Failed to convert or load image "{p}": {e}') + images.append(None) + else: + with tempfile.TemporaryDirectory(prefix="montage_convert_") as tmp_conv: + for p in input_files: + try: + images.append(Image.open(ensure_raster_image(p, tmp_conv))) + except Exception as e: + if fail_on_image_error: + raise + print(f'Warning: Failed to convert or load image "{p}": {e}') + images.append(None) + return labels, images + + +def _natural_key(s: str) -> list: + """Key function for natural sorting (e.g., Slide2 before Slide10).""" + return [int(part) if part.isdigit() else part for part in re.split(r"(\d+)", s)] + + +def create_montage( + input_files: list[str], + output_file: str, + num_col: int, + cell_w: int, + cell_h: int, + gap: int, + label_mode: Literal["number", "filename", "none"], + retain_converted_files: bool = False, + fail_on_image_error: bool = False, +) -> None: + """Build a montage with a fixed number of columns. + + Each cell has size `cell_w` x `cell_h`. Every input image is resized isotropically to fit inside + the cell. `gap` controls spacing around and between cells (outer margin equals gap). + Label behavior is controlled by `label_mode` which can be one of: + - "none": no labels are drawn + - "number": draw a 1-based index beneath each image + - "filename": draw the filename (no directory) beneath each image + """ + + if num_col <= 0: + raise ValueError("num_col must be positive") + if cell_w <= 0 or cell_h <= 0: + raise ValueError("cell_w and cell_h must be positive") + + labels, images = _load_images_with_placeholders( + input_files=input_files, + retain_converted_files=retain_converted_files, + fail_on_image_error=fail_on_image_error, + ) + + num_images = len(images) + num_valid = sum(1 for im in images if im is not None) + if num_valid == 0: + raise ValueError("No valid images to render.") + if num_valid < num_images: + cell_size = round(min(cell_w, cell_h) * 0.6) + placeholder = _make_placeholder(cell_size, cell_size) + else: + placeholder = None + cols = num_col + rows = ceil(num_images / cols) + + temp_canvas = Image.new("RGB", (10, 10), (255, 255, 255)) + temp_draw = ImageDraw.Draw(temp_canvas) + + # Choose a readable default font size relative to cell height + font: ImageFont.FreeTypeFont | ImageFont.ImageFont + try: + # Attempt to use a common system font for clarity; fallback to default + font_size = max(12, min(36, int(cell_h * 0.12))) + font = ImageFont.truetype("arial.ttf", font_size) + except Exception: + font = ImageFont.load_default() + # Adjust default font effect size estimate + font_size = 12 + + draw_labels = label_mode != "none" + label_height = 0 + if draw_labels: + # Height is approximately constant across strings for a given font + # Use 'Ag' to approximate ascent ('A') and descender ('g') for filename text + sample_text = "1" if label_mode == "number" else "Ag" + lbbox = temp_draw.textbbox((0, 0), sample_text, font=font) + label_height = ceil(lbbox[3] - lbbox[1]) + 6 + + row_h = cell_h + label_height + + canvas_w = cols * cell_w + (cols + 1) * gap + canvas_h = rows * row_h + (rows + 1) * gap + # Light grey canvas background as in typical slide sorter view + canvas = Image.new("RGB", (canvas_w, canvas_h), (242, 242, 242)) + draw = ImageDraw.Draw(canvas) + + for idx, img in enumerate(images): + col = idx % cols + row = idx // cols + + # Top-left corner of the cell including outer margin and gaps + x0 = gap + col * (cell_w + gap) + y0 = gap + row * (row_h + gap) + + # Fit the image within the cell while preserving aspect ratio + if label_mode == "number": + label = str(idx + 1) + elif label_mode == "filename": + label = labels[idx] + else: + label = "" + + if draw_labels: + bbox = draw.textbbox((0, 0), label, font=font) + text_w = bbox[2] - bbox[0] + else: + text_w = 0 + + if img: + resized = ImageOps.contain( + img.convert("RGBA"), + (cell_w, cell_h), + method=Image.Resampling.LANCZOS, + ) + else: + print(f"Warning: Using placeholder for invalid image at row={row + 1}, col={col + 1}") + assert placeholder is not None + resized = placeholder + + paste_x = x0 + (cell_w - resized.width) // 2 + paste_y = y0 + (cell_h - resized.height) // 2 + canvas.paste( + resized, + (paste_x, paste_y), + mask=resized.split()[3] if resized.mode == "RGBA" else None, + ) + + border_color = (160, 160, 160) + bw = 1 + draw.rectangle( + [ + paste_x - bw, + paste_y - bw, + paste_x + resized.width, + paste_y + resized.height, + ], + outline=border_color, + width=bw, + ) + + if draw_labels: + tx = x0 + round((cell_w - text_w) / 2) + ty = y0 + cell_h + 3 + draw.text((tx, ty), label, font=font, fill=(0, 0, 0)) + + canvas.save(output_file) + print(f"Montage saved to {output_file}") + + +def main() -> None: + parser = argparse.ArgumentParser( + description=( + "Create a montage with a fixed number of columns. " + "Each image is resized isotropically to fit inside a cell of size (cell_width x cell_height)." + ) + ) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--input_files", nargs="+", help="List of input image file paths") + group.add_argument("--input_dir", help="Directory containing input images") + parser.add_argument( + "--output_file", + required=True, + help=( + "Path to save the output montage image. The format is inferred from the file extension." + ), + ) + parser.add_argument( + "--num_col", + type=int, + default=5, + help="Number of images per row (default: 5)", + ) + parser.add_argument( + "--cell_width", + type=int, + default=400, + help="Container width in pixels for each image (default: 400)", + ) + parser.add_argument( + "--cell_height", + type=int, + default=225, + help="Container height in pixels for each image (default: 225)", + ) + parser.add_argument( + "--gap", + type=int, + default=16, + help="Gap in pixels between images and canvas margins (default: 16)", + ) + parser.add_argument( + "--label_mode", + choices=["number", "filename", "none"], + default="number", + help=( + "Label mode: 'number' to draw 1-based indices (default), 'filename' to use the " + "image's filename (no directory), or 'none' for no labels" + ), + ) + parser.add_argument( + "--retain_converted_files", + action="store_true", + default=False, + help=( + "If set, write converted images (e.g., SVG->PNG, WDP->PNG) next to the original files " + "instead of a temporary directory." + ), + ) + parser.add_argument( + "--fail_on_image_error", + action="store_true", + default=False, + help=( + "If set, fail immediately when any image conversion/loading fails (no placeholders). " + "By default, failures are tolerated and placeholders are used." + ), + ) + args = parser.parse_args() + + output_path = expanduser(args.output_file) + if args.input_files: + input_files = [expanduser(p) for p in args.input_files] + else: + input_dir = expanduser(args.input_dir) + names = sorted(listdir(input_dir), key=_natural_key) + dir_entries = [join(input_dir, f) for f in names] + input_files = [ + p for p in dir_entries if isfile(p) and splitext(p)[1].lower() in SUPPORTED_EXTS + ] + if not input_files: + raise ValueError( + "No image files with supported extensions were found in the specified directory." + ) + + create_montage( + input_files=input_files, + output_file=output_path, + num_col=args.num_col, + cell_w=args.cell_width, + cell_h=args.cell_height, + gap=args.gap, + label_mode=args.label_mode, + retain_converted_files=args.retain_converted_files, + fail_on_image_error=args.fail_on_image_error, + ) + + +if __name__ == "__main__": + main() diff --git a/agents/.agents/skills/slides/scripts/detect_font.py b/agents/.agents/skills/slides/scripts/detect_font.py new file mode 100644 index 0000000..a5fc393 --- /dev/null +++ b/agents/.agents/skills/slides/scripts/detect_font.py @@ -0,0 +1,873 @@ +#!/usr/bin/env python3 +"""Copyright (c) OpenAI. All rights reserved. + +Detect missing fonts for PPTX rendering by converting to ODP and inspecting the resolved font +families per slide. + +Overview +======== +PowerPoint files (PPTX) declare requested font families in runs and theme defaults, but the actual +font used at render time depends on the renderer (LibreOffice in our pipeline), platform +availability, and style inheritance. To make detection stable and renderer-accurate, this module: + +- Extracts requested families from PPTX per slide (reads a:r/a:rPr plus document defaults, grouped + by script: latin/ea/cs/sym). Analysis is done per run: we infer the script from run text and + select the matching a:rPr child (e.g., latin/ea/cs). Fonts declared for other scripts in the same + run are not counted as used. +- Converts the PPTX to ODP using headless LibreOffice and parses ODP content.xml and styles.xml to + discover which families LibreOffice actually resolved for each slide (including master pages and + defaults). +- Classifies each requested family on each slide into two buckets: + - font_missing: the family is not installed on the system (per fontconfig synonyms), so resolution + cannot possibly match the request. + - font_substituted: the family is installed but was resolved to another family in ODP for the + slide (theme/style inheritance or glyph coverage), i.e., installed but substituted. + +Key Design +----------------------- +1) Inspect the renderer's decision, not only the author's request. Reading PPTX alone tells you what + was requested, not what LibreOffice will choose after applying styles and availability checks. + Converting to ODP and reading the resolved fo:font-family/style:font-name* values yields a + faithful view of what the renderer actually used for each slide. + +2) Robust style resolution across ODP structures. Fonts can be specified under multiple layers. We + parse office:automatic-styles (both content.xml and styles.xml), office:styles and + style:default-style, draw:master-page references used by slides, nested style:text-properties + under paragraph-properties, and parent style chains (style:parent-style-name). A text-based + fallback parser supplements XML namespace lookups when vendor XML variations occur. + +3) Scalable aliasing via fontconfig synonyms, not ad hoc maps. PostScript names, full names, and + family names often differ. We build a synonym map from fc-list that unifies those identifiers. We + deliberately do NOT use fc-match -s fallback chains for matching, because fallback families + (e.g., DejaVu Sans) would mask missing/substitution cases and produce false passes. + +4) Clear classification: missing vs substituted. + - Missing: no synonym of the requested base family is present in the installed font set (per + fontconfig). These require installation. + - Substituted: the family is installed, but ODP does not reference it on the slide (LibreOffice + chose another family), which is useful for diagnosing style/theme issues or glyph-coverage + driven substitutions. + +Not Chosen (and why) +-------------------- +- PDF inspection (e.g., pdffonts): PostScript names don't reliably map back to authoring families; + PDFs often reflect subsetted fonts and fallback choices, making robust detection noisy. +- Ad hoc alias tables: unscalable for large-scale fonts and platform variants; the fontconfig + synonym corpus covers family/fullname/PostScript consistently. +- Treating fallback families as matches (fc-match -s): causes false negatives by accepting generic + fallbacks when the requested family is missing. +- Hardcoding checks in the renderer: we keep detection separate from render_slides to avoid + coupling and allow standalone checking. + +CLI +--- +- JSON output exposes two categories by default (and text mode mirrors them): font_missing_overall/ + font_missing_by_slide and font_substituted_overall/font_substituted_by_slide. +- Flags include_missing/include_substituted control which categories are emitted (default True/True). +""" + +import argparse +import json +import os +import re +import shutil +import subprocess +import tempfile +import xml.etree.ElementTree as ET +from functools import lru_cache +from os.path import abspath, basename, exists, expanduser, join, splitext +from zipfile import ZipFile + +STYLE_TOKENS = [ + "regular", + "condensed", + "compressed", + "narrow", + "italic", + "oblique", + "semibold", + "demibold", + "bold", + "black", + "extra light", + "ultra light", + "extralight", + "ultralight", + "light", + "thin", + "medium", +] + + +def normalize_font_family_name(name: str) -> str: + s = name.casefold() + s = re.sub(r"\([^)]*\)", " ", s) + s = re.sub(r"[\s\-\_\.,/\'\"]+", " ", s) + return s.strip() + + +def _or_dummy(node: ET.Element | None) -> ET.Element: + """Return the element if not None, otherwise a harmless dummy element. + + Avoids deprecated truthiness checks on Element instances (`elem or dummy`). + """ + return node if node is not None else ET.Element("dummy") + + +@lru_cache(maxsize=1) +def _build_fc_synonym_map() -> dict[str, set[str]]: + """Build synonym map from fontconfig; raise on failures; memoized (size=1).""" + proc = subprocess.run( + [ + "fc-list", + "--format", + "%{family}\t%{fullname}\t%{postscriptname}\n", + ], + capture_output=True, + text=True, + check=True, + ) + syn: dict[str, set[str]] = {} + for line in (proc.stdout or "").splitlines(): + parts = line.split("\t") + if len(parts) != 3: + continue + fam_field, full_field, ps_field = parts + names: set[str] = set() + for field in (fam_field, full_field, ps_field): + for item in field.split(","): + norm = normalize_font_family_name(item) + if norm: + names.add(norm) + names.add(norm.replace(" ", "")) + for name in list(names): + bucket = syn.setdefault(name, set()) + bucket.update(names) + return syn + + +def _expand_via_fontconfig(family_base_norm: str) -> set[str]: + # Accept only true aliases/synonyms (family/fullname/PostScript) — not fallback replacements + acceptable: set[str] = {family_base_norm, family_base_norm.replace(" ", "")} + syn = _build_fc_synonym_map() + if family_base_norm in syn: + acceptable.update(syn[family_base_norm]) + no_space = family_base_norm.replace(" ", "") + if no_space in syn: + acceptable.update(syn[no_space]) + return acceptable + + +def parse_font_family_base_and_styles(name_norm: str) -> tuple[str, set[str]]: + tokens = name_norm.split() + required: set[str] = set() + weight_code_map = { + "25": "ultra light", + "35": "thin", + "45": "light", + "55": "regular", + "65": "medium", + "75": "bold", + "85": "black", + "95": "black", + } + if tokens and tokens[0].isdigit() and tokens[0] in weight_code_map: + required.add(weight_code_map[tokens[0]]) + tokens = tokens[1:] + if len(tokens) == 1: + t = tokens[0] + fused_map = [ + ("extralight", "extra light"), + ("ultralight", "ultra light"), + ("semibold", "semibold"), + ("demibold", "semibold"), + ("condensed", "condensed"), + ("compressed", "condensed"), + ("narrow", "condensed"), + ("italic", "italic"), + ("oblique", "italic"), + ("bold", "bold"), + ("black", "black"), + ("light", "light"), + ("thin", "thin"), + ("medium", "medium"), + ("regular", "regular"), + ] + changed = True + while changed: + changed = False + for suf, tok in fused_map: + if t.endswith(suf) and len(t) > len(suf): + t = t[: -len(suf)] + required.add(tok) + changed = True + break + return (t.strip(), required) + + while tokens: + tail = " ".join(tokens[-2:]) if len(tokens) >= 2 else tokens[-1] + matched = None + for style in STYLE_TOKENS: + if tail == style: + matched = style + break + if matched is None and tokens[-1] in STYLE_TOKENS: + matched = tokens[-1] + if matched is None: + break + if matched in ("compressed", "narrow"): + required.add("condensed") + elif matched == "roman": + required.add("regular") + elif matched == "demibold": + required.add("semibold") + else: + required.add(matched) + if " " in matched: + tokens = tokens[:-2] + else: + tokens = tokens[:-1] + return (" ".join(tokens).strip(), required) + + +def _split_odf_family_list(value: str) -> list[str]: + out: list[str] = [] + for part in value.split(","): + p = part.strip().strip("\"' ") + if p: + out.append(normalize_font_family_name(p)) + return out + + +def extract_used_fonts_from_pptx(pptx_path: str) -> dict[int, set[str]]: + by_slide: dict[int, set[str]] = {} + with ZipFile(pptx_path, "r") as zf: + for name in zf.namelist(): + if not (name.startswith("ppt/slides/slide") and name.endswith(".xml")): + continue + base = os.path.basename(name) + m = re.search(r"(?i)slide(\d+)\.xml$", base) + slide_num = int(m.group(1)) if m else None + with zf.open(name) as f: + tree = ET.parse(f) + root = tree.getroot() + ns = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"} + defaults = _collect_default_font_faces(root) + for r in root.findall(".//a:r", ns): + parts: list[str] = [] + for t in r.findall("a:t", ns): + if t.text: + parts.append(t.text) + text = "".join(parts) + if not text: + continue + script = _detect_script_tag(text) + rpr = r.find("a:rPr", ns) + face_norm: str | None = None + if rpr is not None: + child = rpr.find(f"a:{script}", ns) + if child is not None: + face = child.get("typeface") + if face and not face.startswith("+"): + face_norm = normalize_font_family_name(face) + bucket = by_slide.setdefault(slide_num or -1, set()) + if face_norm is None: + for f in defaults.get(script, set()): + bucket.add(f) + else: + bucket.add(face_norm) + return {k: v for k, v in by_slide.items() if k is not None and k != -1} + + +def _detect_script_tag(text: str) -> str: + for ch in text: + cp = ord(ch) + if ( + 0x4E00 <= cp <= 0x9FFF + or 0x3400 <= cp <= 0x4DBF + or 0xF900 <= cp <= 0xFAFF + or 0x3040 <= cp <= 0x309F + or 0x30A0 <= cp <= 0x30FF + or 0x31F0 <= cp <= 0x31FF + or 0xAC00 <= cp <= 0xD7AF + or 0x3100 <= cp <= 0x312F + or 0x3000 <= cp <= 0x303F + ): + return "ea" + for ch in text: + cp = ord(ch) + if ( + 0x0590 <= cp <= 0x05FF + or 0x0600 <= cp <= 0x06FF + or 0x0700 <= cp <= 0x077F + or 0x0780 <= cp <= 0x07BF + or 0x0900 <= cp <= 0x0D7F + or 0x0E00 <= cp <= 0x0E7F + or 0x0E80 <= cp <= 0x0EFF + or 0xFB50 <= cp <= 0xFDFF + or 0xFE70 <= cp <= 0xFEFF + ): + return "cs" + for ch in text: + cp = ord(ch) + if ( + (0x0041 <= cp <= 0x005A) + or (0x0061 <= cp <= 0x007A) + or (0x0030 <= cp <= 0x0039) + or (0x00C0 <= cp <= 0x024F) + or (0x1E00 <= cp <= 0x1EFF) + ): + return "latin" + return "latin" + + +def _collect_default_font_faces(root: ET.Element) -> dict[str, set[str]]: + ns = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"} + defaults: dict[str, set[str]] = {"latin": set(), "ea": set(), "cs": set(), "sym": set()} + for defrpr in root.findall(".//a:defRPr", ns): + for tag in ("latin", "ea", "cs", "sym"): + child = defrpr.find(f"a:{tag}", ns) + if child is not None: + face = child.get("typeface") + if face and not face.startswith("+"): + defaults[tag].add(normalize_font_family_name(face)) + return defaults + + +def _run_soffice_convert(cmd: list[str]) -> None: + subprocess.run( + cmd, + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=os.environ.copy(), + ) + + +def _export_to_odp(pptx_path: str, user_profile: str, out_dir: str, stem: str) -> str: + bin_path = shutil.which("soffice") or shutil.which("libreoffice") or "/usr/bin/libreoffice" + cmd_odp = [ + bin_path, + "-env:UserInstallation=file://" + user_profile, + "--invisible", + "--headless", + "--norestore", + "--convert-to", + "odp", + "--outdir", + out_dir, + pptx_path, + ] + _run_soffice_convert(cmd_odp) + odp_path = join(out_dir, f"{stem}.odp") + return odp_path if exists(odp_path) else "" + + +def _collect_face_map(root: ET.Element, ns: dict[str, str]) -> dict[str, str]: + face_map: dict[str, str] = {} + decls = root.find("office:font-face-decls", ns) + if decls is None: + return face_map + for ff in decls.findall("style:font-face", ns): + name_attr = ff.get("{urn:oasis:names:tc:opendocument:xmlns:style:1.0}name") or ff.get( + "style:name" + ) + fam_attr = ff.get("{urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0}font-family") + if not name_attr or not fam_attr: + continue + face_map[normalize_font_family_name(name_attr)] = normalize_font_family_name(fam_attr) + return face_map + + +def _families_from_text_properties( + tp: ET.Element, ns: dict[str, str], face_map: dict[str, str] +) -> set[str]: + fams: set[str] = set() + # Inspect current node for direct font-family + fam_attr = tp.get("{urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0}font-family") + if fam_attr: + fams.update(_split_odf_family_list(fam_attr)) + # Inspect font-name aliases on current node + for key in ( + "{urn:oasis:names:tc:opendocument:xmlns:style:1.0}font-name", + "style:font-name", + "style:font-name-asian", + "style:font-name-complex", + ): + val = tp.get(key) + if val: + norm_val = normalize_font_family_name(val) + mapped = face_map.get(norm_val) + if mapped: + fams.add(normalize_font_family_name(mapped)) + else: + fams.add(norm_val) + # Some styles nest text-properties under paragraph-properties or default-style blocks + if not fams: + nested = None + # paragraph-properties/text-properties + pp = tp.find("style:paragraph-properties", ns) + if pp is not None: + nested = pp.find("style:text-properties", ns) + if nested is None: + # When tp is actually the style:style node, try finding child text-properties directly + nested = tp.find("style:text-properties", ns) + if nested is not None and nested is not tp: + fams.update(_families_from_text_properties(nested, ns, face_map)) + return fams + + +def _extract_styles_from_container( + container: ET.Element | None, ns: dict[str, str], face_map: dict[str, str] +) -> tuple[dict[str, set[str]], set[str]]: + styles: dict[str, set[str]] = {} + defaults: set[str] = set() + if container is None: + return styles, defaults + for st in container.findall("style:style", ns): + name = st.get("{urn:oasis:names:tc:opendocument:xmlns:style:1.0}name") or st.get( + "style:name" + ) + if not name: + continue + fams = _families_from_text_properties( + _or_dummy(st.find("style:text-properties", ns)), ns, face_map + ) + if fams: + styles[name] = fams + for ds in container.findall("style:default-style", ns): + defaults.update( + _families_from_text_properties( + _or_dummy(ds.find("style:text-properties", ns)), ns, face_map + ) + ) + return styles, defaults + + +def _build_style_map( + content: ET.Element, + styles_root: ET.Element | None, + ns: dict[str, str], + face_map: dict[str, str], +) -> tuple[dict[str, set[str]], set[str]]: + style_map: dict[str, set[str]] = {} + default_fams: set[str] = set() + auto_styles = content.find("office:automatic-styles", ns) + styles_part, defaults_part = _extract_styles_from_container(auto_styles, ns, face_map) + style_map.update(styles_part) + default_fams.update(defaults_part) + if styles_root is not None: + # Also parse automatic-styles within styles.xml (document-styles) + styles_auto = styles_root.find("office:automatic-styles", ns) + styles_part, defaults_part = _extract_styles_from_container(styles_auto, ns, face_map) + for k, v in styles_part.items(): + if k not in style_map: + style_map[k] = v + default_fams.update(defaults_part) + common_styles = styles_root.find("office:styles", ns) + styles_part, defaults_part = _extract_styles_from_container(common_styles, ns, face_map) + for k, v in styles_part.items(): + if k not in style_map: + style_map[k] = v + default_fams.update(defaults_part) + # top-level default-style under styles_root + for ds in styles_root.findall("style:default-style", ns): + default_fams.update( + _families_from_text_properties( + _or_dummy(ds.find("style:text-properties", ns)), ns, face_map + ) + ) + # Fallback: include any remaining style:style definitions anywhere in styles.xml + for st in styles_root.findall(".//style:style", ns): + name = st.get("{urn:oasis:names:tc:opendocument:xmlns:style:1.0}name") or st.get( + "style:name" + ) + if not name or name in style_map: + continue + fams = _families_from_text_properties( + _or_dummy(st.find("style:text-properties", ns)), ns, face_map + ) + if fams: + style_map[name] = fams + # also check top-level default-style in content root + for ds in content.findall("style:default-style", ns): + default_fams.update( + _families_from_text_properties( + _or_dummy(ds.find("style:text-properties", ns)), ns, face_map + ) + ) + # Fallback: include any remaining style:style definitions anywhere in content.xml + for st in content.findall(".//style:style", ns): + name = st.get("{urn:oasis:names:tc:opendocument:xmlns:style:1.0}name") or st.get( + "style:name" + ) + if not name or name in style_map: + continue + fams = _families_from_text_properties( + _or_dummy(st.find("style:text-properties", ns)), ns, face_map + ) + if fams: + style_map[name] = fams + return style_map, default_fams + + +def _lookup_style_families( + style_name: str, ns: dict[str, str], face_map: dict[str, str], roots: list[ET.Element | None] +) -> set[str]: + fams: set[str] = set() + if not style_name: + return fams + visited: set[str] = set() + + def _resolve(name: str) -> None: + if not name or name in visited: + return + visited.add(name) + for root in roots: + if root is None: + continue + node = root.find(f".//style:style[@style:name='{name}']", ns) + if node is None: + node = root.find(f".//style:style[@{{{ns['style']}}}name='{name}']", ns) + if node is None: + continue + fams.update( + _families_from_text_properties( + _or_dummy(node.find("style:text-properties", ns)), ns, face_map + ) + ) + # Follow parent style chain if present + parent = node.get( + "{urn:oasis:names:tc:opendocument:xmlns:style:1.0}parent-style-name" + ) or node.get("style:parent-style-name") + if parent: + _resolve(parent) + + _resolve(style_name) + return fams + + +def _collect_slide_families( + page: ET.Element, + ns: dict[str, str], + style_map: dict[str, set[str]], + face_map: dict[str, str], + roots: list[ET.Element | None], + text_style_map: dict[str, set[str]] | None = None, +) -> set[str]: + slide_fams: set[str] = set() + for el in page.iter(): + fam_attr = el.get( + "{urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0}font-family" + ) + if fam_attr: + slide_fams.update(_split_odf_family_list(fam_attr)) + for attr in ( + "{urn:oasis:names:tc:opendocument:xmlns:text:1.0}style-name", + "text:style-name", + "{urn:oasis:names:tc:opendocument:xmlns:drawing:1.0}text-style-name", + "draw:text-style-name", + "draw:style-name", + "presentation:style-name", + ): + style_name = el.get(attr) + if not style_name: + continue + resolved_fams: set[str] = set() + if style_name in style_map: + resolved_fams.update(style_map[style_name]) + if not resolved_fams: + # Fallback: resolve on the fly from XML if not present in prebuilt style_map + resolved_fams.update(_lookup_style_families(style_name, ns, face_map, roots)) + if not resolved_fams and text_style_map and style_name in text_style_map: + resolved_fams.update(text_style_map[style_name]) + if resolved_fams: + slide_fams.update(resolved_fams) + return slide_fams + + +def _build_style_map_text(xml_text: str) -> dict[str, set[str]]: + # Best-effort textual extraction for cases missed by XML namespace lookups + # Finds style:style name="X" blocks and extracts fo:font-family and style:font-name attributes + style_map: dict[str, set[str]] = {} + # Non-greedy match of a style:style block + for m in re.finditer( + r"]*?\bstyle:name=\"([^\"]+)\"[\s\S]*?(?:)", + xml_text, + flags=re.IGNORECASE, + ): + name = m.group(1).strip() + block = m.group(0) + fams: set[str] = set() + # fo:font-family may be a comma list + mff = re.search(r"fo:font-family=\"([^\"]+)\"", block, flags=re.IGNORECASE) + if mff: + for f in _split_odf_family_list(mff.group(1)): + fams.add(f) + # style:font-name may be a face alias; treat as family directly if present + mfn = re.search(r"style:font-name=\"([^\"]+)\"", block, flags=re.IGNORECASE) + if mfn: + fams.add(normalize_font_family_name(mfn.group(1))) + if fams: + style_map[name] = fams + return style_map + + +def _extract_slide_families_from_odp(odp_path: str) -> dict[int, set[str]]: + ns = { + "office": "urn:oasis:names:tc:opendocument:xmlns:office:1.0", + "style": "urn:oasis:names:tc:opendocument:xmlns:style:1.0", + "fo": "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0", + "draw": "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0", + "text": "urn:oasis:names:tc:opendocument:xmlns:text:1.0", + } + by_slide: dict[int, set[str]] = {} + with ZipFile(odp_path, "r") as zf: + content_bytes = zf.read("content.xml") + styles_bytes = zf.read("styles.xml") if "styles.xml" in zf.namelist() else None + content = ET.fromstring(content_bytes) + styles_root = ET.fromstring(styles_bytes) if styles_bytes is not None else None + styles_text = ( + styles_bytes.decode("utf-8", errors="ignore") if styles_bytes is not None else "" + ) + + face_map: dict[str, str] = {} + face_map.update(_collect_face_map(content, ns)) + if styles_root is not None: + face_map.update(_collect_face_map(styles_root, ns)) + + style_map, default_fams = _build_style_map(content, styles_root, ns, face_map) + # Augment style_map with textual parsing fallback (helps with tricky namespace emissions) + text_style_map: dict[str, set[str]] = {} + if styles_text: + text_style_map = _build_style_map_text(styles_text) + for k, v in text_style_map.items(): + if k not in style_map: + style_map[k] = v + + master_map: dict[str, set[str]] = _build_master_page_map(styles_root, ns, style_map) + + pres = content.find("office:body", ns) + if pres is not None: + pres = pres.find("office:presentation", ns) + if pres is None: + return {} + pages = pres.findall("draw:page", ns) + global_fams: set[str] = set() + for idx, page in enumerate(pages, start=1): + slide_fams = _collect_slide_families( + page, ns, style_map, face_map, [content, styles_root], text_style_map + ) + mp_name = page.get( + "{urn:oasis:names:tc:opendocument:xmlns:drawing:1.0}master-page-name" + ) or page.get("draw:master-page-name") + if mp_name and mp_name in master_map: + slide_fams.update(master_map[mp_name]) + # If theme placeholders like +mn lt are present, augment with defaults + if any(f.startswith("+") for f in slide_fams) and default_fams: + slide_fams.update(default_fams) + if not slide_fams and default_fams: + slide_fams.update(default_fams) + expanded: set[str] = set() + for f in slide_fams: + base, _ = parse_font_family_base_and_styles(f) + expanded.add(f) + expanded.add(base) + expanded.add(base.replace(" ", "")) + by_slide[idx] = expanded + global_fams.update(expanded) + # As a last resort, use global families + if global_fams: + for idx in list(by_slide.keys()): + if not by_slide[idx]: + by_slide[idx] = set(global_fams) + elif all(f.startswith("+") for f in by_slide[idx]): + by_slide[idx].update(global_fams) + return by_slide + + +def _build_master_page_map( + styles_root: ET.Element | None, ns: dict[str, str], style_map: dict[str, set[str]] +) -> dict[str, set[str]]: + master_map: dict[str, set[str]] = {} + if styles_root is None: + return master_map + master_styles = styles_root.find("office:master-styles", ns) + if master_styles is None: + return master_map + for mp in master_styles.findall("draw:master-page", ns): + mname = mp.get("{urn:oasis:names:tc:opendocument:xmlns:drawing:1.0}name") or mp.get( + "draw:name" + ) + if not mname: + continue + fams: set[str] = set() + for el in mp.iter(): + fam_attr = el.get( + "{urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0}font-family" + ) + if fam_attr: + fams.update(_split_odf_family_list(fam_attr)) + for attr in ( + "{urn:oasis:names:tc:opendocument:xmlns:text:1.0}style-name", + "text:style-name", + "{urn:oasis:names:tc:opendocument:xmlns:drawing:1.0}text-style-name", + "draw:text-style-name", + "draw:style-name", + "presentation:style-name", + ): + sname = el.get(attr) + if sname and sname in style_map: + fams.update(style_map[sname]) + if fams: + expanded: set[str] = set() + for f in fams: + base, _ = parse_font_family_base_and_styles(f) + expanded.add(f) + expanded.add(base) + expanded.add(base.replace(" ", "")) + master_map[mname] = expanded + return master_map + + +def detect_missing_fonts_odp(pptx_path: str) -> tuple[set[str], dict[int, list[str]]]: + pptx_path = abspath(pptx_path) + used = extract_used_fonts_from_pptx(pptx_path) + with tempfile.TemporaryDirectory(prefix="soffice_profile_") as prof: + with tempfile.TemporaryDirectory(prefix="soffice_convert_") as out: + stem = splitext(basename(pptx_path))[0] + odp_path = _export_to_odp(pptx_path, prof, out, stem) + if not odp_path: + return set(), {} + slide_fams = _extract_slide_families_from_odp(odp_path) + + missing_overall: set[str] = set() + missing_by_slide: dict[int, list[str]] = {} + syn_map = _build_fc_synonym_map() + for slide_num, req_fams in used.items(): + odp_fams = slide_fams.get(slide_num, set()) + slide_missing: list[str] = [] + for req in req_fams: + fam_base, _ = parse_font_family_base_and_styles(req) + # Accept fontconfig-resolved aliases and no-space variants for the requested base family + acceptable: set[str] = _expand_via_fontconfig(fam_base) + # Determine if any acceptable alias is actually installed on system + installed = any(alias in syn_map for alias in acceptable) + # Missing if not installed at all, or if installed but not resolved in ODP families + if (not installed) or ((req not in odp_fams) and not (acceptable & odp_fams)): + slide_missing.append(req) + missing_overall.add(req) + if slide_missing: + missing_by_slide[slide_num] = sorted(slide_missing) + return missing_overall, missing_by_slide + + +def main() -> None: + parser = argparse.ArgumentParser( + description=( + "Detect missing/substituted fonts for a PPTX by converting to ODP and inspecting resolved families." + ) + ) + parser.add_argument("pptx_path", help="Path to .pptx file") + parser.add_argument( + "--json", dest="output_json", action="store_true", default=False, help="Emit JSON output" + ) + parser.add_argument( + "--include-missing", + dest="include_missing", + action="store_true", + default=True, + help="Include missing category", + ) + parser.add_argument( + "--include-substituted", + dest="include_substituted", + action="store_true", + default=True, + help="Include substituted category", + ) + args = parser.parse_args() + + pptx_path = abspath(expanduser(args.pptx_path)) + used = extract_used_fonts_from_pptx(pptx_path) + # Only build ODP families if we need to report substitutions + slide_fams: dict[int, set[str]] = {} + odp_available = False + if args.include_substituted: + with tempfile.TemporaryDirectory(prefix="soffice_profile_") as prof: + with tempfile.TemporaryDirectory(prefix="soffice_convert_") as out: + stem = splitext(basename(pptx_path))[0] + odp_path = _export_to_odp(pptx_path, prof, out, stem) + if odp_path: + slide_fams = _extract_slide_families_from_odp(odp_path) + odp_available = True + + syn_map = _build_fc_synonym_map() + font_missing_by_slide: dict[int, list[str]] = {} + font_substituted_by_slide: dict[int, list[str]] = {} + for slide_num, req_fams in used.items(): + if args.include_substituted and odp_available: + odp_fams = slide_fams.get(slide_num, set()) + else: + odp_fams = set() + miss_missing: list[str] = [] + miss_sub: list[str] = [] + for req in req_fams: + fam_base, _ = parse_font_family_base_and_styles(req) + acceptable: set[str] = _expand_via_fontconfig(fam_base) + installed = any(alias in syn_map for alias in acceptable) + if args.include_missing and not installed: + miss_missing.append(req) + if ( + args.include_substituted + and odp_available + and installed + and (req not in odp_fams) + and not (acceptable & odp_fams) + ): + miss_sub.append(req) + if miss_missing: + font_missing_by_slide[slide_num] = sorted(miss_missing) + if miss_sub: + font_substituted_by_slide[slide_num] = sorted(miss_sub) + + font_missing_overall: set[str] = ( + set().union(*font_missing_by_slide.values()) if font_missing_by_slide else set() + ) + font_substituted_overall: set[str] = ( + set().union(*font_substituted_by_slide.values()) if font_substituted_by_slide else set() + ) + + if args.output_json: + payload: dict[str, object] = {} + if args.include_missing: + payload["font_missing_overall"] = sorted(font_missing_overall) + payload["font_missing_by_slide"] = {str(k): v for k, v in font_missing_by_slide.items()} + if args.include_substituted: + payload["font_substituted_overall"] = sorted(font_substituted_overall) + payload["font_substituted_by_slide"] = { + str(k): v for k, v in font_substituted_by_slide.items() + } + print(json.dumps(payload)) + else: + any_missing = args.include_missing and bool(font_missing_overall) + any_sub = args.include_substituted and bool(font_substituted_overall) + if any_missing or any_sub: + if any_missing: + print("Fonts missing (not installed):") + print(", ".join(sorted(font_missing_overall))) + for slide_num in sorted(font_missing_by_slide.keys()): + print(f"Slide {slide_num} missing: ", end="") + print(", ".join(font_missing_by_slide[slide_num])) + if any_sub: + print("Fonts substituted (installed but substituted during rendering):") + print(", ".join(sorted(font_substituted_overall))) + for slide_num in sorted(font_substituted_by_slide.keys()): + print(f"Slide {slide_num} substituted: ", end="") + print(", ".join(font_substituted_by_slide[slide_num])) + else: + print("No font issues detected.") + + +if __name__ == "__main__": + main() diff --git a/agents/.agents/skills/slides/scripts/ensure_raster_image.py b/agents/.agents/skills/slides/scripts/ensure_raster_image.py new file mode 100644 index 0000000..0ce3dbc --- /dev/null +++ b/agents/.agents/skills/slides/scripts/ensure_raster_image.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Copyright (c) OpenAI. All rights reserved. + +Ensures input images are rasterized, converting to PNG when needed. Primarily used to +preview image assets extracted from PowerPoint files. + + +Dependencies used by this tool: +- Inkscape: SVG/EMF/WMF rasterization +- ImageMagick: format bridging (TIFF→PNG, generic convert) +- Ghostscript: PDF/EPS/PS rasterization (first page) +- libheif-examples: heif-convert for HEIC/HEIF → PNG +- jxr-tools (or libjxr-tools on older distros): JxrDecApp for JPEG XR (JXR/WDP) + +Install (Ubuntu/Debian): + sudo apt-get update + sudo apt-get install -y inkscape imagemagick ghostscript libheif-examples jxr-tools + # If jxr-tools not found on your distro, try: + # sudo apt-get install -y libjxr-tools + +Verify: + inkscape --version + convert -version | grep -i "ImageMagick" + gs -v + heif-convert -h + JxrDecApp -h +""" + +import argparse +import gzip +import shutil +from os import listdir +from os.path import basename, dirname, expanduser, isfile, join, splitext +from subprocess import run + +RASTER_EXTS = { + ".png", + ".jpg", + ".jpeg", + ".bmp", + ".gif", + ".tif", + ".tiff", + ".webp", +} + +CONVERTIBLE_EXTS = { + # Windows metafiles (and compressed variants) + ".emf", + ".wmf", + ".emz", + ".wmz", + # SVG + ".svg", + ".svgz", + # JPEG XR / HD Photo + ".wdp", + ".jxr", + # HEIF family + ".heic", + ".heif", + # Page-description formats (rasterize first page) + ".pdf", + ".eps", + ".ps", +} + +SUPPORTED_EXTS = RASTER_EXTS | CONVERTIBLE_EXTS + + +def _imagemagick_convert(src_path: str, dst_path: str) -> None: + binary = shutil.which("magick") or "convert" + run([binary, src_path, dst_path], check=True) + + +def ensure_raster_image(path: str, out_dir: str | None = None) -> str: + """Return a raster image path for the given input, converting when needed. + + - EMF/WMF/EMZ/WMZ are rasterized via Inkscape (EMZ/WMZ are decompressed first) + - SVG/SVGZ are rasterized via Inkscape + - WDP/JXR are converted via ImageMagick (if codec available) + - Known raster formats are returned as-is + + Raises ValueError if the extension is not supported. + """ + base, ext = splitext(path) + ext_lower = ext.lower() + out_dir = out_dir or dirname(path) + out_path = join(out_dir, basename(base) + ".png") + + # Convertible formats + if ext_lower in (".emf", ".wmf"): + run(["inkscape", path, "-o", out_path], check=True) + if isfile(out_path): + return out_path + raise RuntimeError("inkscape reported success but output file not found: " + out_path) + + if ext_lower in (".emz", ".wmz"): + # Decompress into EMF/WMF then rasterize with Inkscape + decompressed = join(out_dir, basename(base) + (".emf" if ext_lower == ".emz" else ".wmf")) + with gzip.open(path, "rb") as zin, open(decompressed, "wb") as zout: + zout.write(zin.read()) + run( + ["inkscape", decompressed, "-o", out_path], + check=True, + ) + if isfile(out_path): + return out_path + raise RuntimeError("inkscape reported success but output file not found: " + out_path) + + if ext_lower in (".svg", ".svgz"): + run(["inkscape", path, "-o", out_path], check=True) + if isfile(out_path): + return out_path + raise RuntimeError("inkscape reported success but output file not found: " + out_path) + + if ext_lower in (".wdp", ".jxr"): + tmp_tiff = join(out_dir, basename(base) + ".tiff") + run(["JxrDecApp", "-i", path, "-o", tmp_tiff], check=True) + _imagemagick_convert(tmp_tiff, out_path) + if isfile(out_path): + return out_path + raise RuntimeError("JPEG XR decode succeeded but PNG not found: " + out_path) + + if ext_lower in (".heic", ".heif"): + # Use libheif's CLI for robust conversion + heif_convert = shutil.which("heif-convert") or "heif-convert" + run([heif_convert, path, out_path], check=True) + if isfile(out_path): + return out_path + raise RuntimeError("heif-convert reported success but output file not found: " + out_path) + + if ext_lower in (".pdf", ".eps", ".ps"): + # Rasterize first page via Ghostscript + gs = shutil.which("gs") or "gs" + run( + [ + gs, + "-dSAFER", + "-dBATCH", + "-dNOPAUSE", + "-sDEVICE=pngalpha", + "-dFirstPage=1", + "-dLastPage=1", + "-r200", + "-o", + out_path, + path, + ], + check=True, + ) + if isfile(out_path): + return out_path + raise RuntimeError("Ghostscript reported success but output file not found: " + out_path) + + if ext_lower in RASTER_EXTS: + return path + + raise ValueError(f"Unsupported image format for montage: {path}") + + +def main() -> None: + parser = argparse.ArgumentParser( + description=("Ensure input images are rasterized; convert to PNG if needed.") + ) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--input_files", nargs="+", help="List of input image file paths") + group.add_argument("--input_dir", help="Directory containing input images") + parser.add_argument( + "--output_dir", + default=None, + help=( + "Directory to write converted PNGs. If omitted, converted files are written next to inputs." + ), + ) + args = parser.parse_args() + + if args.input_files: + paths = [expanduser(p) for p in args.input_files] + else: + input_dir = expanduser(args.input_dir) + names = listdir(input_dir) + paths = [ + join(input_dir, f) + for f in names + if isfile(join(input_dir, f)) and splitext(f)[1].lower() in SUPPORTED_EXTS + ] + if not paths: + raise SystemExit("No files with supported extensions in input_dir") + + out_dir = expanduser(args.output_dir) if args.output_dir else None + converted_paths = [] + for p in paths: + if ensure_raster_image(p, out_dir) != p: + converted_paths.append(p) + + if converted_paths: + print("Converted the following files to PNG:\n" + "\n".join(converted_paths)) + + +if __name__ == "__main__": + main() diff --git a/agents/.agents/skills/slides/scripts/render_slides.py b/agents/.agents/skills/slides/scripts/render_slides.py new file mode 100644 index 0000000..c540233 --- /dev/null +++ b/agents/.agents/skills/slides/scripts/render_slides.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +# Copyright (c) OpenAI. All rights reserved. +import argparse +import os +import re +import subprocess +import tempfile +import xml.etree.ElementTree as ET +from os import makedirs, replace +from os.path import abspath, basename, exists, expanduser, join, splitext +from typing import Sequence, cast +from zipfile import ZipFile + +from pdf2image import convert_from_path, pdfinfo_from_path + +EMU_PER_INCH: int = 914_400 + + +def calc_dpi_via_ooxml(input_path: str, max_w_px: int, max_h_px: int) -> int: + """Calculate DPI from OOXML `ppt/presentation.xml` slide size (cx/cy in EMUs).""" + with ZipFile(input_path, "r") as zf: + xml = zf.read("ppt/presentation.xml") + root = ET.fromstring(xml) + ns = {"p": "http://schemas.openxmlformats.org/presentationml/2006/main"} + sld_sz = root.find("p:sldSz", ns) + if sld_sz is None: + raise RuntimeError("Slide size not found in presentation.xml") + cx = int(sld_sz.get("cx") or 0) + cy = int(sld_sz.get("cy") or 0) + if cx <= 0 or cy <= 0: + raise RuntimeError("Invalid slide size values in presentation.xml") + width_in = cx / EMU_PER_INCH + height_in = cy / EMU_PER_INCH + return round(min(max_w_px / width_in, max_h_px / height_in)) + + +def calc_dpi_via_pdf(input_path: str, max_w_px: int, max_h_px: int) -> int: + """Compute DPI from PDF page size. + + For non-PDF inputs, first convert to PDF via LibreOffice to read page size. + For PDFs, use the PDF directly (avoids unnecessary conversion and failures). + """ + is_pdf = input_path.lower().endswith(".pdf") + with tempfile.TemporaryDirectory(prefix="soffice_profile_") as user_profile: + with tempfile.TemporaryDirectory(prefix="soffice_convert_") as convert_tmp_dir: + stem = splitext(basename(input_path))[0] + pdf_path = ( + input_path + if is_pdf + else convert_to_pdf(input_path, user_profile, convert_tmp_dir, stem) + ) + if not (pdf_path and exists(pdf_path)): + raise RuntimeError("Failed to produce/read PDF for DPI computation.") + + info = pdfinfo_from_path(pdf_path) + size_val = info.get("Page size") + if not size_val: + for k, v in info.items(): + if isinstance(v, str) and "size" in k.lower() and "pts" in v: + size_val = v + break + if not isinstance(size_val, str): + raise RuntimeError("Failed to read PDF page size for DPI computation.") + + def _parse_page_size_to_pts(s: str) -> tuple[float, float]: + # Common formats from poppler/pdfinfo: + # - "612 x 792 pts (letter)" + # - "595.276 x 841.89 pts (A4)" + # - sometimes inches: "8.5 x 11 in" + m_pts = re.search( + r"([0-9]+(?:\.[0-9]+)?)\s*x\s*([0-9]+(?:\.[0-9]+)?)\s*pts\b", + s, + ) + if m_pts: + return float(m_pts.group(1)), float(m_pts.group(2)) + m_in = re.search( + r"([0-9]+(?:\.[0-9]+)?)\s*x\s*([0-9]+(?:\.[0-9]+)?)\s*in\b", + s, + ) + if m_in: + w_in = float(m_in.group(1)) + h_in = float(m_in.group(2)) + return w_in * 72.0, h_in * 72.0 + # Sometimes poppler returns without an explicit unit; treat as points. + m = re.search(r"([0-9]+(?:\.[0-9]+)?)\s*x\s*([0-9]+(?:\.[0-9]+)?)\b", s) + if m: + return float(m.group(1)), float(m.group(2)) + raise RuntimeError(f"Unrecognized PDF page size format: {s!r}") + + width_pts, height_pts = _parse_page_size_to_pts(size_val) + width_in = width_pts / 72.0 + height_in = height_pts / 72.0 + if width_in <= 0 or height_in <= 0: + raise RuntimeError("Invalid PDF page size values.") + return round(min(max_w_px / width_in, max_h_px / height_in)) + + +def run_cmd_no_check(cmd: list[str]) -> None: + subprocess.run( + cmd, + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=os.environ.copy(), + ) + + +def convert_to_pdf( + pptx_path: str, + user_profile: str, + convert_tmp_dir: str, + stem: str, +) -> str: + # Try direct PPTX -> PDF + cmd_pdf = [ + "soffice", + "-env:UserInstallation=file://" + user_profile, + "--invisible", + "--headless", + "--norestore", + "--convert-to", + "pdf", + "--outdir", + convert_tmp_dir, + pptx_path, + ] + run_cmd_no_check(cmd_pdf) + + pdf_path = join(convert_tmp_dir, f"{stem}.pdf") + if exists(pdf_path): + return pdf_path + + # Fallback: PPTX -> ODP, then ODP -> PDF + # Rationale: Saving as ODP normalizes PPTX-specific constructs via the ODF serializer, + # which often bypasses Impress PDF export issues on problematic decks. + cmd_odp = [ + "soffice", + "-env:UserInstallation=file://" + user_profile, + "--invisible", + "--headless", + "--norestore", + "--convert-to", + "odp", + "--outdir", + convert_tmp_dir, + pptx_path, + ] + run_cmd_no_check(cmd_odp) + + odp_path = join(convert_tmp_dir, f"{stem}.odp") + + if exists(odp_path): + # ODP -> PDF + cmd_odp_pdf = [ + "soffice", + "-env:UserInstallation=file://" + user_profile, + "--invisible", + "--headless", + "--norestore", + "--convert-to", + "pdf", + "--outdir", + convert_tmp_dir, + odp_path, + ] + run_cmd_no_check(cmd_odp_pdf) + if exists(pdf_path): + return pdf_path + + return "" + + +def rasterize( + input_path: str, + out_dir: str, + dpi: int, +) -> Sequence[str]: + """Rasterise PPTX/PDF to PNG files placed in out_dir and return the image paths.""" + makedirs(out_dir, exist_ok=True) + input_path = abspath(input_path) + stem = splitext(basename(input_path))[0] + + # Use a unique user profile to avoid LibreOffice profile lock when running concurrently + with tempfile.TemporaryDirectory(prefix="soffice_profile_") as user_profile: + # Write conversion outputs into a temp directory to avoid any IO oddities + with tempfile.TemporaryDirectory(prefix="soffice_convert_") as convert_tmp_dir: + is_pdf = input_path.lower().endswith(".pdf") + pdf_path = ( + input_path + if is_pdf + else convert_to_pdf(input_path, user_profile, convert_tmp_dir, stem) + ) + + if not pdf_path or not exists(pdf_path): + raise RuntimeError( + "Failed to produce PDF for rasterization (direct and ODP fallback)." + ) + + # Perform rasterization while the temp PDF still exists + paths_raw = cast( + list[str], + convert_from_path( + pdf_path, + dpi=dpi, + fmt="png", + thread_count=8, + output_folder=out_dir, + paths_only=True, + output_file="slide", + ), + ) + # Rename convert_from_path's output format f'slide{thread_id:04d}-{page_num:02d}.png' + slides = [] + for src_path in paths_raw: + base = splitext(basename(src_path))[0] + slide_num_str = base.split("-")[-1] + slide_num = int(slide_num_str) + dst_path = join(out_dir, f"slide-{slide_num}.png") + replace(src_path, dst_path) + slides.append((slide_num, dst_path)) + slides.sort(key=lambda t: t[0]) + final_paths = [path for _, path in slides] + return final_paths + + +def main() -> None: + parser = argparse.ArgumentParser(description="Render slides to images.") + parser.add_argument( + "input_path", + type=str, + help="Path to the input PowerPoint or PDF file.", + ) + parser.add_argument( + "--output_dir", + type=str, + default=None, + help=( + "Output directory for the rendered images. " + "Defaults to a folder next to the input named after the input file (without extension)." + ), + ) + parser.add_argument( + "--width", + type=int, + default=1600, + help=( + "Approximate maximum width in pixels after isotropic scaling (default 1600). " + "The actual value may exceed slightly." + ), + ) + parser.add_argument( + "--height", + type=int, + default=900, + help=( + "Approximate maximum height in pixels after isotropic scaling (default 900). " + "The actual value may exceed slightly." + ), + ) + args = parser.parse_args() + + input_path = abspath(expanduser(args.input_path)) + out_dir = abspath(expanduser(args.output_dir)) if args.output_dir else splitext(input_path)[0] + if input_path.lower().endswith((".pptx", ".ppsx", ".potx", ".pptm", ".ppsm", ".potm")): + dpi = calc_dpi_via_ooxml(input_path, args.width, args.height) + else: + dpi = calc_dpi_via_pdf(input_path, args.width, args.height) + rasterize(input_path, out_dir, dpi) + print("Slides rendered to " + out_dir) + + +if __name__ == "__main__": + main() diff --git a/agents/.agents/skills/slides/scripts/slides_test.py b/agents/.agents/skills/slides/scripts/slides_test.py new file mode 100644 index 0000000..1721681 --- /dev/null +++ b/agents/.agents/skills/slides/scripts/slides_test.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +# Copyright (c) OpenAI. All rights reserved. +import argparse +import sys +import tempfile +from os.path import abspath, expanduser, join +from pathlib import Path +from typing import Sequence, cast + +import numpy as np + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +import render_slides # type: ignore +from PIL import Image +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE +from pptx.util import Emu + +# Configuration specific to overflow checking +PAD_PX: int = 100 # fixed padding on every side in pixels +PAD_RGB = (200, 200, 200) +EMU_PER_INCH: int = 914_400 + + +def px_to_emu(px: int, dpi: int) -> Emu: + return Emu(int(px * EMU_PER_INCH // dpi)) + + +def calc_tol(dpi: int) -> int: + """Calculate per-channel colour tolerance appropriate for *dpi* (anti-aliasing tolerance).""" + if dpi >= 300: + return 0 + # 1 at 250 DPI, 5 at 150 DPI, capped to 10. + tol = round((300 - dpi) / 25) + return min(max(tol, 1), 10) + + +def enlarge_deck(src: str, dst: str, pad_emu: Emu) -> tuple[int, int]: + """Enlarge the input PPTX with a fixed grey padding and return the new page size.""" + prs = Presentation(src) + w0 = cast(Emu, prs.slide_width) + h0 = cast(Emu, prs.slide_height) + w1 = Emu(w0 + 2 * pad_emu) + h1 = Emu(h0 + 2 * pad_emu) + prs.slide_width = w1 + prs.slide_height = h1 + + for slide in prs.slides: + # Shift all shapes so the original canvas sits centred in the new deck. + for shp in list(slide.shapes): + shp.left = Emu(int(shp.left) + pad_emu) + shp.top = Emu(int(shp.top) + pad_emu) + + pads = ( + (Emu(0), Emu(0), pad_emu, h1), # left + (Emu(int(w1) - int(pad_emu)), Emu(0), pad_emu, h1), # right + (Emu(0), Emu(0), w1, pad_emu), # top + (Emu(0), Emu(int(h1) - int(pad_emu)), w1, pad_emu), # bottom + ) + + sp_tree = slide.shapes._spTree # pylint: disable=protected-access + + for left, top, width, height in pads: + pad_shape = slide.shapes.add_shape( + MSO_AUTO_SHAPE_TYPE.RECTANGLE, left, top, width, height + ) + pad_shape.fill.solid() + pad_shape.fill.fore_color.rgb = RGBColor(*PAD_RGB) + pad_shape.line.fill.background() + + # Send pad behind all other shapes (index 2 after mandatory nodes) + sp_tree.remove(pad_shape._element) + sp_tree.insert(2, pad_shape._element) + + prs.save(dst) + return int(w1), int(h1) + + +def inspect_images( + paths: Sequence[str], + pad_ratio_w: float, + pad_ratio_h: float, + dpi: int, +) -> list[int]: + """Return 1-based indices of slides that contain pixels outside the pad.""" + + tol = calc_tol(dpi) + failures: list[int] = [] + pad_colour = np.array(PAD_RGB, dtype=np.uint8) + + for idx, img_path in enumerate(paths, start=1): + with Image.open(img_path) as img: + rgb = img.convert("RGB") + arr = np.asarray(rgb) + + h, w, _ = arr.shape + # Exclude the innermost 1-pixel band + pad_x = int(w * pad_ratio_w) - 1 + pad_y = int(h * pad_ratio_h) - 1 + + left_margin = arr[:, :pad_x, :] + right_margin = arr[:, w - pad_x :, :] + top_margin = arr[:pad_y, :, :] + bottom_margin = arr[h - pad_y :, :, :] + + def _is_clean(margin: np.ndarray) -> bool: + diff = np.abs(margin.astype(np.int16) - pad_colour) + matches = np.all(diff <= tol, axis=-1) + mismatch_fraction = 1.0 - (np.count_nonzero(matches) / matches.size) + if dpi >= 300: + max_mismatch = 0.01 + elif dpi >= 200: + max_mismatch = 0.02 + else: + max_mismatch = 0.03 + return mismatch_fraction <= max_mismatch + + if not ( + _is_clean(left_margin) + and _is_clean(right_margin) + and _is_clean(top_margin) + and _is_clean(bottom_margin) + ): + failures.append(idx) + + return failures + + +def main() -> None: + parser = argparse.ArgumentParser( + description=( + "Check a PPTX for content overflowing the original canvas by rendering with padding " + "and inspecting the margins." + ) + ) + parser.add_argument( + "input_path", + type=str, + help="Path to the input PPTX file.", + ) + parser.add_argument( + "--width", + type=int, + default=1600, + help=( + "Approximate maximum width in pixels after isotropic scaling (default 1600). " + "The actual value may exceed slightly." + ), + ) + parser.add_argument( + "--height", + type=int, + default=900, + help=( + "Approximate maximum height in pixels after isotropic scaling (default 900). " + "The actual value may exceed slightly." + ), + ) + parser.add_argument( + "--pad_px", + type=int, + default=PAD_PX, + help="Padding in pixels to add on each side before rasterization.", + ) + args = parser.parse_args() + + input_path = abspath(expanduser(args.input_path)) + # Width and height refer to the original, unaltered slide dimensions. + dpi = render_slides.calc_dpi_via_ooxml(input_path, args.width, args.height) + + # Not using ``tempfile.TemporaryDirectory(delete=False)`` for Python 3.11 compatibility. + tmpdir = tempfile.mkdtemp() + enlarged_pptx = join(tmpdir, "enlarged.pptx") + pad_emu = px_to_emu(args.pad_px, dpi) + w1, h1 = enlarge_deck(input_path, enlarged_pptx, pad_emu=pad_emu) + pad_ratio_w = pad_emu / w1 + pad_ratio_h = pad_emu / h1 + + img_dir = join(tmpdir, "imgs") + img_paths = render_slides.rasterize(enlarged_pptx, img_dir, dpi) + failing = inspect_images(img_paths, pad_ratio_w, pad_ratio_h, dpi) + + if failing: + print( + "ERROR: Slides with content overflowing original canvas (1-based indexing): " + + ", ".join(map(str, failing)) + + "\n" + + "Rendered images with grey paddings for problematic slides are available at: " + ) + for i in failing: + print(img_paths[i - 1]) + else: + print("Test passed. No overflow detected.") + + +if __name__ == "__main__": + main() diff --git a/agents/.agents/skills/spreadsheet/LICENSE.txt b/agents/.agents/skills/spreadsheet/LICENSE.txt new file mode 100644 index 0000000..13e25df --- /dev/null +++ b/agents/.agents/skills/spreadsheet/LICENSE.txt @@ -0,0 +1,201 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf of + any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don\'t include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [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/agents/.agents/skills/spreadsheet/SKILL.md b/agents/.agents/skills/spreadsheet/SKILL.md new file mode 100644 index 0000000..eb8319c --- /dev/null +++ b/agents/.agents/skills/spreadsheet/SKILL.md @@ -0,0 +1,145 @@ +--- +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/agents/.agents/skills/spreadsheet/agents/openai.yaml b/agents/.agents/skills/spreadsheet/agents/openai.yaml new file mode 100644 index 0000000..c4a670e --- /dev/null +++ b/agents/.agents/skills/spreadsheet/agents/openai.yaml @@ -0,0 +1,6 @@ +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/agents/.agents/skills/spreadsheet/assets/spreadsheet-small.svg b/agents/.agents/skills/spreadsheet/assets/spreadsheet-small.svg new file mode 100644 index 0000000..c045554 --- /dev/null +++ b/agents/.agents/skills/spreadsheet/assets/spreadsheet-small.svg @@ -0,0 +1,3 @@ + + + diff --git a/agents/.agents/skills/spreadsheet/assets/spreadsheet.png b/agents/.agents/skills/spreadsheet/assets/spreadsheet.png new file mode 100644 index 0000000..bd24ca4 Binary files /dev/null and b/agents/.agents/skills/spreadsheet/assets/spreadsheet.png differ diff --git a/agents/.agents/skills/spreadsheet/references/examples/openpyxl/create_basic_spreadsheet.py b/agents/.agents/skills/spreadsheet/references/examples/openpyxl/create_basic_spreadsheet.py new file mode 100644 index 0000000..b4c5889 --- /dev/null +++ b/agents/.agents/skills/spreadsheet/references/examples/openpyxl/create_basic_spreadsheet.py @@ -0,0 +1,51 @@ +"""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/agents/.agents/skills/spreadsheet/references/examples/openpyxl/create_spreadsheet_with_styling.py b/agents/.agents/skills/spreadsheet/references/examples/openpyxl/create_spreadsheet_with_styling.py new file mode 100644 index 0000000..7ff951c --- /dev/null +++ b/agents/.agents/skills/spreadsheet/references/examples/openpyxl/create_spreadsheet_with_styling.py @@ -0,0 +1,96 @@ +"""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/agents/.agents/skills/spreadsheet/references/examples/openpyxl/read_existing_spreadsheet.py b/agents/.agents/skills/spreadsheet/references/examples/openpyxl/read_existing_spreadsheet.py new file mode 100644 index 0000000..fd0df40 --- /dev/null +++ b/agents/.agents/skills/spreadsheet/references/examples/openpyxl/read_existing_spreadsheet.py @@ -0,0 +1,59 @@ +"""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/agents/.agents/skills/spreadsheet/references/examples/openpyxl/styling_spreadsheet.py b/agents/.agents/skills/spreadsheet/references/examples/openpyxl/styling_spreadsheet.py new file mode 100644 index 0000000..37dacdf --- /dev/null +++ b/agents/.agents/skills/spreadsheet/references/examples/openpyxl/styling_spreadsheet.py @@ -0,0 +1,79 @@ +"""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() diff --git a/claude/.claude/settings.json b/claude/.claude/settings.json index bff29f2..b30b1b9 100644 --- a/claude/.claude/settings.json +++ b/claude/.claude/settings.json @@ -95,7 +95,7 @@ "Bash(stow *)" ] }, - "model": "sonnet", + "model": "opus", "statusLine": { "type": "command", "command": "bash ~/.claude/statusline-command.sh"