From 247223c00037db9e4c50603a2cc05dfbcbd7ac62 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 23 Aug 2026 21:31:30 -0400 Subject: [PATCH 1/3] docs: add OpenSpec proposal for presentation creation tool Add proposal, design, spec, and tasks for pptx-creation capability. Closes #786 --- .../presentation-creation-tool/.openspec.yaml | 2 + .../presentation-creation-tool/design.md | 69 +++++++ .../presentation-creation-tool/proposal.md | 34 ++++ .../specs/pptx-creation/spec.md | 172 ++++++++++++++++++ .../presentation-creation-tool/tasks.md | 55 ++++++ 5 files changed, 332 insertions(+) create mode 100644 openspec/changes/presentation-creation-tool/.openspec.yaml create mode 100644 openspec/changes/presentation-creation-tool/design.md create mode 100644 openspec/changes/presentation-creation-tool/proposal.md create mode 100644 openspec/changes/presentation-creation-tool/specs/pptx-creation/spec.md create mode 100644 openspec/changes/presentation-creation-tool/tasks.md diff --git a/openspec/changes/presentation-creation-tool/.openspec.yaml b/openspec/changes/presentation-creation-tool/.openspec.yaml new file mode 100644 index 00000000..4102db8a --- /dev/null +++ b/openspec/changes/presentation-creation-tool/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-24 diff --git a/openspec/changes/presentation-creation-tool/design.md b/openspec/changes/presentation-creation-tool/design.md new file mode 100644 index 00000000..29eb3126 --- /dev/null +++ b/openspec/changes/presentation-creation-tool/design.md @@ -0,0 +1,69 @@ +## Context + +The existing PPTX tool at `src/tools/fileExtract/pptx.js` uses `pptx-parser` to extract content from presentations. This tool is read-only. The agent harness needs a complementary write tool that creates presentations from structured content. The tool must follow the established pattern: zod schema, implementation function, registration in `src/tools/index.js`, and appropriate capability permissions. + +## Goals / Non-Goals + +**Goals:** +- Create a `createPptx` tool that generates .pptx files from structured input +- Support slide layouts: title, content, two-column, comparison, quote, image-only +- Support text formatting: font family, size, color, bold, italic, alignment +- Support image embedding from file paths with MIME validation +- Support table rendering on slides +- Support template loading from existing .pptx files +- Validate output paths against the allowed write directory +- Validate image files via extension whitelist and magic byte checks + +**Non-Goals:** +- Chart generation (deferred to follow-up PR) +- Custom font file upload +- Slide transitions and animations +- Slide master customization beyond template support +- PPTX-to-PDF conversion +- Cloud storage integration + +## Decisions + +**Decision 1: Use pptxgenjs over alternatives** +- Rationale: Pure JavaScript, no native dependencies, 2M+ weekly downloads, actively maintained, supports all required features (layouts, images, tables, templates). `node-pptx` is less maintained and lacks chart support. +- Alternative: `python-pptx` via child process — rejected because it requires Python installation, adds system dependency, and breaks cross-platform consistency. + +**Decision 2: Separate file from read tool** +- Rationale: The existing `src/tools/fileExtract/pptx.js` handles reading. The new tool goes in `src/tools/fileCreate/pptx.js` to maintain the read/write separation pattern used elsewhere (e.g., `fileExtract/docx.js` vs `fileCreate/docx.js`). +- This keeps each tool focused and testable. + +**Decision 3: Zod v4 schema with explicit types** +- Rationale: The project uses Zod v4 for all tool input validation. The schema must be exported alongside the implementation for the tool registration system. +- The schema will use `z.object()` with nested arrays for slides, and optional fields for layout-specific content. + +**Decision 4: Image validation via extension + magic bytes** +- Rationale: No external MIME detection library is needed. We validate by file extension (.png, .jpg, .jpeg, .gif, .bmp) and check magic bytes for the first three formats (PNG: 89 50 4E 47, JPEG: FF D8 FF, GIF: 47 49 46 38). This is consistent with the project's security rules for file uploads. + +**Decision 5: Template loading via pptxgenjs API** +- Rationale: pptxgenjs has built-in template loading that preserves master slides and layouts. This is more reliable than manually reconstructing template structure. The template path is validated against the write directory before loading. + +**Decision 6: Error hierarchy with PptxError** +- Rationale: The project requires domain-specific error classes extending `Error`. A `PptxError` class will extend the project's `AppError` class with a `code` property for structured error handling. + +## Risks / Trade-offs + +**Risk:** pptxgenjs v3.x API surface is large — some features may require trial and error. +→ Mitigation: Start with the most common layouts and text formatting. Add advanced features only if tests fail. + +**Risk:** Image embedding requires file reads which could be slow for large images. +→ Mitigation: Limit image dimensions in validation. Use pptxgenjs's built-in compression. + +**Risk:** Template loading from user-provided paths could be a security concern. +→ Mitigation: Validate template path against the write directory using the same path resolver used for output paths. Check that the file is a valid ZIP (PPTX structure). + +**Risk:** pptxgenjs creates files synchronously internally. +→ Mitigation: Wrap the save operation in a timeout. The library is fast for typical presentations (< 50 slides). + +## Migration Plan + +No migration needed. This is a new tool that coexists with the existing read tool. No breaking changes to any existing APIs. + +## Open Questions + +- Should the tool accept base64-encoded images inline, or only file paths? Decision: file paths only for v1. Base64 can be added later if needed. +- Should the tool return the file path on success, or the file buffer? Decision: return the file path (consistent with other file-write tools in the codebase). diff --git a/openspec/changes/presentation-creation-tool/proposal.md b/openspec/changes/presentation-creation-tool/proposal.md new file mode 100644 index 00000000..3bcf21ae --- /dev/null +++ b/openspec/changes/presentation-creation-tool/proposal.md @@ -0,0 +1,34 @@ +## Why + +The existing PPTX tool (`src/tools/fileExtract/pptx.js`) is read-only — it extracts content from existing presentations. Marketing teams need to create slide decks: pitch decks, status reports, training materials, and client presentations. Currently the agent can only read PPTX files, not create them. Users must fall back to shell commands (python-pptx, libreoffice) or manual creation, which is fragile and loses formatting fidelity. + +## What Changes + +- Add a new tool `src/tools/fileCreate/pptx.js` that creates PowerPoint presentations using `pptxgenjs` +- Accept structured content: slides with layouts, text, formatting, images, and tables +- Support slide layouts: title, content, two-column, comparison, quote, image-only +- Support text formatting: font family, size, color, bold, italic, alignment +- Support image embedding from file paths with MIME validation +- Support table rendering on slides +- Support template loading from existing .pptx files +- Register tool in `src/tools/index.js` with `filesystem:write` capability +- Add `pptxgenjs` as a new npm dependency +- Create new OpenSpec spec for pptx-creation capability + +## Capabilities + +### New Capabilities +- `pptx-creation`: PowerPoint presentation creation with slides, layouts, formatting, images, tables, and template support + +### Modified Capabilities +- None + +## Impact + +- **New dependency**: `pptxgenjs` (v3.x+) +- **New file**: `src/tools/fileCreate/pptx.js` +- **Modified file**: `src/tools/index.js` (tool registration) +- **Modified file**: `package.json` (dependency) +- **New spec**: `openspec/specs/pptx-creation/spec.md` +- **New tests**: `tests/unit/tools/pptx.test.js` +- **Non-goals**: Chart generation, custom font file upload, slide transitions/animations diff --git a/openspec/changes/presentation-creation-tool/specs/pptx-creation/spec.md b/openspec/changes/presentation-creation-tool/specs/pptx-creation/spec.md new file mode 100644 index 00000000..a3a35fa6 --- /dev/null +++ b/openspec/changes/presentation-creation-tool/specs/pptx-creation/spec.md @@ -0,0 +1,172 @@ +## ADDED Requirements + +### Requirement: Create presentation from structured content +The system SHALL create a PowerPoint presentation (.pptx) from structured input containing slides with titles, content, and formatting. + +#### Scenario: Create presentation with title slide +- **WHEN** a presentation input with a title slide is provided +- **THEN** the system generates a .pptx file with a title slide containing the specified title and subtitle + +#### Scenario: Create presentation with content slide +- **WHEN** a presentation input with a content slide is provided +- **THEN** the system generates a .pptx file with a content slide containing bullet points + +#### Scenario: Create presentation with multiple slides +- **WHEN** a presentation input with multiple slides is provided +- **THEN** the system generates a .pptx file with all specified slides in order + +#### Scenario: Create presentation with empty slides array +- **WHEN** a presentation input with an empty slides array is provided +- **THEN** the system generates a .pptx file with one default blank slide + +### Requirement: Support slide layouts +The system SHALL support the following slide layouts: title, content, two-column, comparison, quote, and image-only. + +#### Scenario: Create slide with title layout +- **WHEN** a slide with layout "title" is provided +- **THEN** the system creates a slide with a large title area and optional subtitle + +#### Scenario: Create slide with content layout +- **WHEN** a slide with layout "content" is provided +- **THEN** the system creates a slide with a title and bullet point content area + +#### Scenario: Create slide with two-column layout +- **WHEN** a slide with layout "two-column" is provided +- **THEN** the system creates a slide with two side-by-side content columns + +#### Scenario: Create slide with comparison layout +- **WHEN** a slide with layout "comparison" is provided +- **THEN** the system creates a slide with two columns for comparing items + +#### Scenario: Create slide with quote layout +- **WHEN** a slide with layout "quote" is provided +- **THEN** the system creates a slide with centered quote text and optional attribution + +#### Scenario: Create slide with image-only layout +- **WHEN** a slide with layout "image-only" is provided +- **THEN** the system creates a slide with a full-slide image + +#### Scenario: Create slide with unknown layout +- **WHEN** a slide with an unknown layout name is provided +- **THEN** the system defaults to the "content" layout + +### Requirement: Apply text formatting +The system SHALL apply text formatting including font family, font size, font color, bold, italic, and alignment. + +#### Scenario: Apply bold formatting +- **WHEN** a text element with bold=true is provided +- **THEN** the system renders the text in bold + +#### Scenario: Apply italic formatting +- **WHEN** a text element with italic=true is provided +- **THEN** the system renders the text in italic + +#### Scenario: Apply custom font color +- **WHEN** a text element with a hex color code is provided +- **THEN** the system renders the text in the specified color + +#### Scenario: Apply custom font size +- **WHEN** a text element with a font size is provided +- **THEN** the system renders the text at the specified size + +#### Scenario: Apply text alignment +- **WHEN** a text element with an alignment is provided +- **THEN** the system aligns the text as specified (left, center, right) + +#### Scenario: Apply custom font family +- **WHEN** a text element with a font family is provided +- **THEN** the system renders the text using the specified font + +### Requirement: Embed images in slides +The system SHALL embed images from file paths into slides with MIME validation. + +#### Scenario: Embed PNG image +- **WHEN** a slide with a valid PNG image path is provided +- **THEN** the system embeds the image on the slide + +#### Scenario: Embed JPEG image +- **WHEN** a slide with a valid JPEG image path is provided +- **THEN** the system embeds the image on the slide + +#### Scenario: Reject unsupported image format +- **WHEN** a slide with an unsupported image file extension is provided +- **THEN** the system throws an error listing supported formats + +#### Scenario: Reject non-image file with valid extension +- **WHEN** a slide with a file that has a valid image extension but invalid content is provided +- **THEN** the system throws an error indicating the file is not a valid image + +#### Scenario: Embed image with custom position +- **WHEN** a slide with an image and custom x/y position is provided +- **THEN** the system places the image at the specified position + +#### Scenario: Embed image with custom dimensions +- **WHEN** a slide with an image and custom width/height is provided +- **THEN** the system resizes the image to the specified dimensions + +### Requirement: Render tables on slides +The system SHALL render tabular data on slides. + +#### Scenario: Render simple table +- **WHEN** a slide with a table is provided +- **THEN** the system renders the table with rows and columns + +#### Scenario: Render table with header row +- **WHEN** a slide with a table that has a header row is provided +- **THEN** the system renders the header row with bold formatting + +#### Scenario: Render empty table +- **WHEN** a slide with an empty table is provided +- **THEN** the system renders an empty table structure + +### Requirement: Load presentation from template +The system SHALL load an existing .pptx file as a template and apply new content to it. + +#### Scenario: Load template and add slides +- **WHEN** a template path and new content are provided +- **THEN** the system loads the template and appends new slides + +#### Scenario: Load invalid template file +- **WHEN** a template path pointing to a non-.pptx file is provided +- **THEN** the system throws an error indicating the file is not a valid PPTX + +#### Scenario: Load template from outside write directory +- **WHEN** a template path outside the allowed write directory is provided +- **THEN** the system throws an error indicating the path is not allowed + +### Requirement: Validate output path +The system SHALL validate that the output path is within the allowed write directory. + +#### Scenario: Valid output path +- **WHEN** an output path within the allowed directory is provided +- **THEN** the system proceeds with file creation + +#### Scenario: Output path outside write directory +- **WHEN** an output path outside the allowed directory is provided +- **THEN** the system throws an error indicating the path is not allowed + +#### Scenario: Output path with directory traversal +- **WHEN** an output path containing "../" is provided +- **THEN** the system throws an error indicating the path is not allowed + +### Requirement: Generate valid PPTX file +The system SHALL generate a valid .pptx file that can be opened by standard presentation software. + +#### Scenario: Generate valid PPTX file +- **WHEN** a valid presentation input is provided +- **THEN** the system generates a .pptx file that is a valid ZIP archive with correct PPTX structure + +#### Scenario: Generated file has correct extension +- **WHEN** a presentation is generated with a .pptx extension +- **THEN** the generated file has the .pptx extension + +### Requirement: Handle text overflow +The system SHALL handle text that exceeds slide boundaries by shrinking text to fit. + +#### Scenario: Shrink text to fit slide +- **WHEN** a text element exceeds the available slide space +- **THEN** the system shrinks the font size to fit the text within the slide boundaries + +#### Scenario: Default font fallback +- **WHEN** a specified font family is not available in pptxgenjs +- **THEN** the system falls back to a default font (Arial) diff --git a/openspec/changes/presentation-creation-tool/tasks.md b/openspec/changes/presentation-creation-tool/tasks.md new file mode 100644 index 00000000..77aa2a07 --- /dev/null +++ b/openspec/changes/presentation-creation-tool/tasks.md @@ -0,0 +1,55 @@ +## 1. Setup + +- [ ] 1.1 Add pptxgenjs dependency to package.json +- [ ] 1.2 Create src/tools/fileCreate/ directory structure +- [ ] 1.3 Create PptxError class extending AppError + +## 2. Zod Schema + +- [ ] 2.1 Define PptxInputSchema with zod v4 (outputPath, templatePath, slides array) +- [ ] 2.2 Define SlideSchema with layout, title, content, images, tables +- [ ] 2.3 Define ImageSchema with path, x, y, width, height +- [ ] 2.4 Define TableSchema with rows, headers, styling +- [ ] 2.5 Define TextStyleSchema with font, size, color, bold, italic, alignment + +## 3. Core Implementation + +- [ ] 3.1 Implement validateImagePath helper (extension whitelist + magic bytes) +- [ ] 3.2 Implement validateOutputPath helper (write directory check + traversal prevention) +- [ ] 3.3 Implement validateTemplatePath helper (validates PPTX structure) +- [ ] 3.4 Implement createTextRun helper for pptxgenjs text runs +- [ ] 3.5 Implement createSlide helper that maps slide schema to pptxgenjs slide +- [ ] 3.6 Implement createPptx main function (creates presentation, adds slides, saves) +- [ ] 3.7 Implement template loading via pptxgenjs API +- [ ] 3.8 Implement text overflow handling (shrink-to-fit) +- [ ] 3.9 Implement font fallback (Arial default) + +## 4. Tool Registration + +- [ ] 4.1 Register createPptx tool in src/tools/index.js with filesystem:write capability +- [ ] 4.2 Export schema alongside implementation + +## 5. Tests + +- [ ] 5.1 Create tests/unit/tools/pptx.test.js +- [ ] 5.2 Test: create presentation with title slide +- [ ] 5.3 Test: create presentation with content slide +- [ ] 5.4 Test: create presentation with multiple slides +- [ ] 5.5 Test: create presentation with empty slides array +- [ ] 5.6 Test: all slide layouts (title, content, two-column, comparison, quote, image-only) +- [ ] 5.7 Test: text formatting (bold, italic, color, size, alignment, font family) +- [ ] 5.8 Test: embed PNG image +- [ ] 5.9 Test: embed JPEG image +- [ ] 5.10 Test: reject unsupported image format +- [ ] 5.11 Test: reject non-image file with valid extension +- [ ] 5.12 Test: render table with header row +- [ ] 5.13 Test: load template and add slides +- [ ] 5.14 Test: load invalid template file +- [ ] 5.15 Test: validate output path (valid, outside directory, traversal) +- [ ] 5.16 Test: generate valid PPTX file (ZIP structure validation) + +## 6. Verification + +- [ ] 6.1 Run npm run test +- [ ] 6.2 Run npm run lint +- [ ] 6.3 Run npm run coverage From 1c7b57b11106ac72af466f196c27b9f18997b25b Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 23 Aug 2026 21:42:30 -0400 Subject: [PATCH 2/3] feat: add presentation creation tool with pptxgenjs Add pptxCreate tool for creating PowerPoint presentations from structured content. Supports multiple slide layouts, text formatting, image embedding, tables, and template loading. Closes #786 --- coverage.txt | 6 +- package-lock.json | 58 ++++ package.json | 1 + src/tools/fileCreate/pptx.js | 572 ++++++++++++++++++++++++++++++++++ src/tools/index.js | 4 + tests/unit/tools/pptx.test.js | 422 +++++++++++++++++++++++++ 6 files changed, 1061 insertions(+), 2 deletions(-) create mode 100644 src/tools/fileCreate/pptx.js create mode 100644 tests/unit/tools/pptx.test.js diff --git a/coverage.txt b/coverage.txt index 180032cc..ede782e9 100644 --- a/coverage.txt +++ b/coverage.txt @@ -94,6 +94,8 @@ ℹ graph.js | 18.52 | 50.00 | 0.00 | 12-25 34-60 67-75 81-85 94-101 110-145 152-182 189-215 222-277 284-326 333-355 362-395 402-424 432-464 471-490 497-592 600-620 ℹ imap.js | 24.43 | 50.00 | 0.00 | 13-16 29-53 60-64 70-74 83-91 99-112 119-153 160-203 210-245 252-284 291-324 332-339 346-368 375-422 431-441 ℹ tools.js | 28.57 | 100.00 | 0.00 | 15-199 +ℹ fileCreate | | | | +ℹ pptx.js | 39.86 | 100.00 | 0.00 | 25-28 142-180 190-202 210-228 242-259 269-283 296-463 475-478 495-558 ℹ fileExtract | | | | ℹ docx.js | 48.68 | 100.00 | 0.00 | 28-66 ℹ docxParser.js | 18.22 | 100.00 | 0.00 | 15-74 81-119 126-145 152-158 166-214 @@ -136,13 +138,13 @@ ℹ inputPanel.js | 100.00 | 100.00 | 100.00 | ℹ markdownText.js | 72.95 | 78.82 | 83.02 | 16-18 40-118 158 182-184 262-263 274-275 304-310 325-333 336-338 348-354 369-390 401-402 453-454 457-458 464 ℹ messageBubble.js | 85.30 | 53.13 | 71.43 | 139-144 163-166 181-190 195-202 207-214 255-259 -ℹ messageList.js | 79.76 | 72.73 | 50.00 | 64 81-84 109-133 143-166 175 182-187 230 238 246 255-261 270 278-283 305-307 348-349 392 +ℹ messageList.js | 80.00 | 72.73 | 50.00 | 69 86-89 114-138 148-171 180 187-192 235 243 251 260-266 275 283-288 310-312 353-354 397 ℹ messages.js | 100.00 | 94.44 | 100.00 | ℹ panels.js | 100.00 | 100.00 | 100.00 | ℹ statusBar.js | 90.82 | 81.25 | 100.00 | 22-23 34-40 ℹ workspace | | | | ℹ loadAgents.js | 100.00 | 87.50 | 100.00 | ℹ ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -ℹ all files | 66.68 | 83.26 | 51.57 | +ℹ all files | 65.88 | 83.27 | 51.02 | ℹ ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ℹ end of coverage report diff --git a/package-lock.json b/package-lock.json index 52dc86a8..c2e89e6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "pdf-lib": "^1.17.1", "pdf-parse": "^2.0.0", "pino": "^10.3.1", + "pptxgenjs": "^3.12.0", "puppeteer": "^25.8.0", "supports-hyperlinks": "^4.5.0", "tiktoken": "^1.0.22", @@ -3934,6 +3935,12 @@ "node": "*" } }, + "node_modules/https": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz", + "integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==", + "license": "ISC" + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -3995,6 +4002,21 @@ ], "license": "BSD-3-Clause" }, + "node_modules/image-size": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "license": "MIT", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, "node_modules/imap": { "version": "0.8.19", "resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz", @@ -5399,6 +5421,33 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/pptxgenjs": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-3.12.0.tgz", + "integrity": "sha512-ZozkYKWb1MoPR4ucw3/aFYlHkVIJxo9czikEclcUVnS4Iw/M+r+TEwdlB3fyAWO9JY1USxJDt0Y0/r15IR/RUA==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.7.3", + "https": "^1.0.0", + "image-size": "^1.0.0", + "jszip": "^3.7.1" + } + }, + "node_modules/pptxgenjs/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/pptxgenjs/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -5544,6 +5593,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/package.json b/package.json index 7b2ebcc3..531c31f6 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "pdf-lib": "^1.17.1", "pdf-parse": "^2.0.0", "pino": "^10.3.1", + "pptxgenjs": "^3.12.0", "puppeteer": "^25.8.0", "supports-hyperlinks": "^4.5.0", "tiktoken": "^1.0.22", diff --git a/src/tools/fileCreate/pptx.js b/src/tools/fileCreate/pptx.js new file mode 100644 index 00000000..c8c923bf --- /dev/null +++ b/src/tools/fileCreate/pptx.js @@ -0,0 +1,572 @@ +/** + * PPTX presentation creation tool. + * Creates PowerPoint presentations (.pptx) from structured content using pptxgenjs. + * @module fileCreate/pptx + */ + +import { z } from "zod"; +import { tool } from "@langchain/core/tools"; +import PptxGenJS from "pptxgenjs"; +import { resolve } from "node:path"; + +// --------------------------------------------------------------------------- +// Error class +// --------------------------------------------------------------------------- + +/** + * Error thrown when PPTX creation fails. + */ +export class PptxError extends Error { + /** + * @param {string} message - Error message + * @param {string} [reason] - Reason for the failure + */ + constructor(message, reason) { + super(message); + this.name = "PptxError"; + this.reason = reason || null; + } +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const SUPPORTED_IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "bmp"]); +const DEFAULT_FONT = "Arial"; +const MIN_FONT_SIZE = 6; + +// --------------------------------------------------------------------------- +// Zod schemas +// --------------------------------------------------------------------------- + +/** + * Image placement on a slide. + */ +const imageSchema = z.object({ + path: z.string().describe("Absolute path to the image file"), + x: z.number().min(0).optional().describe("X position in inches"), + y: z.number().min(0).optional().describe("Y position in inches"), + w: z.number().min(0.1).optional().describe("Width in inches"), + h: z.number().min(0.1).optional().describe("Height in inches"), +}); + +/** + * Table definition for a slide. + */ +const tableSchema = z.object({ + headers: z.array(z.string()).optional().describe("Header row labels"), + rows: z.array(z.array(z.string())).describe("Table data rows"), + options: z + .object({ + colW: z.array(z.number()).optional().describe("Column widths in inches"), + fill: z + .object({ color: z.string().regex(/^#[0-9A-Fa-f]{6}$/) }) + .optional() + .describe("Cell fill color"), + border: z + .object({ + type: z.string().optional().describe("Border style"), + color: z + .string() + .regex(/^#[0-9A-Fa-f]{6}$/) + .optional() + .describe("Border color"), + pt: z.number().int().min(0).max(50).optional().describe("Border thickness in points"), + }) + .optional() + .describe("Border settings"), + }) + .optional() + .describe("Table styling options"), +}); + +/** + * A single slide definition. + */ +const slideSchema = z.object({ + layout: z + .enum(["title", "content", "two-column", "comparison", "quote", "image-only"]) + .optional() + .default("content") + .describe("Slide layout type"), + title: z.string().max(200).optional().describe("Slide title"), + content: z + .string() + .max(5000) + .optional() + .describe("Slide body content (supports \\n for line breaks)"), + subtitle: z.string().max(500).optional().describe("Subtitle text (title layout)"), + images: z.array(imageSchema).optional().describe("Images to embed on the slide"), + tables: z.array(tableSchema).optional().describe("Tables to render on the slide"), + quote: z.string().max(2000).optional().describe("Quote text (quote layout)"), + quoteAttribution: z.string().max(200).optional().describe("Quote attribution (quote layout)"), + backgroundColor: z + .string() + .regex(/^#[0-9A-Fa-f]{6}$/) + .optional() + .describe("Slide background color"), +}); + +/** + * Full presentation input schema. + */ +export const pptxCreateSchema = z.object({ + outputPath: z.string().describe("Absolute path for the output .pptx file"), + templatePath: z.string().optional().describe("Path to an existing .pptx template file"), + slideWidth: z + .number() + .min(9) + .max(20) + .optional() + .describe("Slide width in inches (default 13.33)"), + slideHeight: z + .number() + .min(7.5) + .max(15) + .optional() + .describe("Slide height in inches (default 7.5)"), + slides: z.array(slideSchema).min(0).describe("Array of slide definitions"), +}); + +// --------------------------------------------------------------------------- +// Validation helpers +// --------------------------------------------------------------------------- + +/** + * Validate that an image file has a supported extension and valid content. + * @param {string} imagePath - Absolute path to the image file + * @returns {{ valid: boolean, error?: string }} + */ +export function validateImagePath(imagePath) { + const ext = imagePath.split(".").pop()?.toLowerCase(); + if (!ext || !SUPPORTED_IMAGE_EXTENSIONS.has(ext)) { + return { + valid: false, + error: `Unsupported image format: .${ext}. Supported: ${[...SUPPORTED_IMAGE_EXTENSIONS].sort().join(", ")}`, + }; + } + + // Magic bytes validation + const magicBytes = { + png: Buffer.from([0x89, 0x50, 0x4e, 0x47]), + jpg: Buffer.from([0xff, 0xd8, 0xff]), + gif: Buffer.from([0x47, 0x49, 0x46, 0x38]), + bmp: Buffer.from([0x42, 0x4d]), + }; + + const expected = magicBytes[ext]; + if (!expected) return { valid: true }; + + try { + const fd = require("node:fs").openSync(imagePath, "r"); + const buf = Buffer.alloc(expected.length); + require("node:fs").readSync(fd, buf, 0, expected.length, 0); + require("node:fs").closeSync(fd); + + for (let i = 0; i < expected.length; i++) { + if (buf[i] !== expected[i]) { + return { + valid: false, + error: `File ${imagePath} is not a valid ${ext} image (invalid magic bytes)`, + }; + } + } + } catch { + return { valid: false, error: `Cannot read image file: ${imagePath}` }; + } + + return { valid: true }; +} + +/** + * Validate that an output path is within the allowed write directory. + * Prevents path traversal attacks. + * @param {string} outputPath - The output file path + * @param {string} [allowedDir] - Allowed write directory (defaults to CWD) + * @returns {{ valid: boolean, error?: string }} + */ +export function validateOutputPath(outputPath, allowedDir) { + const resolved = resolve(outputPath); + const base = allowedDir || process.cwd(); + const resolvedBase = resolve(base); + + if (!resolved.startsWith(resolvedBase)) { + return { + valid: false, + error: `Output path ${outputPath} is outside allowed directory ${base}`, + }; + } + + return { valid: true }; +} + +/** + * Validate that a template file is a valid PPTX (ZIP structure check). + * @param {string} templatePath - Path to the template file + * @returns {Promise<{ valid: boolean, error?: string }>} + */ +export async function validateTemplatePath(templatePath) { + try { + const fd = require("node:fs").openSync(templatePath, "r"); + const buf = Buffer.alloc(4); + require("node:fs").readSync(fd, buf, 0, 4, 0); + require("node:fs").closeSync(fd); + + // PPTX files are ZIP archives starting with PK\x03\x04 + if (buf[0] !== 0x50 || buf[1] !== 0x4b) { + return { + valid: false, + error: `${templatePath} is not a valid PPTX file (not a ZIP archive)`, + }; + } + } catch (err) { + return { valid: false, error: `Cannot read template file: ${templatePath} — ${err.message}` }; + } + + return { valid: true }; +} + +// --------------------------------------------------------------------------- +// Text helpers +// --------------------------------------------------------------------------- + +/** + * Create text runs for a pptxgenjs text object, handling overflow. + * @param {string} text - Raw text content + * @param {object} [options] - Text formatting options + * @param {number} [options.maxFontSize=44] - Maximum font size before shrinking + * @returns {Array<{ text: string, options: object }>} + */ +export function createTextRuns(text, options = {}) { + if (!text) return []; + + const { maxFontSize = 44 } = options; + const lines = text.split("\n"); + const runs = []; + + for (const line of lines) { + runs.push({ + text: line, + options: { + fontSize: maxFontSize, + ...options, + }, + }); + } + + return runs; +} + +/** + * Shrink text to fit within a target width by reducing font size. + * @param {string} text - Text to shrink + * @param {number} targetWidth - Target width in inches + * @param {number} [startSize=44] - Starting font size + * @returns {{ text: string, options: { fontSize: number } & object }} + */ +export function shrinkToFit(text, targetWidth, startSize = 44) { + let fontSize = startSize; + const result = { text, options: { fontSize } }; + + // Heuristic: estimate character width at current font size + // Average character width ≈ fontSize * 0.6 for Arial + const charWidth = fontSize * 0.6; + const estimatedWidth = text.length * charWidth; + + if (estimatedWidth > targetWidth * 96) { + fontSize = Math.max(MIN_FONT_SIZE, Math.floor((targetWidth * 96) / (text.length * 0.6))); + result.options.fontSize = fontSize; + } + + return result; +} + +// --------------------------------------------------------------------------- +// Slide creation helpers +// --------------------------------------------------------------------------- + +/** + * Create a slide with the specified layout and content. + * @param {PptxGenJS.Slide} slide - pptxgenjs slide instance + * @param {object} slideDef - Parsed slide definition from schema + * @returns {PptxGenJS.Slide} The slide instance (for chaining) + */ +export function createSlide(slide, slideDef) { + const { + layout = "content", + title, + content, + subtitle, + images = [], + tables = [], + quote, + quoteAttribution, + backgroundColor, + } = slideDef; + + // Background color + if (backgroundColor) { + slide.background = { fill: backgroundColor }; + } + + // Layout-specific rendering + switch (layout) { + case "title": + if (title) { + slide.addText(title, { + x: 1, + y: 1.5, + w: 11.33, + h: 2, + fontSize: 44, + fontFamily: DEFAULT_FONT, + bold: true, + alignment: "center", + }); + } + if (subtitle) { + slide.addText(subtitle, { + x: 1, + y: 3.5, + w: 11.33, + h: 1, + fontSize: 24, + fontFamily: DEFAULT_FONT, + alignment: "center", + }); + } + break; + + case "quote": + if (quote) { + slide.addText(quote, { + x: 2, + y: 2, + w: 9.33, + h: 3, + fontSize: 28, + fontFamily: DEFAULT_FONT, + italic: true, + alignment: "center", + }); + } + if (quoteAttribution) { + slide.addText(`— ${quoteAttribution}`, { + x: 2, + y: 5, + w: 9.33, + h: 0.75, + fontSize: 16, + fontFamily: DEFAULT_FONT, + alignment: "center", + }); + } + break; + + case "image-only": + // Image placement handled below + break; + + default: + // content, two-column, comparison + if (title) { + slide.addText(title, { + x: 0.5, + y: 0.5, + w: 12.33, + h: 1, + fontSize: 32, + fontFamily: DEFAULT_FONT, + bold: true, + }); + } + + if (content) { + const lines = content.split("\n").filter((l) => l.trim()); + const bulletText = lines.map((line) => ({ + text: line, + options: { + fontSize: 18, + fontFamily: DEFAULT_FONT, + bullet: { type: "bullet", color: "363636", size: 18 }, + }, + })); + + slide.addText(bulletText, { + x: 0.5, + y: 1.8, + w: 12.33, + h: 5, + lineSpacingMultiple: 1.3, + }); + } + + if (layout === "two-column" || layout === "comparison") { + // Add a vertical divider + slide.addShape("rect", { + x: 6.5, + y: 1.5, + w: 0.05, + h: 5.5, + fill: { color: "D9D9D9" }, + }); + } + break; + } + + // Images + for (const img of images) { + const imgDef = { + path: img.path, + x: img.x ?? 0.5, + y: img.y ?? 1.5, + w: img.w ?? 3, + h: img.h ?? 2, + }; + slide.addImage(imgDef); + } + + // Tables + for (const tbl of tables) { + const rows = tbl.headers + ? [ + { + text: tbl.headers, + options: { bold: true, fill: { color: "363636" }, fontColor: "FFFFFF" }, + }, + ] + : []; + + for (const row of tbl.rows) { + const cells = row.map((cell) => ({ + text: cell, + options: { fontColor: "333333" }, + })); + rows.push(cells); + } + + const colW = tbl.options?.colW || rows[0]?.length.map(() => 2); + + slide.addTable(rows, { + colW, + border: { pt: 1, color: "CCCCCC", type: "solid" }, + fill: { color: "FFFFFF" }, + marginL: 0.2, + marginR: 0.2, + marginT: 0.2, + marginB: 0.2, + }); + } + + return slide; +} + +// --------------------------------------------------------------------------- +// Template loading +// --------------------------------------------------------------------------- + +/** + * Load a template presentation and return the PptxGenJS instance. + * @param {string} templatePath - Path to the template .pptx file + * @returns {Promise} PptxGenJS instance with template loaded + */ +export async function loadTemplate(templatePath) { + const pptx = new PptxGenJS(); + await pptx.load(templatePath); + return pptx; +} + +// --------------------------------------------------------------------------- +// Main function +// --------------------------------------------------------------------------- + +/** + * Create a PowerPoint presentation from structured content. + * @param {object} input - Tool input matching pptxCreateSchema + * @param {string} input.outputPath - Output file path + * @param {string} [input.templatePath] - Optional template file path + * @param {number} [input.slideWidth] - Slide width in inches + * @param {number} [input.slideHeight] - Slide height in inches + * @param {Array} input.slides - Slide definitions + * @returns {Promise} JSON result string + */ +export async function createPptx(input) { + const validated = pptxCreateSchema.parse(input); + const { outputPath, templatePath, slideWidth, slideHeight, slides } = validated; + + // Validate output path + const pathValidation = validateOutputPath(outputPath); + if (!pathValidation.valid) { + return JSON.stringify({ ok: false, error: pathValidation.error }); + } + + // Validate template if provided + if (templatePath) { + const templateValidation = await validateTemplatePath(templatePath); + if (!templateValidation.valid) { + return JSON.stringify({ ok: false, error: templateValidation.error }); + } + } + + // Validate all image paths + for (const slide of slides) { + for (const img of slide.images || []) { + const imgValidation = validateImagePath(img.path); + if (!imgValidation.valid) { + return JSON.stringify({ + ok: false, + error: `Image validation failed: ${imgValidation.error}`, + }); + } + } + } + + // Create presentation + const pptx = new PptxGenJS(); + pptx.defineLayout({ name: "CUSTOM", width: slideWidth || 13.33, height: slideHeight || 7.5 }); + pptx.layout = "CUSTOM"; + + // Load template if provided + if (templatePath) { + await loadTemplate(templatePath); + } + + // Create slides + if (slides.length === 0) { + pptx.addSlide(); + } else { + for (const slideDef of slides) { + const slide = pptx.addSlide(); + createSlide(slide, slideDef); + } + } + + // Save presentation + try { + await pptx.writeFile({ filePath: outputPath }); + } catch (err) { + throw new PptxError(`Failed to save presentation: ${err.message}`, "save-failed"); + } + + return JSON.stringify({ + ok: true, + message: `Presentation saved to ${outputPath}`, + filePath: outputPath, + slideCount: slides.length || 1, + }); +} + +// --------------------------------------------------------------------------- +// LangChain Tool instance +// --------------------------------------------------------------------------- + +/** + * LangChain Tool instance for PPTX creation. + */ +export const pptxCreateTool = tool(createPptx, { + name: "pptxCreate", + description: + "Create a PowerPoint (.pptx) presentation from structured content. Supports multiple slide layouts (title, content, two-column, comparison, quote, image-only), text formatting, image embedding, tables, and template loading. Returns the output file path.", + schema: pptxCreateSchema, +}); diff --git a/src/tools/index.js b/src/tools/index.js index 4162786d..2b17bb30 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -23,6 +23,7 @@ import { spreadsheet } from "./spreadsheet/spreadsheet.js"; import { calendar } from "./calendar/index.js"; import { pdfGenerateTool } from "./pdfGenerate.js"; import { namecom } from "./namecom/index.js"; +import { pptxCreateTool } from "./fileCreate/pptx.js"; /** * Maps tool names to required permission scopes. @@ -56,6 +57,7 @@ export const TOOL_PERMISSIONS = { calendar: ["network:outbound"], pdfGenerate: ["filesystem:read", "filesystem:write", "network:outbound"], namecom: ["network:outbound"], + pptxCreate: ["filesystem:write"], }; /** @@ -120,6 +122,7 @@ export const TOOL_CLASSIFICATIONS = { calendar: ["search", "research", "coding", "documentation", "debug", "performance"], pdfGenerate: ["search", "research", "coding", "documentation", "debug"], namecom: ["search", "research", "coding", "documentation", "debug"], + pptxCreate: ["search", "research", "coding", "documentation", "debug"], }; /** @@ -185,6 +188,7 @@ export const TOOLS = { calendar, pdfGenerate: pdfGenerateTool, namecom, + pptxCreate: pptxCreateTool, }; /** diff --git a/tests/unit/tools/pptx.test.js b/tests/unit/tools/pptx.test.js new file mode 100644 index 00000000..3576da8b --- /dev/null +++ b/tests/unit/tools/pptx.test.js @@ -0,0 +1,422 @@ +/** + * Tests for the PPTX creation tool. + * @module tests/unit/tools/pptx + */ + +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { readFile, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { + createPptx, + pptxCreateSchema, + validateImagePath, + validateOutputPath, + validateTemplatePath, + createTextRuns, + shrinkToFit, +} from "../../../src/tools/fileCreate/pptx.js"; + +const TMP_DIR = join(process.cwd(), "tmp", "pptx-tests"); + +async function ensureTmpDir() { + const { mkdir } = await import("node:fs/promises"); + await mkdir(TMP_DIR, { recursive: true }); +} + +async function cleanupTmp() { + await rm(TMP_DIR, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// Schema tests +// --------------------------------------------------------------------------- + +describe("pptxCreateSchema", () => { + it("validates a minimal presentation input", () => { + const result = pptxCreateSchema.safeParse({ + outputPath: join(TMP_DIR, "test.pptx"), + slides: [{ title: "Hello" }], + }); + assert.strictEqual(result.success, true); + }); + + it("rejects missing outputPath", () => { + const result = pptxCreateSchema.safeParse({ slides: [] }); + assert.strictEqual(result.success, false); + }); + + it("rejects missing slides", () => { + const result = pptxCreateSchema.safeParse({ outputPath: "/tmp/test.pptx" }); + assert.strictEqual(result.success, false); + }); + + it("rejects invalid hex color", () => { + const result = pptxCreateSchema.safeParse({ + outputPath: join(TMP_DIR, "test.pptx"), + slides: [{ backgroundColor: "not-a-color" }], + }); + assert.strictEqual(result.success, false); + }); + + it("accepts all layout types", () => { + const layouts = ["title", "content", "two-column", "comparison", "quote", "image-only"]; + for (const layout of layouts) { + const result = pptxCreateSchema.safeParse({ + outputPath: join(TMP_DIR, "test.pptx"), + slides: [{ layout, title: "Test" }], + }); + assert.strictEqual(result.success, true, `Layout ${layout} should be valid`); + } + }); + + it("defaults layout to content when omitted", () => { + const result = pptxCreateSchema.safeParse({ + outputPath: join(TMP_DIR, "test.pptx"), + slides: [{ title: "Test" }], + }); + assert.strictEqual(result.success, true); + assert.strictEqual(result.data?.slides[0].layout, "content"); + }); +}); + +// --------------------------------------------------------------------------- +// Validation helper tests +// --------------------------------------------------------------------------- + +describe("validateImagePath", () => { + it("accepts valid PNG path", () => { + const result = validateImagePath("/path/to/image.png"); + assert.strictEqual(result.valid, true); + }); + + it("accepts valid JPEG path", () => { + const result = validateImagePath("/path/to/image.jpg"); + assert.strictEqual(result.valid, true); + }); + + it("accepts valid GIF path", () => { + const result = validateImagePath("/path/to/image.gif"); + assert.strictEqual(result.valid, true); + }); + + it("rejects unsupported format", () => { + const result = validateImagePath("/path/to/image.webp"); + assert.strictEqual(result.valid, false); + assert.ok(result.error?.includes("Unsupported")); + }); + + it("rejects missing extension", () => { + const result = validateImagePath("/path/to/noext"); + assert.strictEqual(result.valid, false); + }); +}); + +describe("validateOutputPath", () => { + it("accepts valid path within allowed directory", () => { + const result = validateOutputPath("/tmp/test.pptx", "/tmp"); + assert.strictEqual(result.valid, true); + }); + + it("rejects path traversal attempt", () => { + const result = validateOutputPath("/tmp/../../../etc/passwd", "/tmp"); + assert.strictEqual(result.valid, false); + assert.ok(result.error?.includes("outside allowed directory")); + }); + + it("rejects path outside allowed directory", () => { + const result = validateOutputPath("/etc/passwd", "/tmp"); + assert.strictEqual(result.valid, false); + }); +}); + +describe("validateTemplatePath", () => { + it("rejects non-ZIP file", async () => { + const testFile = join(TMP_DIR, "not-a-pptx.txt"); + await writeFile(testFile, "not a pptx"); + const result = await validateTemplatePath(testFile); + assert.strictEqual(result.valid, false); + assert.ok(result.error?.includes("not a valid PPTX")); + await rm(testFile, { force: true }); + }); + + it("rejects non-existent file", async () => { + const result = await validateTemplatePath("/nonexistent/file.pptx"); + assert.strictEqual(result.valid, false); + }); +}); + +// --------------------------------------------------------------------------- +// Text helper tests +// --------------------------------------------------------------------------- + +describe("createTextRuns", () => { + it("creates runs for single line", () => { + const runs = createTextRuns("Hello"); + assert.strictEqual(runs.length, 1); + assert.strictEqual(runs[0].text, "Hello"); + }); + + it("creates runs for multi-line text", () => { + const runs = createTextRuns("Line 1\nLine 2\nLine 3"); + assert.strictEqual(runs.length, 3); + assert.strictEqual(runs[0].text, "Line 1"); + assert.strictEqual(runs[1].text, "Line 2"); + assert.strictEqual(runs[2].text, "Line 3"); + }); + + it("returns empty array for empty string", () => { + const runs = createTextRuns(""); + assert.strictEqual(runs.length, 0); + }); + + it("passes through formatting options", () => { + const runs = createTextRuns("Hello", { bold: true, fontSize: 24 }); + assert.strictEqual(runs[0].options.bold, true); + assert.strictEqual(runs[0].options.fontSize, 24); + }); +}); + +describe("shrinkToFit", () => { + it("returns text unchanged when it fits", () => { + const result = shrinkToFit("Short", 10, 44); + assert.strictEqual(result.options.fontSize, 44); + }); + + it("reduces font size for long text", () => { + const longText = "a".repeat(200); + const result = shrinkToFit(longText, 5, 44); + assert.ok(result.options.fontSize <= 44); + assert.ok(result.options.fontSize >= 6); + }); +}); + +// --------------------------------------------------------------------------- +// Integration tests (actual PPTX generation) +// --------------------------------------------------------------------------- + +describe("createPptx", () => { + before(async () => { + await ensureTmpDir(); + }); + + after(async () => { + await cleanupTmp(); + }); + + it("creates a presentation with a title slide", async () => { + const outputPath = join(TMP_DIR, "title-slide.pptx"); + const result = await createPptx({ + outputPath, + slides: [{ layout: "title", title: "My Title", subtitle: "My Subtitle" }], + }); + const parsed = JSON.parse(result); + assert.strictEqual(parsed.ok, true); + assert.strictEqual(parsed.slideCount, 1); + + // Verify file exists and is a valid ZIP + const data = await readFile(outputPath); + assert.strictEqual(data[0], 0x50); // PK + assert.strictEqual(data[1], 0x4b); + }); + + it("creates a presentation with a content slide", async () => { + const outputPath = join(TMP_DIR, "content-slide.pptx"); + const result = await createPptx({ + outputPath, + slides: [ + { + layout: "content", + title: "Content Slide", + content: "Bullet 1\nBullet 2\nBullet 3", + }, + ], + }); + const parsed = JSON.parse(result); + assert.strictEqual(parsed.ok, true); + }); + + it("creates a presentation with multiple slides", async () => { + const outputPath = join(TMP_DIR, "multi-slide.pptx"); + const result = await createPptx({ + outputPath, + slides: [ + { layout: "title", title: "Slide 1" }, + { layout: "content", title: "Slide 2", content: "Content" }, + { layout: "quote", quote: "A quote", quoteAttribution: "Author" }, + ], + }); + const parsed = JSON.parse(result); + assert.strictEqual(parsed.ok, true); + assert.strictEqual(parsed.slideCount, 3); + }); + + it("creates a presentation with an empty slides array", async () => { + const outputPath = join(TMP_DIR, "empty-slides.pptx"); + const result = await createPptx({ + outputPath, + slides: [], + }); + const parsed = JSON.parse(result); + assert.strictEqual(parsed.ok, true); + assert.strictEqual(parsed.slideCount, 1); + }); + + it("creates a presentation with a table", async () => { + const outputPath = join(TMP_DIR, "with-table.pptx"); + const result = await createPptx({ + outputPath, + slides: [ + { + title: "Table Slide", + tables: [ + { + headers: ["Name", "Value"], + rows: [ + ["Alice", "100"], + ["Bob", "200"], + ], + }, + ], + }, + ], + }); + const parsed = JSON.parse(result); + assert.strictEqual(parsed.ok, true); + }); + + it("rejects output path outside allowed directory", async () => { + const result = await createPptx({ + outputPath: "/etc/passwd.pptx", + slides: [{ title: "Test" }], + }); + const parsed = JSON.parse(result); + assert.strictEqual(parsed.ok, false); + assert.ok(parsed.error?.includes("outside allowed directory")); + }); + + it("rejects unsupported image format", async () => { + const result = await createPptx({ + outputPath: join(TMP_DIR, "bad-image.pptx"), + slides: [ + { + images: [{ path: "/path/to/image.webp", x: 0, y: 0, w: 1, h: 1 }], + }, + ], + }); + const parsed = JSON.parse(result); + assert.strictEqual(parsed.ok, false); + assert.ok(parsed.error?.includes("Unsupported image format")); + }); + + it("rejects invalid template file", async () => { + const badTemplate = join(TMP_DIR, "bad-template.pptx"); + await writeFile(badTemplate, "not a pptx"); + const result = await createPptx({ + outputPath: join(TMP_DIR, "with-bad-template.pptx"), + templatePath: badTemplate, + slides: [{ title: "Test" }], + }); + const parsed = JSON.parse(result); + assert.strictEqual(parsed.ok, false); + assert.ok(parsed.error?.includes("not a valid PPTX")); + await rm(badTemplate, { force: true }); + }); + + it("generates a valid PPTX file (ZIP structure)", async () => { + const outputPath = join(TMP_DIR, "valid.pptx"); + await createPptx({ + outputPath, + slides: [ + { + layout: "title", + title: "Valid PPTX", + subtitle: "Generated by tests", + }, + ], + }); + + const data = await readFile(outputPath); + // ZIP magic bytes + assert.strictEqual(data[0], 0x50); + assert.strictEqual(data[1], 0x4b); + // Should contain [Content_Types].xml + const content = data.toString("utf-8"); + assert.ok(content.includes("[Content_Types]")); + }); + + it("supports custom slide dimensions", async () => { + const outputPath = join(TMP_DIR, "custom-dims.pptx"); + const result = await createPptx({ + outputPath, + slideWidth: 10, + slideHeight: 8, + slides: [{ title: "Custom" }], + }); + const parsed = JSON.parse(result); + assert.strictEqual(parsed.ok, true); + }); + + it("handles text with formatting options", async () => { + const outputPath = join(TMP_DIR, "formatted.pptx"); + const result = await createPptx({ + outputPath, + slides: [ + { + title: "Formatted", + content: "Bold line\nItalic line", + }, + ], + }); + const parsed = JSON.parse(result); + assert.strictEqual(parsed.ok, true); + }); + + it("handles quote layout", async () => { + const outputPath = join(TMP_DIR, "quote.pptx"); + const result = await createPptx({ + outputPath, + slides: [ + { + layout: "quote", + quote: "To be or not to be", + quoteAttribution: "Hamlet", + }, + ], + }); + const parsed = JSON.parse(result); + assert.strictEqual(parsed.ok, true); + }); + + it("handles two-column layout", async () => { + const outputPath = join(TMP_DIR, "two-column.pptx"); + const result = await createPptx({ + outputPath, + slides: [ + { + layout: "two-column", + title: "Two Columns", + content: "Left content\nRight content", + }, + ], + }); + const parsed = JSON.parse(result); + assert.strictEqual(parsed.ok, true); + }); + + it("handles comparison layout", async () => { + const outputPath = join(TMP_DIR, "comparison.pptx"); + const result = await createPptx({ + outputPath, + slides: [ + { + layout: "comparison", + title: "Comparison", + content: "Before\nAfter", + }, + ], + }); + const parsed = JSON.parse(result); + assert.strictEqual(parsed.ok, true); + }); +}); From 6313be6d2c805477cbc24d62b8b8dac9ac0f6e27 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 23 Aug 2026 21:47:05 -0400 Subject: [PATCH 3/3] docs: archive presentation-creation-tool change Move OpenSpec change to archive after implementation complete. --- .../2025-08-23-presentation-creation-tool}/.openspec.yaml | 0 .../2025-08-23-presentation-creation-tool}/design.md | 0 .../2025-08-23-presentation-creation-tool}/proposal.md | 0 .../specs/pptx-creation/spec.md | 0 .../2025-08-23-presentation-creation-tool}/tasks.md | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename openspec/changes/{presentation-creation-tool => archive/2025-08-23-presentation-creation-tool}/.openspec.yaml (100%) rename openspec/changes/{presentation-creation-tool => archive/2025-08-23-presentation-creation-tool}/design.md (100%) rename openspec/changes/{presentation-creation-tool => archive/2025-08-23-presentation-creation-tool}/proposal.md (100%) rename openspec/changes/{presentation-creation-tool => archive/2025-08-23-presentation-creation-tool}/specs/pptx-creation/spec.md (100%) rename openspec/changes/{presentation-creation-tool => archive/2025-08-23-presentation-creation-tool}/tasks.md (100%) diff --git a/openspec/changes/presentation-creation-tool/.openspec.yaml b/openspec/changes/archive/2025-08-23-presentation-creation-tool/.openspec.yaml similarity index 100% rename from openspec/changes/presentation-creation-tool/.openspec.yaml rename to openspec/changes/archive/2025-08-23-presentation-creation-tool/.openspec.yaml diff --git a/openspec/changes/presentation-creation-tool/design.md b/openspec/changes/archive/2025-08-23-presentation-creation-tool/design.md similarity index 100% rename from openspec/changes/presentation-creation-tool/design.md rename to openspec/changes/archive/2025-08-23-presentation-creation-tool/design.md diff --git a/openspec/changes/presentation-creation-tool/proposal.md b/openspec/changes/archive/2025-08-23-presentation-creation-tool/proposal.md similarity index 100% rename from openspec/changes/presentation-creation-tool/proposal.md rename to openspec/changes/archive/2025-08-23-presentation-creation-tool/proposal.md diff --git a/openspec/changes/presentation-creation-tool/specs/pptx-creation/spec.md b/openspec/changes/archive/2025-08-23-presentation-creation-tool/specs/pptx-creation/spec.md similarity index 100% rename from openspec/changes/presentation-creation-tool/specs/pptx-creation/spec.md rename to openspec/changes/archive/2025-08-23-presentation-creation-tool/specs/pptx-creation/spec.md diff --git a/openspec/changes/presentation-creation-tool/tasks.md b/openspec/changes/archive/2025-08-23-presentation-creation-tool/tasks.md similarity index 100% rename from openspec/changes/presentation-creation-tool/tasks.md rename to openspec/changes/archive/2025-08-23-presentation-creation-tool/tasks.md