
Pptx Generator
- 5 installs
- Updated January 23, 2026
- jwynia/teach
Helps with ai & agent building tasks.
About
pptx-generator is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- pptx-generator
- AI & Agent Building
- AI-coding skill
Pptx Generator by the numbers
- 5 all-time installs (skills.sh)
- +2 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #13,065 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/jwynia/teach --skill pptx-generatorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 5 |
|---|---|
| Last updated | January 23, 2026 |
| Repository | jwynia/teach ↗ |
What it does
Helps with ai & agent building tasks.
Files
PPTX Generator
When to Use This Skill
Use this skill when:
- Creating presentations programmatically from data or specifications
- Populating branded templates with dynamic content while preserving corporate styling
- Extracting text and structure from existing PPTX files for analysis
- Combining slides from a library of approved templates
- Automating presentation generation workflows
Do NOT use this skill when:
- User wants to open/view presentations (use native PowerPoint or viewer)
- Complex animations or transitions are required (limited support)
- Working with older .ppt format (PPTX only)
Prerequisites
- Deno installed (https://deno.land/)
- Input PPTX files for template-based operations
- JSON specification for scratch generation
Quick Start
Two Modes of Operation
1. Template Mode: Modify existing branded templates
- Analyze & Replace: Find
{{PLACEHOLDERS}}and replace with content - Slide Library: Select and combine slides from a template library
2. Scratch Mode: Create presentations from nothing using JSON specifications
Instructions
Mode 1: Template-Based Generation
Step 1a: Analyze the Template
Extract text inventory to understand what can be replaced:
deno run --allow-read scripts/analyze-template.ts corporate-template.pptx > inventory.jsonOutput (inventory.json):
{
"filename": "corporate-template.pptx",
"slideCount": 10,
"textElements": [
{
"slideNumber": 1,
"shapeId": "shape-2",
"shapeName": "Title 1",
"placeholderType": "ctrTitle",
"position": { "x": 1.5, "y": 2.0, "w": 7.0, "h": 1.2 },
"paragraphs": [
{ "text": "{{TITLE}}", "fontSize": 44, "bold": true }
]
}
]
}Step 1b: Create Replacement Specification
Create replacements.json:
{
"textReplacements": [
{ "tag": "{{TITLE}}", "value": "Q4 2024 Results" },
{ "tag": "{{SUBTITLE}}", "value": "Financial Overview" },
{ "tag": "{{DATE}}", "value": "December 2024" },
{ "tag": "{{AUTHOR}}", "value": "Finance Team", "slideNumbers": [1] }
]
}Step 1c: Generate Output
deno run --allow-read --allow-write scripts/generate-from-template.ts \
corporate-template.pptx replacements.json output.pptxMode 1 (Alternative): Slide Library
Step 2a: Preview Template Slides
Get information about available slides:
deno run --allow-read scripts/generate-thumbnails.ts slide-library.pptxFor visual preview, extract the thumbnail:
deno run --allow-read --allow-write scripts/generate-thumbnails.ts \
slide-library.pptx --extract-thumb --output-dir ./previewsStep 2b: Select and Combine Slides
Create selections.json:
{
"slideSelections": [
{ "slideNumber": 1 },
{ "slideNumber": 5 },
{ "slideNumber": 12 },
{ "slideNumber": 3 }
],
"textReplacements": [
{ "tag": "{{TITLE}}", "value": "Custom Presentation" }
]
}Step 2c: Generate Combined Presentation
deno run --allow-read --allow-write scripts/generate-from-template.ts \
slide-library.pptx selections.json custom-deck.pptxMode 2: From-Scratch Generation
Step 3a: Create Specification
Create spec.json:
{
"title": "Product Launch 2025",
"author": "Marketing Team",
"slides": [
{
"background": { "color": "003366" },
"elements": [
{
"type": "text",
"x": 1, "y": 2.5, "w": 8, "h": 1.5,
"options": {
"text": "Product Launch 2025",
"fontSize": 44,
"bold": true,
"color": "FFFFFF",
"align": "center"
}
},
{
"type": "text",
"x": 1, "y": 4, "w": 8, "h": 0.5,
"options": {
"text": "Revolutionizing the Industry",
"fontSize": 24,
"color": "CCCCCC",
"align": "center"
}
}
]
},
{
"elements": [
{
"type": "text",
"x": 0.5, "y": 0.5, "w": 9, "h": 0.7,
"options": {
"text": "Key Features",
"fontSize": 32,
"bold": true,
"color": "003366"
}
},
{
"type": "table",
"x": 0.5, "y": 1.5, "w": 9, "h": 3,
"options": {
"rows": [
["Feature", "Description", "Benefit"],
["Speed", "2x faster processing", "Save time"],
["Quality", "HD output", "Better results"],
["Integration", "Works with existing tools", "Easy adoption"]
],
"border": { "pt": 1, "color": "CCCCCC" }
}
}
]
}
]
}Step 3b: Generate Presentation
deno run --allow-read --allow-write scripts/generate-scratch.ts spec.json output.pptxExamples
Example 1: Corporate Quarterly Report
Scenario: Generate quarterly report from branded template.
Steps:
# 1. Analyze template for replaceable content
deno run --allow-read scripts/analyze-template.ts quarterly-template.pptx --pretty
# 2. Create replacements.json with Q4 data
# 3. Generate report
deno run --allow-read --allow-write scripts/generate-from-template.ts \
quarterly-template.pptx replacements.json Q4-2024-Report.pptxExample 2: Custom Pitch Deck from Slide Library
Scenario: Combine approved slides for a specific client pitch.
Steps:
# 1. View available slides
deno run --allow-read scripts/generate-thumbnails.ts pitch-library.pptx
# 2. Create selections.json picking slides 1, 3, 7, 12, 15
# 3. Generate custom deck
deno run --allow-read --allow-write scripts/generate-from-template.ts \
pitch-library.pptx selections.json acme-pitch.pptxExample 3: Data-Driven Presentation
Scenario: Generate presentation from JSON data (e.g., API response).
Steps:
# 1. Transform your data into spec.json format
# 2. Generate presentation
deno run --allow-read --allow-write scripts/generate-scratch.ts data-spec.json report.pptxScript Reference
| Script | Purpose | Permissions |
|---|---|---|
analyze-template.ts | Extract text inventory from PPTX | --allow-read |
generate-thumbnails.ts | Get slide info and extract previews | --allow-read --allow-write |
generate-from-template.ts | Modify templates (replace/combine) | --allow-read --allow-write |
generate-scratch.ts | Create PPTX from JSON specification | --allow-read --allow-write |
Element Types (Scratch Mode)
| Type | Description | Key Options |
|---|---|---|
text | Text box | text, fontSize, bold, color, align |
image | Image from file or base64 | path, data, sizing |
table | Data table | rows, colW, border, fill |
shape | Geometric shapes | type, fill, line, text |
chart | Charts and graphs | type, data, title, showLegend |
Common Issues and Solutions
Issue: Text not being replaced
Symptoms: Output PPTX still contains {{PLACEHOLDER}} tags.
Solution: 1. Run analyze-template.ts to verify exact tag text 2. Tags may be split across XML runs - ensure your template has tags in single text runs 3. Check slideNumbers filter in replacements
Issue: Slide order incorrect
Symptoms: Slides appear in wrong order after combining.
Solution:
- Slides are added in the order specified in
slideSelections - Verify slide numbers match original template (1-indexed)
Issue: Images not appearing
Symptoms: Image elements are blank in output.
Solution: 1. Use absolute paths or paths relative to spec.json location 2. Verify image file exists and is readable 3. Check supported formats: PNG, JPEG, GIF
OOXML Placeholder Inheritance (Advanced)
Understanding how PowerPoint's OOXML format handles placeholders is crucial for template development.
The Inheritance Chain
PowerPoint uses a hierarchical inheritance model:
Theme → Slide Master → Slide Layout → Slide- Theme: Defines colors, fonts, effects
- Slide Master: Defines default placeholder positions and formatting (including bullets)
- Slide Layout: Overrides master settings for specific layout types (e.g., Title Slide, Content)
- Slide: Contains actual content, inherits formatting from layout
Key Principles
1. Text Content Does NOT Inherit: Slides must contain their own text content. The {{placeholder}} text in a layout does NOT automatically appear on slides using that layout.
2. Text Formatting CAN Inherit: When a slide shape has an empty <a:lstStyle/>, it inherits formatting (color, size, bullets) from the layout's <a:lstStyle>.
3. Placeholder Linking: Slides link to layouts via <p:ph type="..." idx="..."/>. The type (e.g., "title", "body", "ctrTitle") and idx must match.
4. Bullet Suppression: To prevent bullets on a placeholder that would normally inherit them from the master's bodyStyle, add <a:buNone/> in the layout's lstStyle.
Defining Inheritable Formatting
In layout placeholders, define colors in <a:lstStyle> (inheritable), not in <a:rPr> (run-specific):
<!-- Layout: Color in lstStyle (GOOD - inheritable) -->
<p:txBody>
<a:lstStyle>
<a:lvl1pPr algn="ctr">
<a:buNone/> <!-- Suppress bullets -->
<a:defRPr sz="4400" b="1">
<a:solidFill><a:srgbClr val="FFFFFF"/></a:solidFill>
</a:defRPr>
</a:lvl1pPr>
</a:lstStyle>
<a:p>
<a:r><a:rPr lang="en-US"/><a:t>{{placeholder}}</a:t></a:r>
</a:p>
</p:txBody>Slide Shape Structure
For slides to properly inherit from layouts:
<!-- Slide: Empty lstStyle to inherit from layout -->
<p:sp>
<p:nvSpPr>
<p:cNvPr id="2" name="title 2"/>
<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>
<p:nvPr>
<p:ph type="ctrTitle"/> <!-- Links to layout placeholder -->
</p:nvPr>
</p:nvSpPr>
<p:spPr/> <!-- Empty = inherit position from layout -->
<p:txBody>
<a:bodyPr/>
<a:lstStyle/> <!-- Empty = inherit formatting from layout -->
<a:p>
<a:r>
<a:rPr lang="en-US"/> <!-- Empty = inherit character formatting -->
<a:t>{{placeholder}}</a:t> <!-- Content must be here -->
</a:r>
</a:p>
</p:txBody>
</p:sp>Common Issues
| Issue | Cause | Solution |
|---|---|---|
| Text shows as black instead of white | Color defined in <a:rPr> not <a:lstStyle> | Move color to layout's <a:defRPr> in <a:lstStyle> |
| Unwanted bullets appearing | Master's bodyStyle has bullets, layout doesn't override | Add <a:buNone/> to layout's <a:lvl1pPr> |
| Placeholder text not appearing | Text only in layout, not in slide | Include text content in slide's <p:txBody> |
| Formatting not applying | Slide has explicit formatting | Use empty <a:lstStyle/> and <a:rPr lang="en-US"/> |
Reference: Placeholder Types
| Type | Usage |
|---|---|
ctrTitle | Centered title (title slides) |
title | Standard title |
subTitle | Subtitle |
body | Content area (use idx for multiple) |
pic | Picture placeholder |
dt | Date/time |
ftr | Footer |
sldNum | Slide number |
Limitations
- No slide rendering: Cannot render slides to images directly (use LibreOffice for this)
- Limited animation support: Basic animations only in scratch mode
- No master slide editing: Template mode preserves but doesn't modify masters
- PPTX only: Does not support legacy .ppt format
- Text run splitting: Complex formatting in templates may split tags across XML elements
Related Skills
- pdf-generator: For creating PDF documents instead of presentations
- docx-generator: For creating Word documents
- xlsx-generator: For creating Excel spreadsheets
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PPTX Presentation Specification",
"description": "JSON schema for defining PowerPoint presentations to be generated from scratch",
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Presentation title (metadata)"
},
"subject": {
"type": "string",
"description": "Presentation subject (metadata)"
},
"author": {
"type": "string",
"description": "Author name (metadata)"
},
"company": {
"type": "string",
"description": "Company name (metadata)"
},
"layout": {
"type": "object",
"description": "Presentation dimensions",
"properties": {
"width": {
"type": "number",
"description": "Width in inches",
"default": 10
},
"height": {
"type": "number",
"description": "Height in inches",
"default": 5.625
}
}
},
"slides": {
"type": "array",
"description": "Array of slide specifications",
"items": {
"$ref": "#/definitions/SlideSpec"
},
"minItems": 1
}
},
"required": ["slides"],
"definitions": {
"SlideSpec": {
"type": "object",
"properties": {
"layout": {
"type": "string",
"enum": ["blank", "title", "titleAndContent", "section", "twoColumn"],
"description": "Slide layout type"
},
"background": {
"type": "object",
"properties": {
"color": {
"type": "string",
"pattern": "^[0-9A-Fa-f]{6}$",
"description": "Background color as 6-digit hex (no # prefix)"
},
"image": {
"type": "string",
"description": "Path to background image file"
}
}
},
"elements": {
"type": "array",
"items": {
"$ref": "#/definitions/ElementSpec"
}
}
},
"required": ["elements"]
},
"ElementSpec": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["text", "image", "table", "shape", "chart"],
"description": "Element type"
},
"x": {
"type": "number",
"description": "X position in inches"
},
"y": {
"type": "number",
"description": "Y position in inches"
},
"w": {
"type": "number",
"description": "Width in inches"
},
"h": {
"type": "number",
"description": "Height in inches"
},
"options": {
"oneOf": [
{ "$ref": "#/definitions/TextOptions" },
{ "$ref": "#/definitions/ImageOptions" },
{ "$ref": "#/definitions/TableOptions" },
{ "$ref": "#/definitions/ShapeOptions" },
{ "$ref": "#/definitions/ChartOptions" }
]
}
},
"required": ["type", "x", "y", "w", "h", "options"]
},
"TextOptions": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "Text content"
},
"fontSize": {
"type": "number",
"description": "Font size in points"
},
"fontFace": {
"type": "string",
"description": "Font family name"
},
"color": {
"type": "string",
"pattern": "^[0-9A-Fa-f]{6}$",
"description": "Text color as 6-digit hex"
},
"bold": {
"type": "boolean"
},
"italic": {
"type": "boolean"
},
"underline": {
"type": "boolean"
},
"align": {
"type": "string",
"enum": ["left", "center", "right", "justify"]
},
"valign": {
"type": "string",
"enum": ["top", "middle", "bottom"]
},
"bullet": {
"oneOf": [
{ "type": "boolean" },
{
"type": "object",
"properties": {
"type": { "type": "string" },
"code": { "type": "string" }
}
}
]
},
"paraSpaceAfter": {
"type": "number",
"description": "Space after paragraph in points"
},
"paraSpaceBefore": {
"type": "number",
"description": "Space before paragraph in points"
}
},
"required": ["text"]
},
"ImageOptions": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to image file"
},
"data": {
"type": "string",
"description": "Base64-encoded image data"
},
"sizing": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["contain", "cover", "crop"]
},
"w": { "type": "number" },
"h": { "type": "number" }
}
},
"hyperlink": {
"type": "object",
"properties": {
"url": { "type": "string", "format": "uri" }
}
}
}
},
"TableOptions": {
"type": "object",
"properties": {
"rows": {
"type": "array",
"items": {
"type": "array",
"items": {
"oneOf": [
{ "type": "string" },
{ "$ref": "#/definitions/TableCell" }
]
}
},
"description": "2D array of table cells"
},
"colW": {
"type": "array",
"items": { "type": "number" },
"description": "Column widths in inches"
},
"rowH": {
"type": "array",
"items": { "type": "number" },
"description": "Row heights in inches"
},
"border": {
"type": "object",
"properties": {
"pt": { "type": "number" },
"color": { "type": "string", "pattern": "^[0-9A-Fa-f]{6}$" }
}
},
"fill": {
"type": "string",
"pattern": "^[0-9A-Fa-f]{6}$"
},
"fontSize": { "type": "number" },
"fontFace": { "type": "string" },
"color": { "type": "string", "pattern": "^[0-9A-Fa-f]{6}$" },
"align": { "type": "string", "enum": ["left", "center", "right"] },
"valign": { "type": "string", "enum": ["top", "middle", "bottom"] }
},
"required": ["rows"]
},
"TableCell": {
"type": "object",
"properties": {
"text": { "type": "string" },
"options": {
"type": "object",
"properties": {
"bold": { "type": "boolean" },
"color": { "type": "string", "pattern": "^[0-9A-Fa-f]{6}$" },
"fill": { "type": "string", "pattern": "^[0-9A-Fa-f]{6}$" },
"fontSize": { "type": "number" },
"align": { "type": "string", "enum": ["left", "center", "right"] },
"valign": { "type": "string", "enum": ["top", "middle", "bottom"] },
"colspan": { "type": "integer", "minimum": 1 },
"rowspan": { "type": "integer", "minimum": 1 }
}
}
},
"required": ["text"]
},
"ShapeOptions": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["rect", "roundRect", "ellipse", "triangle", "line", "arrow", "star"]
},
"fill": { "type": "string", "pattern": "^[0-9A-Fa-f]{6}$" },
"line": {
"type": "object",
"properties": {
"color": { "type": "string", "pattern": "^[0-9A-Fa-f]{6}$" },
"width": { "type": "number" },
"dashType": { "type": "string" }
}
},
"text": { "type": "string" },
"fontSize": { "type": "number" },
"fontFace": { "type": "string" },
"color": { "type": "string", "pattern": "^[0-9A-Fa-f]{6}$" },
"align": { "type": "string", "enum": ["left", "center", "right"] },
"valign": { "type": "string", "enum": ["top", "middle", "bottom"] }
},
"required": ["type"]
},
"ChartOptions": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["bar", "line", "pie", "doughnut", "area", "scatter"]
},
"data": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"labels": { "type": "array", "items": { "type": "string" } },
"values": { "type": "array", "items": { "type": "number" } }
},
"required": ["name", "labels", "values"]
}
},
"title": { "type": "string" },
"showLegend": { "type": "boolean" },
"legendPos": { "type": "string", "enum": ["b", "l", "r", "t", "tr"] },
"showTitle": { "type": "boolean" },
"showValue": { "type": "boolean" },
"catAxisTitle": { "type": "string" },
"valAxisTitle": { "type": "string" }
},
"required": ["type", "data"]
}
}
}
{
"compilerOptions": {
"lib": ["dom", "deno.ns"]
},
"imports": {
"jszip": "npm:jszip@3.10.1",
"@xmldom/xmldom": "npm:@xmldom/xmldom@0.9.6"
}
}{
"version": "5",
"specifiers": {
"jsr:@std/cli@1.0.9": "1.0.9",
"jsr:@std/path@1.0.8": "1.0.8",
"npm:@xmldom/xmldom@0.8.10": "0.8.10",
"npm:@xmldom/xmldom@0.9.6": "0.9.6",
"npm:jszip@3.10.1": "3.10.1",
"npm:pptxgenjs@3.12.0": "3.12.0"
},
"jsr": {
"@std/cli@1.0.9": {
"integrity": "557e5865af000efbf3f737dcfea5b8ab86453594f4a9cd8d08c9fa83d8e3f3bc"
},
"@std/path@1.0.8": {
"integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be"
}
},
"npm": {
"@types/node@18.19.130": {
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
"dependencies": [
"undici-types"
]
},
"@xmldom/xmldom@0.8.10": {
"integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw=="
},
"@xmldom/xmldom@0.9.6": {
"integrity": "sha512-Su4xcxR0CPGwlDHNmVP09fqET9YxbyDXHaSob6JlBH7L6reTYaeim6zbk9o08UarO0L5GTRo3uzl0D+9lSxmvw=="
},
"core-util-is@1.0.3": {
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
},
"https@1.0.0": {
"integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg=="
},
"image-size@1.2.1": {
"integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
"dependencies": [
"queue"
],
"bin": true
},
"immediate@3.0.6": {
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
},
"inherits@2.0.4": {
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"isarray@1.0.0": {
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
},
"jszip@3.10.1": {
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"dependencies": [
"lie",
"pako",
"readable-stream",
"setimmediate"
]
},
"lie@3.3.0": {
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"dependencies": [
"immediate"
]
},
"pako@1.0.11": {
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
},
"pptxgenjs@3.12.0": {
"integrity": "sha512-ZozkYKWb1MoPR4ucw3/aFYlHkVIJxo9czikEclcUVnS4Iw/M+r+TEwdlB3fyAWO9JY1USxJDt0Y0/r15IR/RUA==",
"dependencies": [
"@types/node",
"https",
"image-size",
"jszip"
]
},
"process-nextick-args@2.0.1": {
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
},
"queue@6.0.2": {
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"dependencies": [
"inherits"
]
},
"readable-stream@2.3.8": {
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dependencies": [
"core-util-is",
"inherits",
"isarray",
"process-nextick-args",
"safe-buffer",
"string_decoder",
"util-deprecate"
]
},
"safe-buffer@5.1.2": {
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"setimmediate@1.0.5": {
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
},
"string_decoder@1.1.1": {
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dependencies": [
"safe-buffer"
]
},
"undici-types@5.26.5": {
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
},
"util-deprecate@1.0.2": {
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
}
},
"workspace": {
"dependencies": [
"npm:@xmldom/xmldom@0.9.6",
"npm:jszip@3.10.1"
]
}
}
PptxGenJS API Reference
This document covers the JSON specification format for scratch mode generation.
Presentation Specification
interface PresentationSpec {
title?: string; // Presentation title (metadata)
subject?: string; // Presentation subject (metadata)
author?: string; // Author name (metadata)
company?: string; // Company name (metadata)
layout?: {
width?: number; // Width in inches (default: 10)
height?: number; // Height in inches (default: 5.625 for 16:9)
};
slides: SlideSpec[];
}Slide Specification
interface SlideSpec {
layout?: 'blank' | 'title' | 'titleAndContent' | 'section' | 'twoColumn';
background?: {
color?: string; // Hex color without # (e.g., "003366")
image?: string; // Path to image file
};
elements: ElementSpec[];
}Element Specification
All elements share common positioning properties:
interface ElementSpec {
type: 'text' | 'image' | 'table' | 'shape' | 'chart';
x: number; // X position in inches from left edge
y: number; // Y position in inches from top edge
w: number; // Width in inches
h: number; // Height in inches
options: TextOptions | ImageOptions | TableOptions | ShapeOptions | ChartOptions;
}Text Element
interface TextOptions {
text: string; // The text content
fontSize?: number; // Font size in points
fontFace?: string; // Font family name
color?: string; // Hex color without #
bold?: boolean;
italic?: boolean;
underline?: boolean;
align?: 'left' | 'center' | 'right' | 'justify';
valign?: 'top' | 'middle' | 'bottom';
bullet?: boolean | {
type?: string; // Bullet type
code?: string; // Unicode bullet character
};
paraSpaceAfter?: number; // Space after paragraph in points
paraSpaceBefore?: number; // Space before paragraph in points
}Example:
{
"type": "text",
"x": 1, "y": 1, "w": 8, "h": 1,
"options": {
"text": "Hello World",
"fontSize": 32,
"bold": true,
"color": "003366",
"align": "center"
}
}Image Element
interface ImageOptions {
path?: string; // Path to image file (relative to spec.json)
data?: string; // Base64-encoded image data
sizing?: {
type: 'contain' | 'cover' | 'crop';
w?: number; // Target width
h?: number; // Target height
};
hyperlink?: {
url: string; // URL to link to
};
}Example (file path):
{
"type": "image",
"x": 1, "y": 2, "w": 4, "h": 3,
"options": {
"path": "./images/logo.png",
"sizing": { "type": "contain" }
}
}Example (base64):
{
"type": "image",
"x": 1, "y": 2, "w": 4, "h": 3,
"options": {
"data": "data:image/png;base64,iVBORw0KGgo..."
}
}Table Element
interface TableCell {
text: string;
options?: {
bold?: boolean;
color?: string;
fill?: string; // Background color
fontSize?: number;
align?: 'left' | 'center' | 'right';
valign?: 'top' | 'middle' | 'bottom';
colspan?: number;
rowspan?: number;
};
}
interface TableOptions {
rows: (string | TableCell)[][]; // 2D array of cells
colW?: number[]; // Column widths in inches
rowH?: number[]; // Row heights in inches
border?: {
pt?: number; // Border width in points
color?: string; // Border color
};
fill?: string; // Default cell background
fontSize?: number;
fontFace?: string;
color?: string; // Default text color
align?: 'left' | 'center' | 'right';
valign?: 'top' | 'middle' | 'bottom';
}Example (simple):
{
"type": "table",
"x": 0.5, "y": 1.5, "w": 9, "h": 3,
"options": {
"rows": [
["Header 1", "Header 2", "Header 3"],
["Row 1 Col 1", "Row 1 Col 2", "Row 1 Col 3"],
["Row 2 Col 1", "Row 2 Col 2", "Row 2 Col 3"]
],
"border": { "pt": 1, "color": "CCCCCC" }
}
}Example (styled cells):
{
"type": "table",
"x": 0.5, "y": 1.5, "w": 9, "h": 3,
"options": {
"rows": [
[
{ "text": "Header 1", "options": { "bold": true, "fill": "003366", "color": "FFFFFF" }},
{ "text": "Header 2", "options": { "bold": true, "fill": "003366", "color": "FFFFFF" }},
{ "text": "Header 3", "options": { "bold": true, "fill": "003366", "color": "FFFFFF" }}
],
["Data 1", "Data 2", "Data 3"]
],
"colW": [3, 3, 3]
}
}Shape Element
interface ShapeOptions {
type: 'rect' | 'roundRect' | 'ellipse' | 'triangle' | 'line' | 'arrow' | 'star';
fill?: string; // Fill color
line?: {
color?: string;
width?: number; // Line width in points
dashType?: string; // 'solid', 'dash', 'dot', etc.
};
text?: string; // Text inside shape
fontSize?: number;
fontFace?: string;
color?: string; // Text color
align?: 'left' | 'center' | 'right';
valign?: 'top' | 'middle' | 'bottom';
}Example:
{
"type": "shape",
"x": 1, "y": 1, "w": 3, "h": 2,
"options": {
"type": "roundRect",
"fill": "4472C4",
"text": "Click Here",
"color": "FFFFFF",
"align": "center",
"valign": "middle"
}
}Chart Element
interface ChartData {
name: string; // Series name
labels: string[]; // Category labels
values: number[]; // Data values
}
interface ChartOptions {
type: 'bar' | 'line' | 'pie' | 'doughnut' | 'area' | 'scatter';
data: ChartData[];
title?: string;
showLegend?: boolean;
legendPos?: 'b' | 'l' | 'r' | 't' | 'tr'; // bottom, left, right, top, top-right
showTitle?: boolean;
showValue?: boolean;
catAxisTitle?: string; // Category axis title
valAxisTitle?: string; // Value axis title
}Example (bar chart):
{
"type": "chart",
"x": 0.5, "y": 1.5, "w": 9, "h": 4,
"options": {
"type": "bar",
"title": "Quarterly Sales",
"showLegend": true,
"legendPos": "r",
"data": [
{
"name": "2023",
"labels": ["Q1", "Q2", "Q3", "Q4"],
"values": [100, 150, 180, 220]
},
{
"name": "2024",
"labels": ["Q1", "Q2", "Q3", "Q4"],
"values": [120, 175, 200, 250]
}
]
}
}Example (pie chart):
{
"type": "chart",
"x": 3, "y": 1.5, "w": 4, "h": 4,
"options": {
"type": "pie",
"title": "Market Share",
"showValue": true,
"data": [
{
"name": "Share",
"labels": ["Product A", "Product B", "Product C", "Other"],
"values": [35, 25, 20, 20]
}
]
}
}Color Values
Colors are specified as 6-character hex strings WITHOUT the # prefix:
"FFFFFF"- White"000000"- Black"003366"- Dark blue"4472C4"- Office blue"FF0000"- Red
Common Layouts
Title Slide
{
"background": { "color": "003366" },
"elements": [
{
"type": "text",
"x": 0.5, "y": 2, "w": 9, "h": 1.5,
"options": {
"text": "Presentation Title",
"fontSize": 44,
"bold": true,
"color": "FFFFFF",
"align": "center"
}
},
{
"type": "text",
"x": 0.5, "y": 3.5, "w": 9, "h": 0.5,
"options": {
"text": "Subtitle or Author",
"fontSize": 20,
"color": "CCCCCC",
"align": "center"
}
}
]
}Content Slide
{
"elements": [
{
"type": "text",
"x": 0.5, "y": 0.3, "w": 9, "h": 0.7,
"options": {
"text": "Slide Title",
"fontSize": 28,
"bold": true,
"color": "003366"
}
},
{
"type": "text",
"x": 0.5, "y": 1.2, "w": 9, "h": 4,
"options": {
"text": "• First bullet point\n• Second bullet point\n• Third bullet point",
"fontSize": 18,
"bullet": true
}
}
]
}Two-Column Layout
{
"elements": [
{
"type": "text",
"x": 0.5, "y": 0.3, "w": 9, "h": 0.7,
"options": { "text": "Title", "fontSize": 28, "bold": true }
},
{
"type": "text",
"x": 0.5, "y": 1.2, "w": 4, "h": 4,
"options": { "text": "Left column content", "fontSize": 16 }
},
{
"type": "text",
"x": 5, "y": 1.2, "w": 4.5, "h": 4,
"options": { "text": "Right column content", "fontSize": 16 }
}
]
}Template-Based PPTX Generation Workflow
This document provides detailed guidance for working with existing PPTX templates.
Overview
Template mode enables you to: 1. Preserve corporate branding and design standards 2. Replace placeholder content while maintaining formatting 3. Combine pre-approved slides into new presentations
Preparing Templates
Best Practices for Template Design
1. Use Clear Placeholder Tags: Use consistent format like {{PLACEHOLDER_NAME}}
- Keep tags in single text runs (don't format parts of the tag differently)
- Use uppercase for clarity:
{{TITLE}},{{AUTHOR}},{{DATE}}
2. Name Your Shapes: In PowerPoint, select a shape and use Selection Pane to give it a meaningful name
- This helps with debugging and targeted replacements
3. Use Placeholder Types: When possible, use PowerPoint's built-in placeholder types (Title, Subtitle, Content)
- These are easier to identify programmatically
- Placeholder shapes inherit formatting from the layout automatically
- Regular text boxes do NOT inherit - they must have explicit formatting
4. Understand the Inheritance Chain: PowerPoint uses a hierarchy:
- Theme → Slide Master → Slide Layout → Slide
- Text formatting (colors, sizes, bullets) flows down this chain
- Text CONTENT does not inherit - it must be in the slide itself
Analyze & Replace Workflow
Step 1: Analyze the Template
deno run --allow-read scripts/analyze-template.ts template.pptx --pretty > inventory.jsonThe inventory JSON contains:
filename: Original template nameslideCount: Total number of slidesslideWidth/slideHeight: Dimensions in inchestextElements: Array of all text shapes with:slideNumber: Which slide (1-indexed)shapeId: Unique identifiershapeName: PowerPoint shape nameplaceholderType: "title", "ctrTitle", "subTitle", "body", or nullposition: { x, y, width, height } in inchesparagraphs: Array of paragraph objects with text and formatting
Step 2: Create Replacement Specification
{
"textReplacements": [
{
"tag": "{{TITLE}}",
"value": "Replacement Text"
},
{
"tag": "{{DATE}}",
"value": "January 2025",
"slideNumbers": [1, 2]
}
]
}Fields:
tag: The text to find and replace (include{{}}if used in template)value: The replacement textslideNumbers(optional): Limit replacement to specific slides
Step 3: Generate Output
deno run --allow-read --allow-write scripts/generate-from-template.ts \
template.pptx replacements.json output.pptxSlide Library Workflow
Step 1: Preview Available Slides
# Get slide information
deno run --allow-read scripts/generate-thumbnails.ts library.pptx
# Extract presentation thumbnail
deno run --allow-read --allow-write scripts/generate-thumbnails.ts \
library.pptx --extract-thumb --output-dir ./previewsStep 2: Select Slides
Create a selection specification:
{
"slideSelections": [
{ "slideNumber": 1 },
{ "slideNumber": 5 },
{ "slideNumber": 3 },
{ "slideNumber": 12 }
]
}Notes:
- Slides are added to output in the order specified
- Same slide can be included multiple times
- Original slide numbers are preserved for reference
Step 3: Combine with Text Replacements
{
"slideSelections": [
{ "slideNumber": 1 },
{ "slideNumber": 5 }
],
"textReplacements": [
{ "tag": "{{CLIENT}}", "value": "Acme Corp" }
]
}Including/Excluding Slides
Instead of selecting specific slides, you can filter:
Include Only Specific Slides
{
"includeSlides": [1, 2, 5, 10]
}Exclude Specific Slides
{
"excludeSlides": [3, 4, 7]
}Troubleshooting
Tags Not Being Replaced
Cause: Text is split across multiple XML runs
PowerPoint may internally split text like:
<a:r><a:t>{{TI</a:t></a:r>
<a:r><a:t>TLE}}</a:t></a:r>Solutions: 1. Re-create the placeholder in PowerPoint by typing it fresh 2. Select all text in the shape and apply uniform formatting 3. Use simpler tag names without special characters
Formatting Lost After Replacement
Cause: The replacement process preserves paragraph-level formatting but may not preserve character-level formatting within runs.
Solution: Apply formatting at the paragraph level in your template, not to individual characters within the placeholder.
Text Shows Wrong Color (e.g., Black Instead of White)
Cause: Text color was defined in the text run properties (<a:rPr>) rather than in the layout's list style (<a:lstStyle>).
Solution: When creating templates programmatically: 1. Define text colors in the layout's <a:lstStyle>/<a:lvl1pPr>/<a:defRPr> element 2. Use empty <a:rPr lang="en-US"/> in the actual text runs so they inherit from lstStyle 3. Slides should have empty <a:lstStyle/> to inherit from the layout
Unwanted Bullet Points Appearing
Cause: The slide master's <p:bodyStyle> defines bullets by default for body placeholders.
Solution: Add <a:buNone/> inside <a:lvl1pPr> in the layout's placeholder definition to suppress bullets:
<a:lstStyle>
<a:lvl1pPr>
<a:buNone/> <!-- Suppresses bullets -->
<a:defRPr>...</a:defRPr>
</a:lvl1pPr>
</a:lstStyle>Placeholder Text Not Appearing on Slides
Cause: Text content in layouts does NOT automatically appear on slides using that layout. In OOXML, only formatting inherits, not content.
Solution: Ensure the slide XML includes the placeholder text:
- Slides must have their own
<p:txBody>with the text content - The
{{PLACEHOLDER}}text must be in the slide, not just the layout - Use
generate-template-proper.tswhich handles this automatically
Wrong Slides Selected
Cause: Slide numbering mismatch
Solution: 1. Run generate-thumbnails.ts to verify slide numbers 2. Remember slides are 1-indexed 3. Hidden slides are still counted
Advanced: Multiple Source Templates
For combining slides from different templates:
{
"slideSelections": [
{ "slideNumber": 1 },
{ "sourceTemplate": "./other-template.pptx", "slideNumber": 3 }
]
}Note: This feature requires slides to have compatible layouts and masters. Cross-template slide combination works best with templates from the same design family.
#!/usr/bin/env -S deno run --allow-read
/**
* analyze-template.ts - Extract text inventory from PPTX templates
*
* Extracts all text shapes, positions, and content from a PowerPoint file
* for template analysis and content replacement planning.
*
* Usage:
* deno run --allow-read scripts/analyze-template.ts <input.pptx> [options]
*
* Options:
* -h, --help Show help
* -v, --verbose Enable verbose output
* --slide <n> Only analyze specific slide number (1-indexed)
* --json Output as JSON (default)
* --pretty Pretty-print JSON output
*
* Permissions:
* --allow-read: Read PPTX file
*/
import { parseArgs } from "jsr:@std/cli@1.0.9/parse-args";
import { basename } from "jsr:@std/path@1.0.8";
import JSZip from "npm:jszip@3.10.1";
import { DOMParser } from "npm:@xmldom/xmldom@0.9.6";
// === Types ===
export interface Position {
x: number; // inches
y: number; // inches
width: number; // inches
height: number; // inches
}
export interface Paragraph {
text: string;
bullet: boolean;
level: number;
alignment?: "left" | "center" | "right" | "justify";
fontSize?: number; // points
fontFamily?: string;
bold?: boolean;
italic?: boolean;
underline?: boolean;
color?: string; // hex RGB
}
export interface TextElement {
slideNumber: number;
shapeId: string;
shapeName: string;
placeholderType?: string;
position: Position;
paragraphs: Paragraph[];
}
export interface ImageElement {
slideNumber: number;
shapeId: string;
position: Position;
relationshipId: string;
filename?: string;
}
export interface TemplateInventory {
filename: string;
slideCount: number;
slideWidth: number; // inches
slideHeight: number; // inches
textElements: TextElement[];
images: ImageElement[];
}
interface ParsedArgs {
help: boolean;
verbose: boolean;
slide?: number;
json: boolean;
pretty: boolean;
_: (string | number)[];
}
// === Constants ===
const VERSION = "1.0.0";
const SCRIPT_NAME = "analyze-template";
// EMU (English Metric Units) to inches conversion
const EMU_PER_INCH = 914400;
// === Help Text ===
function printHelp(): void {
console.log(`
${SCRIPT_NAME} v${VERSION} - Extract text inventory from PPTX templates
Usage:
deno run --allow-read scripts/${SCRIPT_NAME}.ts <input.pptx> [options]
Arguments:
<input.pptx> Path to the PowerPoint file to analyze
Options:
-h, --help Show this help message
-v, --verbose Enable verbose output (to stderr)
--slide <n> Only analyze specific slide number (1-indexed)
--pretty Pretty-print JSON output (default: compact)
Examples:
# Analyze entire presentation
deno run --allow-read scripts/${SCRIPT_NAME}.ts template.pptx > inventory.json
# Analyze specific slide with verbose output
deno run --allow-read scripts/${SCRIPT_NAME}.ts template.pptx --slide 1 -v --pretty
# Pipe to jq for further processing
deno run --allow-read scripts/${SCRIPT_NAME}.ts template.pptx | jq '.textElements'
`);
}
// === Utility Functions ===
function emuToInches(emu: number): number {
return Math.round((emu / EMU_PER_INCH) * 1000) / 1000;
}
// deno-lint-ignore no-explicit-any
function getAttr(element: any, name: string): string | null {
return element.getAttribute(name);
}
// deno-lint-ignore no-explicit-any
function getNumAttr(element: any, name: string): number | null {
const val = element.getAttribute(name);
return val ? parseInt(val, 10) : null;
}
// deno-lint-ignore no-explicit-any
function getElementsByTagNameNS(
parent: any,
ns: string,
localName: string
// deno-lint-ignore no-explicit-any
): any[] {
const elements = parent.getElementsByTagNameNS(ns, localName);
// deno-lint-ignore no-explicit-any
return Array.from(elements) as any[];
}
// === XML Namespaces ===
const NS = {
a: "http://schemas.openxmlformats.org/drawingml/2006/main",
p: "http://schemas.openxmlformats.org/presentationml/2006/main",
r: "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
};
// === Core Logic ===
// deno-lint-ignore no-explicit-any
function parsePosition(spElement: any): Position | null {
// Find xfrm element (transform) - can be in spPr or within the shape
const xfrmElements = getElementsByTagNameNS(spElement, NS.a, "xfrm");
if (xfrmElements.length === 0) return null;
const xfrm = xfrmElements[0];
const off = getElementsByTagNameNS(xfrm, NS.a, "off")[0];
const ext = getElementsByTagNameNS(xfrm, NS.a, "ext")[0];
if (!off || !ext) return null;
const x = getNumAttr(off, "x");
const y = getNumAttr(off, "y");
const cx = getNumAttr(ext, "cx");
const cy = getNumAttr(ext, "cy");
if (x === null || y === null || cx === null || cy === null) return null;
return {
x: emuToInches(x),
y: emuToInches(y),
width: emuToInches(cx),
height: emuToInches(cy),
};
}
// deno-lint-ignore no-explicit-any
function parseTextRun(rElement: any): { text: string; props: Partial<Paragraph> } {
const props: Partial<Paragraph> = {};
// Get text content
const tElements = getElementsByTagNameNS(rElement, NS.a, "t");
const text = tElements.map((t) => t.textContent || "").join("");
// Get run properties
const rPrElements = getElementsByTagNameNS(rElement, NS.a, "rPr");
if (rPrElements.length > 0) {
const rPr = rPrElements[0];
// Font size (in hundredths of a point)
const sz = getNumAttr(rPr, "sz");
if (sz) props.fontSize = sz / 100;
// Bold
const b = getAttr(rPr, "b");
if (b === "1" || b === "true") props.bold = true;
// Italic
const i = getAttr(rPr, "i");
if (i === "1" || i === "true") props.italic = true;
// Underline
const u = getAttr(rPr, "u");
if (u && u !== "none") props.underline = true;
// Font family
const latin = getElementsByTagNameNS(rPr, NS.a, "latin")[0];
if (latin) {
const typeface = getAttr(latin, "typeface");
if (typeface) props.fontFamily = typeface;
}
// Color
const srgbClr = getElementsByTagNameNS(rPr, NS.a, "srgbClr")[0];
if (srgbClr) {
const val = getAttr(srgbClr, "val");
if (val) props.color = val;
}
}
return { text, props };
}
// deno-lint-ignore no-explicit-any
function parseParagraph(pElement: any): Paragraph {
const paragraph: Paragraph = {
text: "",
bullet: false,
level: 0,
};
// Get paragraph properties
const pPrElements = getElementsByTagNameNS(pElement, NS.a, "pPr");
if (pPrElements.length > 0) {
const pPr = pPrElements[0];
// Indentation level
const lvl = getNumAttr(pPr, "lvl");
if (lvl !== null) paragraph.level = lvl;
// Alignment
const algn = getAttr(pPr, "algn");
if (algn) {
const alignMap: Record<string, Paragraph["alignment"]> = {
l: "left",
ctr: "center",
r: "right",
just: "justify",
};
paragraph.alignment = alignMap[algn] || "left";
}
// Check for bullet
const buNone = getElementsByTagNameNS(pPr, NS.a, "buNone");
const buChar = getElementsByTagNameNS(pPr, NS.a, "buChar");
const buAutoNum = getElementsByTagNameNS(pPr, NS.a, "buAutoNum");
paragraph.bullet = buNone.length === 0 && (buChar.length > 0 || buAutoNum.length > 0);
}
// Get text runs
const runs = getElementsByTagNameNS(pElement, NS.a, "r");
const textParts: string[] = [];
let lastProps: Partial<Paragraph> = {};
for (const run of runs) {
const { text, props } = parseTextRun(run);
textParts.push(text);
// Use properties from first run with non-empty text
if (text.trim() && Object.keys(lastProps).length === 0) {
lastProps = props;
}
}
paragraph.text = textParts.join("");
Object.assign(paragraph, lastProps);
return paragraph;
}
// deno-lint-ignore no-explicit-any
function parseShape(
spElement: any,
slideNumber: number
): TextElement | null {
// Get shape ID and name from nvSpPr
const nvSpPr = getElementsByTagNameNS(spElement, NS.p, "nvSpPr")[0];
if (!nvSpPr) return null;
const cNvPr = getElementsByTagNameNS(nvSpPr, NS.p, "cNvPr")[0];
if (!cNvPr) return null;
const shapeId = getAttr(cNvPr, "id") || "unknown";
const shapeName = getAttr(cNvPr, "name") || "";
// Check for placeholder type
let placeholderType: string | undefined;
const nvPr = getElementsByTagNameNS(nvSpPr, NS.p, "nvPr")[0];
if (nvPr) {
const ph = getElementsByTagNameNS(nvPr, NS.p, "ph")[0];
if (ph) {
placeholderType = getAttr(ph, "type") || "body";
}
}
// Get position
const position = parsePosition(spElement);
if (!position) return null;
// Get text body
const txBody = getElementsByTagNameNS(spElement, NS.p, "txBody")[0];
if (!txBody) return null;
// Parse paragraphs
const pElements = getElementsByTagNameNS(txBody, NS.a, "p");
const paragraphs: Paragraph[] = [];
for (const p of pElements) {
const paragraph = parseParagraph(p);
// Only include paragraphs with actual text
if (paragraph.text.trim()) {
paragraphs.push(paragraph);
}
}
// Skip shapes with no text
if (paragraphs.length === 0) return null;
return {
slideNumber,
shapeId: `shape-${shapeId}`,
shapeName,
placeholderType,
position,
paragraphs,
};
}
// deno-lint-ignore no-explicit-any
function parsePicture(
picElement: any,
slideNumber: number
): ImageElement | null {
// Get picture ID from nvPicPr
const nvPicPr = getElementsByTagNameNS(picElement, NS.p, "nvPicPr")[0];
if (!nvPicPr) return null;
const cNvPr = getElementsByTagNameNS(nvPicPr, NS.p, "cNvPr")[0];
if (!cNvPr) return null;
const shapeId = getAttr(cNvPr, "id") || "unknown";
// Get position
const position = parsePosition(picElement);
if (!position) return null;
// Get relationship ID for the image
const blipFill = getElementsByTagNameNS(picElement, NS.p, "blipFill")[0];
if (!blipFill) return null;
const blip = getElementsByTagNameNS(blipFill, NS.a, "blip")[0];
if (!blip) return null;
const relationshipId = blip.getAttributeNS(NS.r, "embed") || "";
return {
slideNumber,
shapeId: `image-${shapeId}`,
position,
relationshipId,
};
}
function parseSlide(
slideXml: string,
slideNumber: number
): { textElements: TextElement[]; images: ImageElement[] } {
const parser = new DOMParser();
const doc = parser.parseFromString(slideXml, "text/xml");
const textElements: TextElement[] = [];
const images: ImageElement[] = [];
// Parse shapes (sp elements)
const shapes = getElementsByTagNameNS(doc, NS.p, "sp");
for (const shape of shapes) {
const textElement = parseShape(shape, slideNumber);
if (textElement) {
textElements.push(textElement);
}
}
// Parse pictures (pic elements)
const pictures = getElementsByTagNameNS(doc, NS.p, "pic");
for (const pic of pictures) {
const imageElement = parsePicture(pic, slideNumber);
if (imageElement) {
images.push(imageElement);
}
}
// Sort by position (top to bottom, left to right)
textElements.sort((a, b) => {
const yDiff = a.position.y - b.position.y;
if (Math.abs(yDiff) > 0.5) return yDiff; // Different rows
return a.position.x - b.position.x; // Same row, sort by x
});
return { textElements, images };
}
function parsePresentationSize(
presentationXml: string
): { width: number; height: number } {
const parser = new DOMParser();
const doc = parser.parseFromString(presentationXml, "text/xml");
const sldSz = getElementsByTagNameNS(doc, NS.p, "sldSz")[0];
if (!sldSz) {
// Default to 16:9
return { width: 10, height: 5.625 };
}
const cx = getNumAttr(sldSz, "cx") || 9144000; // Default 10"
const cy = getNumAttr(sldSz, "cy") || 5143500; // Default 5.625"
return {
width: emuToInches(cx),
height: emuToInches(cy),
};
}
export async function analyzeTemplate(
pptxPath: string,
options: { verbose?: boolean; slideNumber?: number } = {}
): Promise<TemplateInventory> {
const { verbose = false, slideNumber } = options;
// Read the PPTX file
const data = await Deno.readFile(pptxPath);
const zip = await JSZip.loadAsync(data);
// Get presentation size
const presentationFile = zip.file("ppt/presentation.xml");
let slideWidth = 10;
let slideHeight = 5.625;
if (presentationFile) {
const presentationXml = await presentationFile.async("string");
const size = parsePresentationSize(presentationXml);
slideWidth = size.width;
slideHeight = size.height;
}
// Find all slide files
const slideFiles: string[] = [];
zip.forEach((relativePath) => {
const match = relativePath.match(/^ppt\/slides\/slide(\d+)\.xml$/);
if (match) {
slideFiles.push(relativePath);
}
});
// Sort slides by number
slideFiles.sort((a, b) => {
const numA = parseInt(a.match(/slide(\d+)\.xml$/)?.[1] || "0", 10);
const numB = parseInt(b.match(/slide(\d+)\.xml$/)?.[1] || "0", 10);
return numA - numB;
});
if (verbose) {
console.error(`Found ${slideFiles.length} slides`);
}
const allTextElements: TextElement[] = [];
const allImages: ImageElement[] = [];
for (let i = 0; i < slideFiles.length; i++) {
const slideNum = i + 1;
// Skip if filtering to specific slide
if (slideNumber !== undefined && slideNum !== slideNumber) {
continue;
}
const slideFile = zip.file(slideFiles[i]);
if (!slideFile) continue;
const slideXml = await slideFile.async("string");
const { textElements, images } = parseSlide(slideXml, slideNum);
if (verbose) {
console.error(
`Slide ${slideNum}: ${textElements.length} text elements, ${images.length} images`
);
}
allTextElements.push(...textElements);
allImages.push(...images);
}
return {
filename: basename(pptxPath),
slideCount: slideFiles.length,
slideWidth,
slideHeight,
textElements: allTextElements,
images: allImages,
};
}
// === Main CLI Handler ===
async function main(args: string[]): Promise<void> {
const parsed = parseArgs(args, {
boolean: ["help", "verbose", "json", "pretty"],
string: ["slide"],
alias: { help: "h", verbose: "v" },
default: { verbose: false, json: true, pretty: false },
}) as ParsedArgs;
if (parsed.help) {
printHelp();
Deno.exit(0);
}
const positionalArgs = parsed._.map(String);
if (positionalArgs.length === 0) {
console.error("Error: No input file provided\n");
printHelp();
Deno.exit(1);
}
const inputPath = positionalArgs[0];
const slideNumber = parsed.slide ? parseInt(parsed.slide as unknown as string, 10) : undefined;
try {
const inventory = await analyzeTemplate(inputPath, {
verbose: parsed.verbose,
slideNumber,
});
// Output as JSON
const output = parsed.pretty
? JSON.stringify(inventory, null, 2)
: JSON.stringify(inventory);
console.log(output);
} catch (error) {
console.error(
"Error:",
error instanceof Error ? error.message : String(error)
);
Deno.exit(1);
}
}
// === Entry Point ===
if (import.meta.main) {
main(Deno.args);
}
#!/usr/bin/env -S deno run --allow-read --allow-write
/**
* generate-from-template.ts - Generate PPTX from existing templates
*
* Modifies existing PowerPoint templates using two patterns:
* 1. Analyze & Replace: Find and replace tagged content (e.g., {{TITLE}})
* 2. Slide Library: Select and combine slides from template into new presentation
*
* Usage:
* deno run --allow-read --allow-write scripts/generate-from-template.ts <template.pptx> <spec.json> <output.pptx>
*
* Options:
* -h, --help Show help
* -v, --verbose Enable verbose output
*
* Permissions:
* --allow-read: Read template and specification files
* --allow-write: Write output PPTX file
*/
import { parseArgs } from "jsr:@std/cli@1.0.9/parse-args";
import { basename } from "jsr:@std/path@1.0.8";
import JSZip from "npm:jszip@3.10.1";
import { DOMParser, XMLSerializer } from "npm:@xmldom/xmldom@0.9.6";
// === Types ===
export interface TextReplacement {
/** The tag to find and replace (e.g., "{{TITLE}}" or just "TITLE") */
tag: string;
/** The replacement text */
value: string;
/** Optional: only apply to specific slides (1-indexed) */
slideNumbers?: number[];
}
export interface SlideSelection {
/** Path to source template (can be same as master or different) */
sourceTemplate?: string;
/** Which slide to copy (1-indexed) */
slideNumber: number;
/** Position in output (1-indexed, appends if omitted) */
insertAt?: number;
}
export interface SlideNotes {
/** Slide number (1-indexed) */
slideNumber: number;
/** Speaker notes text (verbatim transcript) */
notes: string;
}
export interface TemplateSpec {
/** Path to the master template file (can be overridden by CLI) */
masterTemplate?: string;
/** Text replacements to apply */
textReplacements?: TextReplacement[];
/** Slides to select and combine (slide library mode) */
slideSelections?: SlideSelection[];
/** Which slides from master to include (1-indexed, all if omitted) */
includeSlides?: number[];
/** Which slides to exclude from master (1-indexed) */
excludeSlides?: number[];
/** Speaker notes for slides (verbatim transcript text) */
slideNotes?: SlideNotes[];
}
interface ParsedArgs {
help: boolean;
verbose: boolean;
_: (string | number)[];
}
// === Constants ===
const VERSION = "1.0.0";
const SCRIPT_NAME = "generate-from-template";
// XML Namespaces
const NS = {
a: "http://schemas.openxmlformats.org/drawingml/2006/main",
p: "http://schemas.openxmlformats.org/presentationml/2006/main",
r: "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
rel: "http://schemas.openxmlformats.org/package/2006/relationships",
ct: "http://schemas.openxmlformats.org/package/2006/content-types",
};
// === Help Text ===
function printHelp(): void {
console.log(`
${SCRIPT_NAME} v${VERSION} - Generate PPTX from existing templates
Usage:
deno run --allow-read --allow-write scripts/${SCRIPT_NAME}.ts <template.pptx> <spec.json> <output.pptx>
Arguments:
<template.pptx> Path to the master template PowerPoint file
<spec.json> Path to JSON specification for replacements/selections
<output.pptx> Path for output PowerPoint file
Options:
-h, --help Show this help message
-v, --verbose Enable verbose output
Specification Format (Text Replacement):
{
"textReplacements": [
{ "tag": "{{TITLE}}", "value": "Q4 2024 Results" },
{ "tag": "{{DATE}}", "value": "December 2024" },
{ "tag": "{{AUTHOR}}", "value": "John Smith", "slideNumbers": [1] }
]
}
Specification Format (Slide Library):
{
"slideSelections": [
{ "slideNumber": 1 },
{ "slideNumber": 5 },
{ "slideNumber": 12 }
],
"textReplacements": [
{ "tag": "{{TITLE}}", "value": "Custom Presentation" }
]
}
Specification Format (Speaker Notes):
{
"slideNotes": [
{ "slideNumber": 1, "notes": "Welcome everyone to today's presentation..." },
{ "slideNumber": 2, "notes": "This slide covers our key objectives..." }
]
}
Notes appear in PowerPoint's Notes pane and Presenter View.
Can be combined with textReplacements and slideSelections.
Examples:
# Replace text in template
deno run --allow-read --allow-write scripts/${SCRIPT_NAME}.ts \\
template.pptx replacements.json output.pptx
# Combine slides from library
deno run --allow-read --allow-write scripts/${SCRIPT_NAME}.ts \\
slide-library.pptx selections.json custom-deck.pptx -v
`);
}
// === Utility Functions ===
// deno-lint-ignore no-explicit-any
function getElementsByTagNameNS(
parent: any,
ns: string,
localName: string
// deno-lint-ignore no-explicit-any
): any[] {
const elements = parent.getElementsByTagNameNS(ns, localName);
// deno-lint-ignore no-explicit-any
return Array.from(elements) as any[];
}
function getSlideFiles(zip: JSZip): string[] {
const slideFiles: string[] = [];
zip.forEach((relativePath) => {
const match = relativePath.match(/^ppt\/slides\/slide(\d+)\.xml$/);
if (match) {
slideFiles.push(relativePath);
}
});
// Sort by slide number
slideFiles.sort((a, b) => {
const numA = parseInt(a.match(/slide(\d+)\.xml$/)?.[1] || "0", 10);
const numB = parseInt(b.match(/slide(\d+)\.xml$/)?.[1] || "0", 10);
return numA - numB;
});
return slideFiles;
}
// === Text Replacement ===
function replaceTextInXml(
xmlContent: string,
replacements: TextReplacement[]
): string {
const parser = new DOMParser();
const doc = parser.parseFromString(xmlContent, "text/xml");
// Find all text elements
const textElements = getElementsByTagNameNS(doc, NS.a, "t");
for (const textEl of textElements as { textContent: string | null }[]) {
let text = textEl.textContent || "";
for (const replacement of replacements) {
// Normalize tag format - support both {{TAG}} and TAG formats
const tag = replacement.tag.startsWith("{{")
? replacement.tag
: `{{${replacement.tag}}}`;
if (text.includes(tag)) {
text = text.replace(new RegExp(escapeRegExp(tag), "g"), replacement.value);
}
}
if (textEl.textContent !== text) {
textEl.textContent = text;
}
}
const serializer = new XMLSerializer();
return serializer.serializeToString(doc);
}
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// === Slide Management ===
interface SlideInfo {
slideXml: string;
relsXml: string | null;
slideNumber: number;
}
async function extractSlide(
zip: JSZip,
slideNumber: number
): Promise<SlideInfo | null> {
const slidePath = `ppt/slides/slide${slideNumber}.xml`;
const relsPath = `ppt/slides/_rels/slide${slideNumber}.xml.rels`;
const slideFile = zip.file(slidePath);
if (!slideFile) return null;
const slideXml = await slideFile.async("string");
const relsFile = zip.file(relsPath);
const relsXml = relsFile ? await relsFile.async("string") : null;
return { slideXml, relsXml, slideNumber };
}
async function updatePresentationXml(
zip: JSZip,
slideCount: number
): Promise<void> {
const presPath = "ppt/presentation.xml";
const presFile = zip.file(presPath);
if (!presFile) return;
const presXml = await presFile.async("string");
const parser = new DOMParser();
const doc = parser.parseFromString(presXml, "text/xml");
// Find sldIdLst and update
// deno-lint-ignore no-explicit-any
const sldIdLst = getElementsByTagNameNS(doc, NS.p, "sldIdLst")[0] as any;
if (sldIdLst) {
// Clear existing entries
while (sldIdLst.firstChild) {
sldIdLst.removeChild(sldIdLst.firstChild);
}
// Add new slide references
for (let i = 1; i <= slideCount; i++) {
// deno-lint-ignore no-explicit-any
const sldId = (doc as any).createElementNS(NS.p, "p:sldId");
sldId.setAttribute("id", String(255 + i));
sldId.setAttributeNS(NS.r, "r:id", `rId${i + 1}`);
sldIdLst.appendChild(sldId);
}
}
const serializer = new XMLSerializer();
zip.file(presPath, serializer.serializeToString(doc));
}
async function updatePresentationRels(
zip: JSZip,
slideCount: number
): Promise<void> {
const relsPath = "ppt/_rels/presentation.xml.rels";
const relsFile = zip.file(relsPath);
if (!relsFile) return;
const relsXml = await relsFile.async("string");
const parser = new DOMParser();
const doc = parser.parseFromString(relsXml, "text/xml");
// deno-lint-ignore no-explicit-any
const relationships = (doc as any).documentElement;
if (!relationships) return;
// Remove existing slide relationships
const existingRels = getElementsByTagNameNS(doc, NS.rel, "Relationship");
const slideRelType =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide";
// deno-lint-ignore no-explicit-any
for (const rel of existingRels as any[]) {
if (rel.getAttribute("Type") === slideRelType) {
relationships.removeChild(rel);
}
}
// Add new slide relationships
for (let i = 1; i <= slideCount; i++) {
// deno-lint-ignore no-explicit-any
const rel = (doc as any).createElementNS(NS.rel, "Relationship");
rel.setAttribute("Id", `rId${i + 1}`);
rel.setAttribute("Type", slideRelType);
rel.setAttribute("Target", `slides/slide${i}.xml`);
relationships.appendChild(rel);
}
const serializer = new XMLSerializer();
zip.file(relsPath, serializer.serializeToString(doc));
}
async function updateContentTypes(
zip: JSZip,
slideCount: number
): Promise<void> {
const ctPath = "[Content_Types].xml";
const ctFile = zip.file(ctPath);
if (!ctFile) return;
const ctXml = await ctFile.async("string");
const parser = new DOMParser();
const doc = parser.parseFromString(ctXml, "text/xml");
// deno-lint-ignore no-explicit-any
const types = (doc as any).documentElement;
if (!types) return;
// Remove existing slide overrides
// deno-lint-ignore no-explicit-any
const overrides = (doc as any).getElementsByTagName("Override");
// deno-lint-ignore no-explicit-any
const toRemove: any[] = [];
for (let i = 0; i < overrides.length; i++) {
const override = overrides[i];
const partName = override.getAttribute("PartName") || "";
if (partName.match(/\/ppt\/slides\/slide\d+\.xml$/)) {
toRemove.push(override);
}
}
for (const el of toRemove) {
types.removeChild(el);
}
// Add new slide overrides
const slideContentType =
"application/vnd.openxmlformats-officedocument.presentationml.slide+xml";
for (let i = 1; i <= slideCount; i++) {
// deno-lint-ignore no-explicit-any
const override = (doc as any).createElement("Override");
override.setAttribute("PartName", `/ppt/slides/slide${i}.xml`);
override.setAttribute("ContentType", slideContentType);
types.appendChild(override);
}
const serializer = new XMLSerializer();
zip.file(ctPath, serializer.serializeToString(doc));
}
// === Speaker Notes Support ===
/**
* Generate the notes master XML (template for all notes pages)
*/
function generateNotesMasterXml(): string {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:notesMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:nvGrpSpPr>
<p:cNvPr id="1" name=""/>
<p:cNvGrpSpPr/>
<p:nvPr/>
</p:nvGrpSpPr>
<p:grpSpPr>
<a:xfrm>
<a:off x="0" y="0"/>
<a:ext cx="0" cy="0"/>
<a:chOff x="0" y="0"/>
<a:chExt cx="0" cy="0"/>
</a:xfrm>
</p:grpSpPr>
<p:sp>
<p:nvSpPr>
<p:cNvPr id="2" name="Slide Image Placeholder"/>
<p:cNvSpPr><a:spLocks noGrp="1" noRot="1" noChangeAspect="1"/></p:cNvSpPr>
<p:nvPr><p:ph type="sldImg"/></p:nvPr>
</p:nvSpPr>
<p:spPr>
<a:xfrm>
<a:off x="381000" y="685800"/>
<a:ext cx="6096000" cy="3429000"/>
</a:xfrm>
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
<a:noFill/>
<a:ln w="12700"><a:solidFill><a:schemeClr val="tx1"/></a:solidFill></a:ln>
</p:spPr>
</p:sp>
<p:sp>
<p:nvSpPr>
<p:cNvPr id="3" name="Notes Placeholder"/>
<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>
<p:nvPr><p:ph type="body" idx="1"/></p:nvPr>
</p:nvSpPr>
<p:spPr>
<a:xfrm>
<a:off x="381000" y="4343400"/>
<a:ext cx="6096000" cy="4114800"/>
</a:xfrm>
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
</p:spPr>
<p:txBody>
<a:bodyPr vert="horz" lIns="91440" tIns="45720" rIns="91440" bIns="45720" rtlCol="0"/>
<a:lstStyle/>
<a:p><a:pPr lvl="0"/><a:r><a:rPr lang="en-US"/><a:t></a:t></a:r></a:p>
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
</p:notesMaster>`;
}
/**
* Generate the notes master relationships file
*/
function generateNotesMasterRelsXml(): string {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>
</Relationships>`;
}
/**
* Escape text for XML content
*/
function escapeXmlText(text: string): string {
return text
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
/**
* Generate a notes slide XML with the given speaker notes text
*/
function generateNotesSlideXml(notesText: string): string {
// Split notes into paragraphs and create XML for each
const paragraphs = notesText.split(/\n\n+/).map(p => p.trim()).filter(p => p);
const paragraphsXml = paragraphs.length > 0
? paragraphs.map(p =>
`<a:p><a:r><a:rPr lang="en-US" dirty="0"/><a:t>${escapeXmlText(p.replace(/\n/g, " "))}</a:t></a:r></a:p>`
).join("")
: `<a:p><a:r><a:rPr lang="en-US" dirty="0"/><a:t>${escapeXmlText(notesText)}</a:t></a:r></a:p>`;
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:notes xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:nvGrpSpPr>
<p:cNvPr id="1" name=""/>
<p:cNvGrpSpPr/>
<p:nvPr/>
</p:nvGrpSpPr>
<p:grpSpPr>
<a:xfrm>
<a:off x="0" y="0"/>
<a:ext cx="0" cy="0"/>
<a:chOff x="0" y="0"/>
<a:chExt cx="0" cy="0"/>
</a:xfrm>
</p:grpSpPr>
<p:sp>
<p:nvSpPr>
<p:cNvPr id="2" name="Slide Image Placeholder"/>
<p:cNvSpPr><a:spLocks noGrp="1" noRot="1" noChangeAspect="1"/></p:cNvSpPr>
<p:nvPr><p:ph type="sldImg"/></p:nvPr>
</p:nvSpPr>
<p:spPr/>
</p:sp>
<p:sp>
<p:nvSpPr>
<p:cNvPr id="3" name="Notes Placeholder"/>
<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>
<p:nvPr><p:ph type="body" idx="1"/></p:nvPr>
</p:nvSpPr>
<p:spPr/>
<p:txBody>
<a:bodyPr/>
<a:lstStyle/>
${paragraphsXml}
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
</p:notes>`;
}
/**
* Generate a notes slide relationships file
*/
function generateNotesSlideRelsXml(slideNumber: number): string {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" Target="../notesMasters/notesMaster1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="../slides/slide${slideNumber}.xml"/>
</Relationships>`;
}
/**
* Add notes infrastructure to a PPTX zip
*/
async function addNotesToPptx(
zip: JSZip,
slideNotes: SlideNotes[],
slideCount: number,
verbose: boolean = false
): Promise<void> {
if (slideNotes.length === 0) return;
if (verbose) {
console.error(`Adding speaker notes for ${slideNotes.length} slides`);
}
// Create notes master
zip.file("ppt/notesMasters/notesMaster1.xml", generateNotesMasterXml());
zip.file("ppt/notesMasters/_rels/notesMaster1.xml.rels", generateNotesMasterRelsXml());
// Create a map of slide number to notes
const notesMap = new Map<number, string>();
for (const sn of slideNotes) {
notesMap.set(sn.slideNumber, sn.notes);
}
// Create notes slides for each slide that has notes
for (let i = 1; i <= slideCount; i++) {
const notes = notesMap.get(i);
if (notes) {
zip.file(`ppt/notesSlides/notesSlide${i}.xml`, generateNotesSlideXml(notes));
zip.file(`ppt/notesSlides/_rels/notesSlide${i}.xml.rels`, generateNotesSlideRelsXml(i));
if (verbose) {
console.error(` Created notesSlide${i}.xml`);
}
}
}
// Update slide relationships to link to notes slides
for (let i = 1; i <= slideCount; i++) {
if (notesMap.has(i)) {
const relsPath = `ppt/slides/_rels/slide${i}.xml.rels`;
const relsFile = zip.file(relsPath);
if (relsFile) {
let relsXml = await relsFile.async("string");
// Check if notesSlide relationship already exists
if (!relsXml.includes("relationships/notesSlide")) {
// Find highest rId
const rIdMatches = relsXml.match(/Id="rId(\d+)"/g) || [];
let maxRId = 0;
for (const match of rIdMatches) {
const num = parseInt(match.match(/rId(\d+)/)?.[1] || "0", 10);
if (num > maxRId) maxRId = num;
}
const newRId = `rId${maxRId + 1}`;
// Insert new relationship before closing tag
const newRel = `<Relationship Id="${newRId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" Target="../notesSlides/notesSlide${i}.xml"/>`;
relsXml = relsXml.replace("</Relationships>", `${newRel}\n</Relationships>`);
zip.file(relsPath, relsXml);
}
} else {
// Create new rels file if it doesn't exist
const newRelsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" Target="../notesSlides/notesSlide${i}.xml"/>
</Relationships>`;
zip.file(relsPath, newRelsXml);
}
}
}
// Update presentation.xml.rels to include notesMaster
const presRelsPath = "ppt/_rels/presentation.xml.rels";
const presRelsFile = zip.file(presRelsPath);
if (presRelsFile) {
let presRelsXml = await presRelsFile.async("string");
if (!presRelsXml.includes("relationships/notesMaster")) {
// Find highest rId
const rIdMatches = presRelsXml.match(/Id="rId(\d+)"/g) || [];
let maxRId = 0;
for (const match of rIdMatches) {
const num = parseInt(match.match(/rId(\d+)/)?.[1] || "0", 10);
if (num > maxRId) maxRId = num;
}
const newRId = `rId${maxRId + 1}`;
const newRel = `<Relationship Id="${newRId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" Target="notesMasters/notesMaster1.xml"/>`;
presRelsXml = presRelsXml.replace("</Relationships>", `${newRel}\n</Relationships>`);
zip.file(presRelsPath, presRelsXml);
}
}
// Update [Content_Types].xml to include notes types
const ctPath = "[Content_Types].xml";
const ctFile = zip.file(ctPath);
if (ctFile) {
let ctXml = await ctFile.async("string");
// Add notesMaster content type if not present
if (!ctXml.includes("notesMaster+xml")) {
const notesMasterOverride = `<Override PartName="/ppt/notesMasters/notesMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml"/>`;
ctXml = ctXml.replace("</Types>", `${notesMasterOverride}\n</Types>`);
}
// Add notesSlide content types for each slide with notes
for (let i = 1; i <= slideCount; i++) {
if (notesMap.has(i)) {
const partName = `/ppt/notesSlides/notesSlide${i}.xml`;
if (!ctXml.includes(partName)) {
const notesSlideOverride = `<Override PartName="${partName}" ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml"/>`;
ctXml = ctXml.replace("</Types>", `${notesSlideOverride}\n</Types>`);
}
}
}
zip.file(ctPath, ctXml);
}
if (verbose) {
console.error(`Notes infrastructure added successfully`);
}
}
// === Core Logic ===
export async function generateFromTemplate(
templatePath: string,
spec: TemplateSpec,
outputPath: string,
options: { verbose?: boolean } = {}
): Promise<void> {
const { verbose = false } = options;
// Read template
const templateData = await Deno.readFile(templatePath);
const zip = await JSZip.loadAsync(templateData);
if (verbose) {
console.error(`Loaded template: ${basename(templatePath)}`);
}
// Get all slide files
const allSlideFiles = getSlideFiles(zip);
const totalSlides = allSlideFiles.length;
if (verbose) {
console.error(`Template has ${totalSlides} slides`);
}
// Determine which slides to include
let slidesToInclude: number[];
if (spec.slideSelections && spec.slideSelections.length > 0) {
// Slide library mode: use selected slides
slidesToInclude = spec.slideSelections.map((s) => s.slideNumber);
if (verbose) {
console.error(`Slide library mode: selecting slides ${slidesToInclude.join(", ")}`);
}
} else if (spec.includeSlides && spec.includeSlides.length > 0) {
// Include specific slides
slidesToInclude = spec.includeSlides;
} else if (spec.excludeSlides && spec.excludeSlides.length > 0) {
// Exclude specific slides
slidesToInclude = [];
for (let i = 1; i <= totalSlides; i++) {
if (!spec.excludeSlides.includes(i)) {
slidesToInclude.push(i);
}
}
} else {
// Include all slides
slidesToInclude = [];
for (let i = 1; i <= totalSlides; i++) {
slidesToInclude.push(i);
}
}
// Extract selected slides
const selectedSlides: SlideInfo[] = [];
for (const slideNum of slidesToInclude) {
const slideInfo = await extractSlide(zip, slideNum);
if (slideInfo) {
selectedSlides.push(slideInfo);
} else if (verbose) {
console.error(`Warning: Slide ${slideNum} not found`);
}
}
if (verbose) {
console.error(`Selected ${selectedSlides.length} slides`);
}
// Apply text replacements
const replacements = spec.textReplacements || [];
if (replacements.length > 0 && verbose) {
console.error(`Applying ${replacements.length} text replacements`);
}
// Remove all existing slide files
const filesToRemove: string[] = [];
zip.forEach((path) => {
if (path.match(/^ppt\/slides\/slide\d+\.xml$/) ||
path.match(/^ppt\/slides\/_rels\/slide\d+\.xml\.rels$/)) {
filesToRemove.push(path);
}
});
for (const path of filesToRemove) {
zip.remove(path);
}
// Add selected slides with new numbering
for (let i = 0; i < selectedSlides.length; i++) {
const slide = selectedSlides[i];
const newSlideNum = i + 1;
// Filter replacements for this slide
const slideReplacements = replacements.filter(
(r) => !r.slideNumbers || r.slideNumbers.includes(slide.slideNumber)
);
// Apply text replacements
let slideXml = slide.slideXml;
if (slideReplacements.length > 0) {
slideXml = replaceTextInXml(slideXml, slideReplacements);
}
// Write slide with new number
zip.file(`ppt/slides/slide${newSlideNum}.xml`, slideXml);
// Write relationships if present
if (slide.relsXml) {
zip.file(`ppt/slides/_rels/slide${newSlideNum}.xml.rels`, slide.relsXml);
}
if (verbose) {
console.error(
`Wrote slide ${newSlideNum} (from original slide ${slide.slideNumber})`
);
}
}
// Update presentation.xml with new slide list
await updatePresentationXml(zip, selectedSlides.length);
// Update presentation.xml.rels
await updatePresentationRels(zip, selectedSlides.length);
// Update [Content_Types].xml
await updateContentTypes(zip, selectedSlides.length);
// Add speaker notes if provided
if (spec.slideNotes && spec.slideNotes.length > 0) {
await addNotesToPptx(zip, spec.slideNotes, selectedSlides.length, verbose);
}
// Write output file
const outputData = await zip.generateAsync({
type: "uint8array",
compression: "DEFLATE",
compressionOptions: { level: 6 },
});
await Deno.writeFile(outputPath, outputData);
if (verbose) {
console.error(`Wrote ${outputPath}`);
}
}
// === Main CLI Handler ===
async function main(args: string[]): Promise<void> {
const parsed = parseArgs(args, {
boolean: ["help", "verbose"],
alias: { help: "h", verbose: "v" },
default: { verbose: false },
}) as ParsedArgs;
if (parsed.help) {
printHelp();
Deno.exit(0);
}
const positionalArgs = parsed._.map(String);
if (positionalArgs.length < 3) {
console.error(
"Error: template.pptx, spec.json, and output.pptx are required\n"
);
printHelp();
Deno.exit(1);
}
const templatePath = positionalArgs[0];
const specPath = positionalArgs[1];
const outputPath = positionalArgs[2];
try {
// Read specification
const specText = await Deno.readTextFile(specPath);
const spec = JSON.parse(specText) as TemplateSpec;
await generateFromTemplate(templatePath, spec, outputPath, {
verbose: parsed.verbose,
});
console.log(`Created: ${outputPath}`);
} catch (error) {
console.error(
"Error:",
error instanceof Error ? error.message : String(error)
);
Deno.exit(1);
}
}
// === Entry Point ===
if (import.meta.main) {
main(Deno.args);
}
#!/usr/bin/env -S deno run --allow-read --allow-write
/**
* generate-scratch.ts - Create PPTX from scratch using JSON specification
*
* Creates PowerPoint presentations programmatically from a JSON specification
* using PptxGenJS. Supports text, images, tables, shapes, and charts.
*
* Usage:
* deno run --allow-read --allow-write scripts/generate-scratch.ts <spec.json> <output.pptx>
*
* Options:
* -h, --help Show help
* -v, --verbose Enable verbose output
*
* Permissions:
* --allow-read: Read specification file and image assets
* --allow-write: Write output PPTX file
*/
import { parseArgs } from "jsr:@std/cli@1.0.9/parse-args";
import { dirname, resolve } from "jsr:@std/path@1.0.8";
// deno-lint-ignore no-explicit-any
const PptxGenJS: any = (await import("npm:pptxgenjs@3.12.0")).default;
// === Types ===
export interface TextOptions {
text: string;
fontSize?: number;
fontFace?: string;
color?: string;
bold?: boolean;
italic?: boolean;
underline?: boolean;
align?: "left" | "center" | "right" | "justify";
valign?: "top" | "middle" | "bottom";
breakLine?: boolean;
bullet?: boolean | { type?: string; code?: string };
paraSpaceAfter?: number;
paraSpaceBefore?: number;
}
export interface ImageOptions {
path?: string;
data?: string; // base64
sizing?: {
type: "contain" | "cover" | "crop";
w?: number;
h?: number;
};
hyperlink?: { url: string };
}
export interface TableCell {
text: string;
options?: {
bold?: boolean;
color?: string;
fill?: string;
fontSize?: number;
align?: "left" | "center" | "right";
valign?: "top" | "middle" | "bottom";
colspan?: number;
rowspan?: number;
};
}
export interface TableOptions {
rows: (string | TableCell)[][];
colW?: number[];
rowH?: number[];
border?: { pt?: number; color?: string };
fill?: string;
fontSize?: number;
fontFace?: string;
color?: string;
align?: "left" | "center" | "right";
valign?: "top" | "middle" | "bottom";
}
export interface ShapeOptions {
type:
| "rect"
| "roundRect"
| "ellipse"
| "triangle"
| "line"
| "arrow"
| "star";
fill?: string;
line?: { color?: string; width?: number; dashType?: string };
text?: string;
fontSize?: number;
fontFace?: string;
color?: string;
align?: "left" | "center" | "right";
valign?: "top" | "middle" | "bottom";
}
export interface ChartOptions {
type: "bar" | "line" | "pie" | "doughnut" | "area" | "scatter";
data: {
name: string;
labels: string[];
values: number[];
}[];
title?: string;
showLegend?: boolean;
legendPos?: "b" | "l" | "r" | "t" | "tr";
showTitle?: boolean;
showValue?: boolean;
catAxisTitle?: string;
valAxisTitle?: string;
}
export interface ElementSpec {
type: "text" | "image" | "table" | "shape" | "chart";
x: number; // inches
y: number; // inches
w: number; // inches
h: number; // inches
options: TextOptions | ImageOptions | TableOptions | ShapeOptions | ChartOptions;
}
export interface SlideSpec {
layout?: "blank" | "title" | "titleAndContent" | "section" | "twoColumn";
background?: {
color?: string;
image?: string;
};
masterName?: string;
elements: ElementSpec[];
}
export interface PresentationSpec {
title?: string;
subject?: string;
author?: string;
company?: string;
layout?: {
width?: number; // inches, default 10
height?: number; // inches, default 5.625 (16:9)
};
theme?: {
headFontFace?: string;
bodyFontFace?: string;
};
slides: SlideSpec[];
}
interface ParsedArgs {
help: boolean;
verbose: boolean;
_: (string | number)[];
}
// Use 'any' for PptxGenJS types to avoid namespace issues
// deno-lint-ignore no-explicit-any
type Slide = any;
// deno-lint-ignore no-explicit-any
type Pptx = any;
// === Constants ===
const VERSION = "1.0.0";
const SCRIPT_NAME = "generate-scratch";
// Shape type mapping
const SHAPE_MAP: Record<string, string> = {
rect: "rect",
roundRect: "roundRect",
ellipse: "ellipse",
triangle: "triangle",
line: "line",
arrow: "rightArrow",
star: "star5",
};
// Chart type mapping
const CHART_MAP: Record<string, string> = {
bar: "bar",
line: "line",
pie: "pie",
doughnut: "doughnut",
area: "area",
scatter: "scatter",
};
// === Help Text ===
function printHelp(): void {
console.log(`
${SCRIPT_NAME} v${VERSION} - Create PPTX from scratch using JSON specification
Usage:
deno run --allow-read --allow-write scripts/${SCRIPT_NAME}.ts <spec.json> <output.pptx>
Arguments:
<spec.json> Path to JSON specification file
<output.pptx> Path for output PowerPoint file
Options:
-h, --help Show this help message
-v, --verbose Enable verbose output
Specification Format:
{
"title": "Presentation Title",
"author": "Author Name",
"slides": [
{
"layout": "blank",
"background": { "color": "FFFFFF" },
"elements": [
{
"type": "text",
"x": 1, "y": 1, "w": 8, "h": 1,
"options": {
"text": "Hello World",
"fontSize": 44,
"bold": true,
"color": "003366"
}
}
]
}
]
}
Examples:
# Generate from specification
deno run --allow-read --allow-write scripts/${SCRIPT_NAME}.ts spec.json output.pptx
# With verbose output
deno run --allow-read --allow-write scripts/${SCRIPT_NAME}.ts spec.json output.pptx -v
`);
}
// === Core Logic ===
function addTextElement(slide: Slide, element: ElementSpec): void {
const opts = element.options as TextOptions;
slide.addText(opts.text, {
x: element.x,
y: element.y,
w: element.w,
h: element.h,
fontSize: opts.fontSize,
fontFace: opts.fontFace,
color: opts.color,
bold: opts.bold,
italic: opts.italic,
underline: opts.underline ? { style: "sng" } : undefined,
align: opts.align,
valign: opts.valign,
bullet: opts.bullet,
paraSpaceAfter: opts.paraSpaceAfter,
paraSpaceBefore: opts.paraSpaceBefore,
});
}
async function addImageElement(
slide: Slide,
element: ElementSpec,
specDir: string
): Promise<void> {
const opts = element.options as ImageOptions;
// deno-lint-ignore no-explicit-any
const imageProps: any = {
x: element.x,
y: element.y,
w: element.w,
h: element.h,
sizing: opts.sizing,
hyperlink: opts.hyperlink,
};
if (opts.data) {
imageProps.data = opts.data;
} else if (opts.path) {
const imagePath = resolve(specDir, opts.path);
const imageData = await Deno.readFile(imagePath);
const base64 = btoa(String.fromCharCode(...imageData));
const ext = opts.path.split(".").pop()?.toLowerCase() || "png";
imageProps.data = `image/${ext};base64,${base64}`;
}
slide.addImage(imageProps);
}
function addTableElement(slide: Slide, element: ElementSpec): void {
const opts = element.options as TableOptions;
// Convert rows to pptxgen format
const tableRows = opts.rows.map((row) =>
row.map((cell) => {
if (typeof cell === "string") {
return { text: cell };
}
return {
text: cell.text,
options: {
bold: cell.options?.bold,
color: cell.options?.color,
fill: cell.options?.fill ? { color: cell.options.fill } : undefined,
fontSize: cell.options?.fontSize,
align: cell.options?.align,
valign: cell.options?.valign,
colspan: cell.options?.colspan,
rowspan: cell.options?.rowspan,
},
};
})
);
slide.addTable(tableRows, {
x: element.x,
y: element.y,
w: element.w,
h: element.h,
colW: opts.colW,
rowH: opts.rowH,
border: opts.border,
fill: opts.fill ? { color: opts.fill } : undefined,
fontSize: opts.fontSize,
fontFace: opts.fontFace,
color: opts.color,
align: opts.align,
valign: opts.valign,
});
}
function addShapeElement(slide: Slide, element: ElementSpec): void {
const opts = element.options as ShapeOptions;
const shapeType = SHAPE_MAP[opts.type] || "rect";
// deno-lint-ignore no-explicit-any
const shapeProps: any = {
x: element.x,
y: element.y,
w: element.w,
h: element.h,
fill: opts.fill ? { color: opts.fill } : undefined,
line: opts.line
? {
color: opts.line.color,
width: opts.line.width,
dashType: opts.line.dashType,
}
: undefined,
};
if (opts.text) {
slide.addText(opts.text, {
...shapeProps,
shape: shapeType,
fontSize: opts.fontSize,
fontFace: opts.fontFace,
color: opts.color,
align: opts.align,
valign: opts.valign,
});
} else {
slide.addShape(shapeType, shapeProps);
}
}
function addChartElement(slide: Slide, element: ElementSpec): void {
const opts = element.options as ChartOptions;
const chartType = CHART_MAP[opts.type] || "bar";
const chartData = opts.data.map((series) => ({
name: series.name,
labels: series.labels,
values: series.values,
}));
slide.addChart(chartType, chartData, {
x: element.x,
y: element.y,
w: element.w,
h: element.h,
title: opts.title,
showLegend: opts.showLegend,
legendPos: opts.legendPos,
showTitle: opts.showTitle,
showValue: opts.showValue,
catAxisTitle: opts.catAxisTitle,
valAxisTitle: opts.valAxisTitle,
});
}
export async function generateFromSpec(
spec: PresentationSpec,
outputPath: string,
options: { verbose?: boolean; specDir?: string } = {}
): Promise<void> {
const { verbose = false, specDir = "." } = options;
// Create presentation
const pptx: Pptx = new PptxGenJS();
// Set metadata
if (spec.title) pptx.title = spec.title;
if (spec.subject) pptx.subject = spec.subject;
if (spec.author) pptx.author = spec.author;
if (spec.company) pptx.company = spec.company;
// Set layout
if (spec.layout) {
if (spec.layout.width && spec.layout.height) {
pptx.defineLayout({
name: "CUSTOM",
width: spec.layout.width,
height: spec.layout.height,
});
pptx.layout = "CUSTOM";
}
}
if (verbose) {
console.error(`Creating presentation with ${spec.slides.length} slides`);
}
// Process each slide
for (let i = 0; i < spec.slides.length; i++) {
const slideSpec = spec.slides[i];
if (verbose) {
console.error(
`Processing slide ${i + 1}: ${slideSpec.elements.length} elements`
);
}
// Add slide
const slide: Slide = pptx.addSlide();
// Set background
if (slideSpec.background) {
if (slideSpec.background.color) {
slide.background = { color: slideSpec.background.color };
} else if (slideSpec.background.image) {
const imagePath = resolve(specDir, slideSpec.background.image);
const imageData = await Deno.readFile(imagePath);
const base64 = btoa(String.fromCharCode(...imageData));
const ext = slideSpec.background.image.split(".").pop()?.toLowerCase() || "png";
slide.background = { data: `image/${ext};base64,${base64}` };
}
}
// Add elements
for (const element of slideSpec.elements) {
switch (element.type) {
case "text":
addTextElement(slide, element);
break;
case "image":
await addImageElement(slide, element, specDir);
break;
case "table":
addTableElement(slide, element);
break;
case "shape":
addShapeElement(slide, element);
break;
case "chart":
addChartElement(slide, element);
break;
default:
if (verbose) {
console.error(`Unknown element type: ${(element as ElementSpec).type}`);
}
}
}
}
// Write output file
const buffer = await pptx.write({ outputType: "nodebuffer" });
await Deno.writeFile(outputPath, new Uint8Array(buffer as ArrayBuffer));
if (verbose) {
console.error(`Wrote ${outputPath}`);
}
}
// === Main CLI Handler ===
async function main(args: string[]): Promise<void> {
const parsed = parseArgs(args, {
boolean: ["help", "verbose"],
alias: { help: "h", verbose: "v" },
default: { verbose: false },
}) as ParsedArgs;
if (parsed.help) {
printHelp();
Deno.exit(0);
}
const positionalArgs = parsed._.map(String);
if (positionalArgs.length < 2) {
console.error("Error: Both spec.json and output.pptx are required\n");
printHelp();
Deno.exit(1);
}
const specPath = positionalArgs[0];
const outputPath = positionalArgs[1];
try {
// Read and parse specification
const specText = await Deno.readTextFile(specPath);
const spec = JSON.parse(specText) as PresentationSpec;
// Get directory of spec file for resolving relative paths
const specDir = dirname(resolve(specPath));
await generateFromSpec(spec, outputPath, {
verbose: parsed.verbose,
specDir,
});
console.log(`Created: ${outputPath}`);
} catch (error) {
console.error(
"Error:",
error instanceof Error ? error.message : String(error)
);
Deno.exit(1);
}
}
// === Entry Point ===
if (import.meta.main) {
main(Deno.args);
}
#!/usr/bin/env -S deno run --allow-read --allow-write
/**
* generate-template.ts - Create PPTX template with proper slide masters
*
* Generates a PowerPoint template with correctly structured slide masters
* and layouts using PptxGenJS's defineSlideMaster() API.
*
* Usage:
* deno run --allow-read --allow-write scripts/generate-template.ts <output.pptx>
*
* Example:
* deno run --allow-read --allow-write scripts/generate-template.ts \
* ../../storage/starter-templates/pptx/professional-course-template-v1.0.pptx
*/
import { parseArgs } from "jsr:@std/cli@1.0.9/parse-args";
// deno-lint-ignore no-explicit-any
const PptxGenJS: any = (await import("npm:pptxgenjs@3.12.0")).default;
// === Color Scheme ===
const COLORS = {
primaryBlue: "1B365D",
accentTeal: "2E7D7A",
lightGray: "F5F5F5",
textDark: "333333",
textMedium: "666666",
background: "FFFFFF",
white: "FFFFFF",
};
// === Slide Dimensions (16:9) ===
const SLIDE = {
width: 10,
height: 5.625,
margin: 0.5,
};
// === Font Settings ===
const FONTS = {
heading: "Arial",
body: "Arial",
};
// === Help ===
function printHelp(): void {
console.log(`
generate-template.ts - Create PPTX template with proper slide masters
Usage:
deno run --allow-read --allow-write scripts/generate-template.ts <output.pptx>
Arguments:
<output.pptx> Path for output PowerPoint template file
Options:
-h, --help Show this help message
-v, --verbose Enable verbose output
Example:
deno run --allow-read --allow-write scripts/generate-template.ts template.pptx
`);
}
// === Main Template Generation ===
async function generateTemplate(
outputPath: string,
verbose: boolean
): Promise<void> {
// deno-lint-ignore no-explicit-any
const pptx: any = new PptxGenJS();
// Set presentation metadata
pptx.title = "Professional Course Template";
pptx.subject = "Course Presentation Template";
pptx.author = "Teach Platform";
pptx.company = "Teach";
// Set layout to 16:9
pptx.defineLayout({ name: "CUSTOM_16_9", width: SLIDE.width, height: SLIDE.height });
pptx.layout = "CUSTOM_16_9";
if (verbose) console.error("Defining slide masters...");
// === 1. TITLE SLIDE ===
pptx.defineSlideMaster({
title: "TITLE_SLIDE",
background: { color: COLORS.primaryBlue },
objects: [
// Course title
{
text: {
text: "{{course_title}}",
options: {
x: SLIDE.margin,
y: 1.5,
w: SLIDE.width - SLIDE.margin * 2,
h: 1.2,
fontSize: 44,
fontFace: FONTS.heading,
color: COLORS.white,
bold: true,
align: "center",
valign: "middle",
},
},
},
// Subtitle
{
text: {
text: "{{course_subtitle}}",
options: {
x: SLIDE.margin,
y: 2.8,
w: SLIDE.width - SLIDE.margin * 2,
h: 0.6,
fontSize: 20,
fontFace: FONTS.body,
color: COLORS.lightGray,
align: "center",
valign: "middle",
},
},
},
// Instructor name
{
text: {
text: "Instructor: {{instructor_name}}",
options: {
x: SLIDE.margin,
y: 4.2,
w: (SLIDE.width - SLIDE.margin * 2) / 2,
h: 0.4,
fontSize: 14,
fontFace: FONTS.body,
color: COLORS.lightGray,
align: "left",
valign: "middle",
},
},
},
// Date
{
text: {
text: "{{course_date}}",
options: {
x: SLIDE.width / 2,
y: 4.2,
w: (SLIDE.width - SLIDE.margin * 2) / 2,
h: 0.4,
fontSize: 14,
fontFace: FONTS.body,
color: COLORS.lightGray,
align: "right",
valign: "middle",
},
},
},
],
});
// === 2. SECTION HEADER ===
pptx.defineSlideMaster({
title: "SECTION_HEADER",
background: { color: COLORS.primaryBlue },
objects: [
// Section title
{
text: {
text: "{{section_title}}",
options: {
x: SLIDE.margin,
y: 2.0,
w: SLIDE.width - SLIDE.margin * 2,
h: 1.0,
fontSize: 40,
fontFace: FONTS.heading,
color: COLORS.white,
bold: true,
align: "center",
valign: "middle",
},
},
},
// Section description
{
text: {
text: "{{section_description}}",
options: {
x: SLIDE.margin,
y: 3.2,
w: SLIDE.width - SLIDE.margin * 2,
h: 0.8,
fontSize: 18,
fontFace: FONTS.body,
color: COLORS.lightGray,
align: "center",
valign: "top",
},
},
},
],
});
// === 3. CONTENT SLIDE ===
pptx.defineSlideMaster({
title: "CONTENT",
background: { color: COLORS.background },
objects: [
// Title
{
text: {
text: "{{slide_title}}",
options: {
x: SLIDE.margin,
y: 0.3,
w: SLIDE.width - SLIDE.margin * 2,
h: 0.8,
fontSize: 28,
fontFace: FONTS.heading,
color: COLORS.primaryBlue,
bold: true,
align: "left",
valign: "middle",
},
},
},
// Main content area
{
text: {
text: "{{main_content}}",
options: {
x: SLIDE.margin,
y: 1.3,
w: SLIDE.width - SLIDE.margin * 2,
h: 4.0,
fontSize: 18,
fontFace: FONTS.body,
color: COLORS.textDark,
align: "left",
valign: "top",
},
},
},
],
});
// === 4. TWO COLUMN ===
pptx.defineSlideMaster({
title: "TWO_COLUMN",
background: { color: COLORS.background },
objects: [
// Title
{
text: {
text: "{{slide_title}}",
options: {
x: SLIDE.margin,
y: 0.3,
w: SLIDE.width - SLIDE.margin * 2,
h: 0.8,
fontSize: 28,
fontFace: FONTS.heading,
color: COLORS.primaryBlue,
bold: true,
align: "left",
valign: "middle",
},
},
},
// Left column
{
text: {
text: "{{left_column}}",
options: {
x: SLIDE.margin,
y: 1.3,
w: (SLIDE.width - SLIDE.margin * 3) / 2,
h: 4.0,
fontSize: 16,
fontFace: FONTS.body,
color: COLORS.textDark,
align: "left",
valign: "top",
},
},
},
// Right column
{
text: {
text: "{{right_column}}",
options: {
x: SLIDE.width / 2 + SLIDE.margin / 2,
y: 1.3,
w: (SLIDE.width - SLIDE.margin * 3) / 2,
h: 4.0,
fontSize: 16,
fontFace: FONTS.body,
color: COLORS.textDark,
align: "left",
valign: "top",
},
},
},
],
});
// === 5. COMPETENCY OVERVIEW ===
pptx.defineSlideMaster({
title: "COMPETENCY",
background: { color: COLORS.background },
objects: [
// Competency title/code
{
text: {
text: "{{competency_title}}",
options: {
x: SLIDE.margin,
y: 0.3,
w: SLIDE.width - SLIDE.margin * 2,
h: 0.6,
fontSize: 24,
fontFace: FONTS.heading,
color: COLORS.primaryBlue,
bold: true,
align: "left",
valign: "middle",
},
},
},
// Competency description box
{
rect: {
x: SLIDE.margin,
y: 1.0,
w: SLIDE.width - SLIDE.margin * 2,
h: 1.2,
fill: { color: COLORS.lightGray },
line: { color: COLORS.accentTeal, width: 2 },
},
},
{
text: {
text: "{{competency_description}}",
options: {
x: SLIDE.margin + 0.2,
y: 1.1,
w: SLIDE.width - SLIDE.margin * 2 - 0.4,
h: 1.0,
fontSize: 16,
fontFace: FONTS.body,
color: COLORS.textDark,
align: "left",
valign: "middle",
},
},
},
// Learning objectives header
{
text: {
text: "Learning Objectives:",
options: {
x: SLIDE.margin,
y: 2.4,
w: SLIDE.width - SLIDE.margin * 2,
h: 0.5,
fontSize: 18,
fontFace: FONTS.heading,
color: COLORS.primaryBlue,
bold: true,
align: "left",
valign: "middle",
},
},
},
// Learning objectives content
{
text: {
text: "{{learning_objectives}}",
options: {
x: SLIDE.margin,
y: 2.9,
w: SLIDE.width - SLIDE.margin * 2,
h: 2.4,
fontSize: 16,
fontFace: FONTS.body,
color: COLORS.textDark,
align: "left",
valign: "top",
},
},
},
],
});
// === 6. ACTIVITY INSTRUCTIONS ===
pptx.defineSlideMaster({
title: "ACTIVITY",
background: { color: COLORS.background },
objects: [
// Activity prefix
{
text: {
text: "Activity:",
options: {
x: SLIDE.margin,
y: 0.3,
w: 1.0,
h: 0.6,
fontSize: 20,
fontFace: FONTS.heading,
color: COLORS.accentTeal,
bold: true,
align: "left",
valign: "middle",
},
},
},
// Activity title
{
text: {
text: "{{activity_title}}",
options: {
x: SLIDE.margin + 1.1,
y: 0.3,
w: SLIDE.width - SLIDE.margin * 2 - 1.1,
h: 0.6,
fontSize: 24,
fontFace: FONTS.heading,
color: COLORS.primaryBlue,
bold: true,
align: "left",
valign: "middle",
},
},
},
// Instructions header
{
text: {
text: "Instructions:",
options: {
x: SLIDE.margin,
y: 1.0,
w: 2.0,
h: 0.4,
fontSize: 14,
fontFace: FONTS.heading,
color: COLORS.textMedium,
bold: true,
align: "left",
valign: "middle",
},
},
},
// Instructions content
{
text: {
text: "{{activity_instructions}}",
options: {
x: SLIDE.margin,
y: 1.4,
w: SLIDE.width - SLIDE.margin * 2 - 2.5,
h: 2.8,
fontSize: 16,
fontFace: FONTS.body,
color: COLORS.textDark,
align: "left",
valign: "top",
},
},
},
// Time estimate box
{
rect: {
x: SLIDE.width - SLIDE.margin - 2.0,
y: 1.0,
w: 2.0,
h: 0.8,
fill: { color: "E8F4F3" }, // Light teal
line: { color: COLORS.accentTeal, width: 1 },
},
},
{
text: {
text: "{{time_estimate}} min",
options: {
x: SLIDE.width - SLIDE.margin - 2.0,
y: 1.0,
w: 2.0,
h: 0.8,
fontSize: 18,
fontFace: FONTS.heading,
color: COLORS.primaryBlue,
bold: true,
align: "center",
valign: "middle",
},
},
},
// Materials header
{
text: {
text: "Materials Needed:",
options: {
x: SLIDE.margin,
y: 4.3,
w: 2.5,
h: 0.4,
fontSize: 14,
fontFace: FONTS.heading,
color: COLORS.textMedium,
bold: true,
align: "left",
valign: "middle",
},
},
},
// Materials content
{
text: {
text: "{{materials_needed}}",
options: {
x: SLIDE.margin + 2.5,
y: 4.3,
w: SLIDE.width - SLIDE.margin * 2 - 2.5,
h: 0.8,
fontSize: 14,
fontFace: FONTS.body,
color: COLORS.textDark,
align: "left",
valign: "top",
},
},
},
],
});
// === 7. Q&A / DISCUSSION ===
pptx.defineSlideMaster({
title: "DISCUSSION",
background: { color: COLORS.background },
objects: [
// Discussion header
{
text: {
text: "Discussion",
options: {
x: SLIDE.margin,
y: 0.3,
w: SLIDE.width - SLIDE.margin * 2,
h: 0.6,
fontSize: 24,
fontFace: FONTS.heading,
color: COLORS.primaryBlue,
bold: true,
align: "left",
valign: "middle",
},
},
},
// Discussion prompt box
{
rect: {
x: SLIDE.margin,
y: 1.0,
w: SLIDE.width - SLIDE.margin * 2,
h: 1.2,
fill: { color: COLORS.lightGray },
},
},
{
text: {
text: "{{discussion_prompt}}",
options: {
x: SLIDE.margin + 0.2,
y: 1.1,
w: SLIDE.width - SLIDE.margin * 2 - 0.4,
h: 1.0,
fontSize: 20,
fontFace: FONTS.body,
color: COLORS.textDark,
align: "left",
valign: "middle",
},
},
},
// Key points header
{
text: {
text: "Key Discussion Points:",
options: {
x: SLIDE.margin,
y: 2.4,
w: SLIDE.width - SLIDE.margin * 2,
h: 0.5,
fontSize: 16,
fontFace: FONTS.heading,
color: COLORS.accentTeal,
bold: true,
align: "left",
valign: "middle",
},
},
},
// Discussion points
{
text: {
text: "{{discussion_points}}",
options: {
x: SLIDE.margin,
y: 2.9,
w: SLIDE.width - SLIDE.margin * 2,
h: 1.5,
fontSize: 16,
fontFace: FONTS.body,
color: COLORS.textDark,
align: "left",
valign: "top",
},
},
},
// Teaching notes (instructor only - styled differently)
{
text: {
text: "Teaching Notes: {{teaching_notes}}",
options: {
x: SLIDE.margin,
y: 4.6,
w: SLIDE.width - SLIDE.margin * 2,
h: 0.7,
fontSize: 12,
fontFace: FONTS.body,
color: COLORS.textMedium,
italic: true,
align: "left",
valign: "top",
},
},
},
],
});
if (verbose) console.error("Creating sample slides...");
// === Create Sample Slides ===
// Slide 1: Title
const slide1 = pptx.addSlide({ masterName: "TITLE_SLIDE" });
slide1.addNotes("Instructor introduction notes go here. Welcome participants and set expectations for the session.");
// Slide 2: Section Header
const slide2 = pptx.addSlide({ masterName: "SECTION_HEADER" });
slide2.addNotes("Section instructor notes. Provide context for this module and transition from previous content.");
// Slide 3: Content
const slide3 = pptx.addSlide({ masterName: "CONTENT" });
slide3.addNotes("Slide instructor notes. Key talking points and additional context for this slide.");
// Slide 4: Two Column
const slide4 = pptx.addSlide({ masterName: "TWO_COLUMN" });
slide4.addNotes("Two column notes. Use this layout for comparisons or presenting related information side by side.");
// Slide 5: Competency
const slide5 = pptx.addSlide({ masterName: "COMPETENCY" });
slide5.addNotes("Competency assessment notes. How to evaluate learner progress on this competency.");
// Slide 6: Activity
const slide6 = pptx.addSlide({ masterName: "ACTIVITY" });
slide6.addNotes("Activity facilitation notes. Tips for running this activity effectively.");
// Slide 7: Discussion
const slide7 = pptx.addSlide({ masterName: "DISCUSSION" });
slide7.addNotes("Discussion facilitation tips. Watch for common misconceptions and guide conversation constructively.");
if (verbose) console.error("Writing output file...");
// Write the file
const data = await pptx.write({ outputType: "nodebuffer" });
await Deno.writeFile(outputPath, new Uint8Array(data as ArrayBuffer));
console.log(`Created: ${outputPath}`);
console.log(`\nTemplate includes ${7} slide masters:`);
console.log(" 1. TITLE_SLIDE - Course introduction");
console.log(" 2. SECTION_HEADER - Module/section dividers");
console.log(" 3. CONTENT - Standard content slides");
console.log(" 4. TWO_COLUMN - Side-by-side comparisons");
console.log(" 5. COMPETENCY - Learning objectives focus");
console.log(" 6. ACTIVITY - Exercise instructions");
console.log(" 7. DISCUSSION - Q&A and discussion prompts");
}
// === Main Entry Point ===
async function main(args: string[]): Promise<void> {
const parsed = parseArgs(args, {
boolean: ["help", "verbose"],
alias: { help: "h", verbose: "v" },
default: { verbose: false },
});
if (parsed.help) {
printHelp();
Deno.exit(0);
}
const positionalArgs = parsed._.map(String);
if (positionalArgs.length < 1) {
console.error("Error: Output path is required\n");
printHelp();
Deno.exit(1);
}
const outputPath = positionalArgs[0];
try {
await generateTemplate(outputPath, parsed.verbose as boolean);
} catch (error) {
console.error(
"Error:",
error instanceof Error ? error.message : String(error)
);
Deno.exit(1);
}
}
if (import.meta.main) {
main(Deno.args);
}
#!/usr/bin/env -S deno run --allow-read --allow-write
/**
* generate-thumbnails.ts - Extract and display PPTX slide information
*
* Extracts thumbnail and slide metadata from PowerPoint files.
* Provides visual preview information for template analysis.
*
* Usage:
* deno run --allow-read --allow-write scripts/generate-thumbnails.ts <input.pptx> [options]
*
* Options:
* -h, --help Show help
* -v, --verbose Enable verbose output
* --extract-thumb Extract presentation thumbnail to file
* --extract-images Extract all embedded images
* --output-dir <dir> Output directory for extracted files (default: current dir)
* --info Show slide information in JSON format
*
* Permissions:
* --allow-read: Read PPTX file
* --allow-write: Write extracted files
*
* Note: Full slide-by-slide thumbnail rendering requires external tools
* like LibreOffice. This script extracts built-in previews and metadata.
*/
import { parseArgs } from "jsr:@std/cli@1.0.9/parse-args";
import { basename, join } from "jsr:@std/path@1.0.8";
import JSZip from "npm:jszip@3.10.1";
import { DOMParser } from "npm:@xmldom/xmldom@0.9.6";
// === Types ===
export interface SlideInfo {
slideNumber: number;
title?: string;
textPreview?: string;
shapeCount: number;
imageCount: number;
hasNotes: boolean;
}
export interface EmbeddedImage {
filename: string;
path: string;
size: number;
type: string;
}
export interface PresentationInfo {
filename: string;
slideCount: number;
slideWidth: number;
slideHeight: number;
aspectRatio: string;
hasThumbnail: boolean;
thumbnailPath?: string;
slides: SlideInfo[];
embeddedImages: EmbeddedImage[];
}
interface ParsedArgs {
help: boolean;
verbose: boolean;
"extract-thumb": boolean;
"extract-images": boolean;
"output-dir": string;
info: boolean;
_: (string | number)[];
}
// === Constants ===
const VERSION = "1.0.0";
const SCRIPT_NAME = "generate-thumbnails";
const NS = {
a: "http://schemas.openxmlformats.org/drawingml/2006/main",
p: "http://schemas.openxmlformats.org/presentationml/2006/main",
r: "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
};
const EMU_PER_INCH = 914400;
// === Help Text ===
function printHelp(): void {
console.log(`
${SCRIPT_NAME} v${VERSION} - Extract thumbnails and slide info from PPTX
Usage:
deno run --allow-read --allow-write scripts/${SCRIPT_NAME}.ts <input.pptx> [options]
Arguments:
<input.pptx> Path to the PowerPoint file to analyze
Options:
-h, --help Show this help message
-v, --verbose Enable verbose output
--extract-thumb Extract presentation thumbnail to file
--extract-images Extract all embedded images
--output-dir <dir> Output directory (default: current directory)
--info Output presentation info as JSON (default behavior)
Output:
By default, outputs JSON with presentation metadata including:
- Slide count and dimensions
- Per-slide information (title, text preview, shape/image counts)
- List of embedded images
Examples:
# Get presentation info
deno run --allow-read scripts/${SCRIPT_NAME}.ts template.pptx
# Extract thumbnail
deno run --allow-read --allow-write scripts/${SCRIPT_NAME}.ts \\
template.pptx --extract-thumb --output-dir ./previews
# Extract all embedded images
deno run --allow-read --allow-write scripts/${SCRIPT_NAME}.ts \\
template.pptx --extract-images --output-dir ./images
Note:
For full slide-by-slide thumbnail rendering, use LibreOffice:
libreoffice --headless --convert-to pdf template.pptx
pdftoppm -png template.pdf slide
`);
}
// === Utility Functions ===
// deno-lint-ignore no-explicit-any
function getElementsByTagNameNS(
parent: any,
ns: string,
localName: string
// deno-lint-ignore no-explicit-any
): any[] {
const elements = parent.getElementsByTagNameNS(ns, localName);
// deno-lint-ignore no-explicit-any
return Array.from(elements) as any[];
}
function emuToInches(emu: number): number {
return Math.round((emu / EMU_PER_INCH) * 1000) / 1000;
}
function getAspectRatio(width: number, height: number): string {
const ratio = width / height;
if (Math.abs(ratio - 16 / 9) < 0.01) return "16:9";
if (Math.abs(ratio - 4 / 3) < 0.01) return "4:3";
if (Math.abs(ratio - 16 / 10) < 0.01) return "16:10";
return `${width.toFixed(2)}:${height.toFixed(2)}`;
}
// === Slide Analysis ===
function extractSlideTitle(slideXml: string): string | undefined {
const parser = new DOMParser();
const doc = parser.parseFromString(slideXml, "text/xml");
// Look for title placeholder
const shapes = getElementsByTagNameNS(doc, NS.p, "sp");
for (const shape of shapes) {
// Check if this is a title placeholder
const nvSpPr = getElementsByTagNameNS(shape, NS.p, "nvSpPr")[0];
if (!nvSpPr) continue;
const nvPr = getElementsByTagNameNS(nvSpPr, NS.p, "nvPr")[0];
if (!nvPr) continue;
const ph = getElementsByTagNameNS(nvPr, NS.p, "ph")[0];
if (!ph) continue;
const phType = ph.getAttribute("type");
if (phType === "title" || phType === "ctrTitle") {
// Extract text from this shape
const txBody = getElementsByTagNameNS(shape, NS.p, "txBody")[0];
if (!txBody) continue;
const textElements = getElementsByTagNameNS(txBody, NS.a, "t");
const text = textElements
.map((t) => t.textContent || "")
.join("")
.trim();
if (text) return text;
}
}
return undefined;
}
function extractTextPreview(slideXml: string, maxLength = 100): string | undefined {
const parser = new DOMParser();
const doc = parser.parseFromString(slideXml, "text/xml");
const textElements = getElementsByTagNameNS(doc, NS.a, "t");
const allText = textElements
.map((t) => t.textContent || "")
.join(" ")
.replace(/\s+/g, " ")
.trim();
if (!allText) return undefined;
if (allText.length <= maxLength) return allText;
return allText.substring(0, maxLength - 3) + "...";
}
function countShapes(slideXml: string): number {
const parser = new DOMParser();
const doc = parser.parseFromString(slideXml, "text/xml");
return getElementsByTagNameNS(doc, NS.p, "sp").length;
}
function countImages(slideXml: string): number {
const parser = new DOMParser();
const doc = parser.parseFromString(slideXml, "text/xml");
return getElementsByTagNameNS(doc, NS.p, "pic").length;
}
// === Core Logic ===
export async function analyzePresentationThumbnails(
pptxPath: string,
options: { verbose?: boolean } = {}
): Promise<PresentationInfo> {
const { verbose = false } = options;
// Read the PPTX file
const data = await Deno.readFile(pptxPath);
const zip = await JSZip.loadAsync(data);
const filename = basename(pptxPath);
// Check for thumbnail
const thumbnailFile = zip.file("docProps/thumbnail.jpeg") ||
zip.file("docProps/thumbnail.png");
const hasThumbnail = thumbnailFile !== null;
if (verbose) {
console.error(`Thumbnail present: ${hasThumbnail}`);
}
// Get presentation dimensions
let slideWidth = 10;
let slideHeight = 5.625;
const presFile = zip.file("ppt/presentation.xml");
if (presFile) {
const presXml = await presFile.async("string");
const parser = new DOMParser();
const doc = parser.parseFromString(presXml, "text/xml");
const sldSz = getElementsByTagNameNS(doc, NS.p, "sldSz")[0];
if (sldSz) {
const cx = parseInt(sldSz.getAttribute("cx") || "9144000", 10);
const cy = parseInt(sldSz.getAttribute("cy") || "5143500", 10);
slideWidth = emuToInches(cx);
slideHeight = emuToInches(cy);
}
}
// Find all slide files
const slideFiles: string[] = [];
zip.forEach((relativePath) => {
const match = relativePath.match(/^ppt\/slides\/slide(\d+)\.xml$/);
if (match) {
slideFiles.push(relativePath);
}
});
// Sort by slide number
slideFiles.sort((a, b) => {
const numA = parseInt(a.match(/slide(\d+)\.xml$/)?.[1] || "0", 10);
const numB = parseInt(b.match(/slide(\d+)\.xml$/)?.[1] || "0", 10);
return numA - numB;
});
if (verbose) {
console.error(`Found ${slideFiles.length} slides`);
}
// Analyze each slide
const slides: SlideInfo[] = [];
for (let i = 0; i < slideFiles.length; i++) {
const slideNum = i + 1;
const slideFile = zip.file(slideFiles[i]);
if (!slideFile) continue;
const slideXml = await slideFile.async("string");
// Check for notes
const notesPath = `ppt/notesSlides/notesSlide${slideNum}.xml`;
const hasNotes = zip.file(notesPath) !== null;
const slideInfo: SlideInfo = {
slideNumber: slideNum,
title: extractSlideTitle(slideXml),
textPreview: extractTextPreview(slideXml),
shapeCount: countShapes(slideXml),
imageCount: countImages(slideXml),
hasNotes,
};
slides.push(slideInfo);
if (verbose) {
console.error(
`Slide ${slideNum}: "${slideInfo.title || "(no title)"}" - ${slideInfo.shapeCount} shapes, ${slideInfo.imageCount} images`
);
}
}
// Find embedded images
const embeddedImages: EmbeddedImage[] = [];
const mediaRegex = /^ppt\/media\/(.+)$/;
zip.forEach((path, file) => {
const match = path.match(mediaRegex);
if (match && !file.dir) {
const filename = match[1];
const ext = filename.split(".").pop()?.toLowerCase() || "";
let type = "unknown";
if (["png", "jpg", "jpeg", "gif", "bmp", "tiff"].includes(ext)) {
type = "image";
} else if (["wmf", "emf"].includes(ext)) {
type = "vector";
} else if (["mp4", "mov", "avi", "wmv"].includes(ext)) {
type = "video";
}
embeddedImages.push({
filename,
path,
size: 0, // Size is not available without decompressing
type,
});
}
});
if (verbose) {
console.error(`Found ${embeddedImages.length} embedded media files`);
}
return {
filename,
slideCount: slideFiles.length,
slideWidth,
slideHeight,
aspectRatio: getAspectRatio(slideWidth, slideHeight),
hasThumbnail,
thumbnailPath: hasThumbnail
? thumbnailFile?.name.endsWith(".png")
? "docProps/thumbnail.png"
: "docProps/thumbnail.jpeg"
: undefined,
slides,
embeddedImages,
};
}
export async function extractThumbnail(
pptxPath: string,
outputDir: string,
options: { verbose?: boolean } = {}
): Promise<string | null> {
const { verbose = false } = options;
const data = await Deno.readFile(pptxPath);
const zip = await JSZip.loadAsync(data);
const thumbnailJpeg = zip.file("docProps/thumbnail.jpeg");
const thumbnailPng = zip.file("docProps/thumbnail.png");
const thumbnailFile = thumbnailJpeg || thumbnailPng;
if (!thumbnailFile) {
if (verbose) {
console.error("No thumbnail found in presentation");
}
return null;
}
const ext = thumbnailJpeg ? "jpeg" : "png";
const baseName = basename(pptxPath, ".pptx");
const outputPath = join(outputDir, `${baseName}-thumbnail.${ext}`);
const thumbData = await thumbnailFile.async("uint8array");
await Deno.writeFile(outputPath, thumbData);
if (verbose) {
console.error(`Extracted thumbnail to: ${outputPath}`);
}
return outputPath;
}
export async function extractEmbeddedImages(
pptxPath: string,
outputDir: string,
options: { verbose?: boolean } = {}
): Promise<string[]> {
const { verbose = false } = options;
const data = await Deno.readFile(pptxPath);
const zip = await JSZip.loadAsync(data);
const baseName = basename(pptxPath, ".pptx");
const extractedPaths: string[] = [];
const mediaRegex = /^ppt\/media\/(.+)$/;
const files: { path: string; filename: string; file: JSZip.JSZipObject }[] = [];
zip.forEach((path, file) => {
const match = path.match(mediaRegex);
if (match && !file.dir) {
files.push({ path, filename: match[1], file });
}
});
for (const { filename, file } of files) {
const outputPath = join(outputDir, `${baseName}-${filename}`);
const fileData = await file.async("uint8array");
await Deno.writeFile(outputPath, fileData);
extractedPaths.push(outputPath);
if (verbose) {
console.error(`Extracted: ${outputPath}`);
}
}
if (verbose) {
console.error(`Extracted ${extractedPaths.length} media files`);
}
return extractedPaths;
}
// === Main CLI Handler ===
async function main(args: string[]): Promise<void> {
const parsed = parseArgs(args, {
boolean: ["help", "verbose", "extract-thumb", "extract-images", "info"],
string: ["output-dir"],
alias: { help: "h", verbose: "v" },
default: {
verbose: false,
"extract-thumb": false,
"extract-images": false,
"output-dir": ".",
info: true,
},
}) as ParsedArgs;
if (parsed.help) {
printHelp();
Deno.exit(0);
}
const positionalArgs = parsed._.map(String);
if (positionalArgs.length === 0) {
console.error("Error: No input file provided\n");
printHelp();
Deno.exit(1);
}
const inputPath = positionalArgs[0];
const outputDir = parsed["output-dir"];
try {
// Always analyze the presentation
const info = await analyzePresentationThumbnails(inputPath, {
verbose: parsed.verbose,
});
// Extract thumbnail if requested
if (parsed["extract-thumb"]) {
const thumbPath = await extractThumbnail(inputPath, outputDir, {
verbose: parsed.verbose,
});
if (thumbPath) {
info.thumbnailPath = thumbPath;
}
}
// Extract images if requested
if (parsed["extract-images"]) {
const extractedPaths = await extractEmbeddedImages(inputPath, outputDir, {
verbose: parsed.verbose,
});
// Update sizes for extracted images
for (const embeddedImage of info.embeddedImages) {
const extractedPath = extractedPaths.find((p) =>
p.includes(embeddedImage.filename)
);
if (extractedPath) {
try {
const stat = await Deno.stat(extractedPath);
embeddedImage.size = stat.size;
} catch {
// Ignore stat errors
}
}
}
}
// Output info as JSON
console.log(JSON.stringify(info, null, 2));
} catch (error) {
console.error(
"Error:",
error instanceof Error ? error.message : String(error)
);
Deno.exit(1);
}
}
// === Entry Point ===
if (import.meta.main) {
main(Deno.args);
}