feat: add PDF export for slide-deck and comic skills

This commit is contained in:
Jim Liu 宝玉 2026-01-17 12:08:08 -06:00
parent c731faea8f
commit bb4f0dc52c
7 changed files with 334 additions and 15 deletions

View File

@ -6,7 +6,7 @@
},
"metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "0.6.0"
"version": "0.6.1"
},
"plugins": [
{

View File

@ -71,6 +71,30 @@ npx -y bun skills/baoyu-gemini-web/scripts/main.ts --promptfiles system.md conte
2. Add TypeScript in `skills/baoyu-<name>/scripts/`
3. Add prompt templates in `skills/baoyu-<name>/prompts/` if needed
4. Register in `marketplace.json` plugins[0].skills array as `./skills/baoyu-<name>`
5. **Add Script Directory section** to SKILL.md (see template below)
### Script Directory Template
Every SKILL.md with scripts MUST include this section after Usage:
```markdown
## Script Directory
**Important**: All scripts are located in the `scripts/` subdirectory of this skill.
**Agent Execution Instructions**:
1. Determine this SKILL.md file's directory path as `SKILL_DIR`
2. Script path = `${SKILL_DIR}/scripts/<script-name>.ts`
3. Replace all `${SKILL_DIR}` in this document with the actual path
**Script Reference**:
| Script | Purpose |
|--------|---------|
| `scripts/main.ts` | Main entry point |
| `scripts/other.ts` | Other functionality |
```
When referencing scripts in workflow sections, use `${SKILL_DIR}/scripts/<name>.ts` so agents can resolve the correct path.
## Code Style

View File

@ -36,6 +36,20 @@ Style × Layout can be freely combined.
| Wine, food, business, lifestyle, professional | realistic | cinematic |
| Biography, balanced | classic | mixed |
## Script Directory
**Important**: All scripts are located in the `scripts/` subdirectory of this skill.
**Agent Execution Instructions**:
1. Determine this SKILL.md file's directory path as `SKILL_DIR`
2. Script path = `${SKILL_DIR}/scripts/<script-name>.ts`
3. Replace all `${SKILL_DIR}` in this document with the actual path
**Script Reference**:
| Script | Purpose |
|--------|---------|
| `scripts/merge-to-pdf.ts` | Merge comic pages into PDF |
## File Structure
```
@ -48,7 +62,8 @@ Style × Layout can be freely combined.
│ ├── 00-cover.md
│ └── XX-page.md
├── 00-cover.png
└── XX-page.png
├── XX-page.png
└── {topic-slug}.pdf
```
**Target directory**:
@ -99,12 +114,13 @@ For each page (cover + pages):
**Image Generation Skill Selection**:
- Check available image generation skills in the environment
- Adapt parameters based on skill capabilities:
- If supports `--promptfiles`: pass prompt files
- If supports reference image: pass `characters/characters.png`
- If text-only: concatenate prompts into single text
- If multiple skills available, ask user preference
**Character Reference Handling**:
- If skill supports reference image: pass `characters/characters.png` as reference image
- If skill does NOT support reference image: include `characters/characters.md` content in the prompt
- This ensures character visual consistency across all pages
**Session Management**:
If the image generation skill supports `--sessionId`:
1. Generate a unique session ID at the start (e.g., `comic-{topic-slug}-{timestamp}`)
@ -113,7 +129,17 @@ If the image generation skill supports `--sessionId`:
3. Report progress after each generation
### Step 5: Completion Report
### Step 5: Merge to PDF
After all images are generated, merge them into a PDF file:
```bash
npx -y bun ${SKILL_DIR}/scripts/merge-to-pdf.ts <comic-dir>
```
This creates `{topic-slug}.pdf` in the comic directory with all pages as full-page images.
### Step 6: Completion Report
```
Comic Complete!
@ -121,6 +147,7 @@ Title: [title] | Style: [style] | Pages: [count]
Location: [path]
✓ characters.png
✓ 00-cover.png ... XX-page.png
✓ {topic-slug}.pdf
```
## Style-Specific Guidelines

View File

@ -0,0 +1,116 @@
import { existsSync, readdirSync, readFileSync } from "fs";
import { join, basename } from "path";
import { PDFDocument } from "pdf-lib";
interface PageInfo {
filename: string;
path: string;
index: number;
promptPath?: string;
}
function parseArgs(): { dir: string; output?: string } {
const args = process.argv.slice(2);
let dir = "";
let output: string | undefined;
for (let i = 0; i < args.length; i++) {
if (args[i] === "--output" || args[i] === "-o") {
output = args[++i];
} else if (!args[i].startsWith("-")) {
dir = args[i];
}
}
if (!dir) {
console.error("Usage: bun merge-to-pdf.ts <comic-dir> [--output filename.pdf]");
process.exit(1);
}
return { dir, output };
}
function findComicPages(dir: string): PageInfo[] {
if (!existsSync(dir)) {
console.error(`Directory not found: ${dir}`);
process.exit(1);
}
const files = readdirSync(dir);
const pagePattern = /^(\d+)-(cover|page)\.(png|jpg|jpeg)$/i;
const promptsDir = join(dir, "prompts");
const hasPrompts = existsSync(promptsDir);
const pages: PageInfo[] = files
.filter((f) => pagePattern.test(f))
.map((f) => {
const match = f.match(pagePattern);
const baseName = f.replace(/\.(png|jpg|jpeg)$/i, "");
const promptPath = hasPrompts ? join(promptsDir, `${baseName}.md`) : undefined;
return {
filename: f,
path: join(dir, f),
index: parseInt(match![1], 10),
promptPath: promptPath && existsSync(promptPath) ? promptPath : undefined,
};
})
.sort((a, b) => a.index - b.index);
if (pages.length === 0) {
console.error(`No comic pages found in: ${dir}`);
console.error("Expected format: 00-cover.png, 01-page.png, etc.");
process.exit(1);
}
return pages;
}
async function createPdf(pages: PageInfo[], outputPath: string) {
const pdfDoc = await PDFDocument.create();
pdfDoc.setAuthor("baoyu-comic");
pdfDoc.setSubject("Generated Comic");
for (const page of pages) {
const imageData = readFileSync(page.path);
const ext = page.filename.toLowerCase();
const image = ext.endsWith(".png")
? await pdfDoc.embedPng(imageData)
: await pdfDoc.embedJpg(imageData);
const { width, height } = image;
const pdfPage = pdfDoc.addPage([width, height]);
pdfPage.drawImage(image, {
x: 0,
y: 0,
width,
height,
});
console.log(`Added: ${page.filename}${page.promptPath ? " (prompt available)" : ""}`);
}
const pdfBytes = await pdfDoc.save();
await Bun.write(outputPath, pdfBytes);
console.log(`\nCreated: ${outputPath}`);
console.log(`Total pages: ${pages.length}`);
}
async function main() {
const { dir, output } = parseArgs();
const pages = findComicPages(dir);
const dirName = basename(dir) === "comic" ? basename(join(dir, "..")) : basename(dir);
const outputPath = output || join(dir, `${dirName}.pdf`);
console.log(`Found ${pages.length} pages in: ${dir}\n`);
await createPdf(pages, outputPath);
}
main().catch((err) => {
console.error("Error:", err.message);
process.exit(1);
});

View File

@ -12,6 +12,21 @@ Supports:
- Multi-turn conversations within the same executor instance (`keepSession`)
- Experimental video generation (`generateVideo`) — Gemini may return an async placeholder; download might require Gemini web UI
## Script Directory
**Important**: All scripts are located in the `scripts/` subdirectory of this skill.
**Agent Execution Instructions**:
1. Determine this SKILL.md file's directory path as `SKILL_DIR`
2. Script path = `${SKILL_DIR}/scripts/<script-name>.ts`
3. Replace all `${SKILL_DIR}` in this document with the actual path
**Script Reference**:
| Script | Purpose |
|--------|---------|
| `scripts/main.ts` | CLI entry point for text/image generation |
| `scripts/executor.ts` | Programmatic Gemini executor API |
## Quick start
```bash

View File

@ -37,6 +37,21 @@ Transform content into professional slide deck images with flexible style option
/baoyu-slide-deck path/to/content.md --style storytelling --audience experts --slides 15
```
## Script Directory
**Important**: All scripts are located in the `scripts/` subdirectory of this skill.
**Agent Execution Instructions**:
1. Determine this SKILL.md file's directory path as `SKILL_DIR`
2. Script path = `${SKILL_DIR}/scripts/<script-name>.ts`
3. Replace all `${SKILL_DIR}` in this document with the actual path
**Script Reference**:
| Script | Purpose |
|--------|---------|
| `scripts/merge-to-pptx.ts` | Merge slides into PowerPoint |
| `scripts/merge-to-pdf.ts` | Merge slides into PDF |
## Options
| Option | Description |
@ -105,7 +120,9 @@ content-dir/
│ └── ...
├── 01-slide-cover.png
├── 02-slide-{slug}.png
└── ...
├── ...
├── {topic-slug}.pptx
└── {topic-slug}.pdf
```
### Without Content Path
@ -121,7 +138,9 @@ slide-outputs/
│ ├── 01-slide-cover.md
│ └── ...
├── 01-slide-cover.png
└── ...
├── ...
├── ai-future-trends.pptx
└── ai-future-trends.pdf
```
## Workflow
@ -282,17 +301,18 @@ If the image generation skill supports `--sessionId`:
3. Report progress: "Generated X/N"
4. Continue to next
### Step 6: Merge to PPTX
### Step 6: Merge to PPTX and PDF
After all images are generated, merge them into a PowerPoint file:
After all images are generated, merge them into PowerPoint and PDF files:
```bash
npx -y bun skills/baoyu-slide-deck/scripts/merge-to-pptx.ts <slide-deck-dir>
npx -y bun ${SKILL_DIR}/scripts/merge-to-pptx.ts <slide-deck-dir>
npx -y bun ${SKILL_DIR}/scripts/merge-to-pdf.ts <slide-deck-dir>
```
This creates `{topic-slug}.pptx` in the slide deck directory with:
- All images as full-bleed 16:9 slides
- Prompt content added as speaker notes (from `prompts/` directory)
This creates:
- `{topic-slug}.pptx` - PowerPoint with all images as full-bleed 16:9 slides and prompt content as speaker notes
- `{topic-slug}.pdf` - PDF with all images as full-page slides
### Step 7: Output Summary
@ -313,6 +333,7 @@ Slides: N total
Outline: outline.md
PPTX: {topic-slug}.pptx
PDF: {topic-slug}.pdf
```
## Content Rules

View File

@ -0,0 +1,116 @@
import { existsSync, readdirSync, readFileSync } from "fs";
import { join, basename } from "path";
import { PDFDocument, rgb } from "pdf-lib";
interface SlideInfo {
filename: string;
path: string;
index: number;
promptPath?: string;
}
function parseArgs(): { dir: string; output?: string } {
const args = process.argv.slice(2);
let dir = "";
let output: string | undefined;
for (let i = 0; i < args.length; i++) {
if (args[i] === "--output" || args[i] === "-o") {
output = args[++i];
} else if (!args[i].startsWith("-")) {
dir = args[i];
}
}
if (!dir) {
console.error("Usage: bun merge-to-pdf.ts <slide-deck-dir> [--output filename.pdf]");
process.exit(1);
}
return { dir, output };
}
function findSlideImages(dir: string): SlideInfo[] {
if (!existsSync(dir)) {
console.error(`Directory not found: ${dir}`);
process.exit(1);
}
const files = readdirSync(dir);
const slidePattern = /^(\d+)-slide-.*\.(png|jpg|jpeg)$/i;
const promptsDir = join(dir, "prompts");
const hasPrompts = existsSync(promptsDir);
const slides: SlideInfo[] = files
.filter((f) => slidePattern.test(f))
.map((f) => {
const match = f.match(slidePattern);
const baseName = f.replace(/\.(png|jpg|jpeg)$/i, "");
const promptPath = hasPrompts ? join(promptsDir, `${baseName}.md`) : undefined;
return {
filename: f,
path: join(dir, f),
index: parseInt(match![1], 10),
promptPath: promptPath && existsSync(promptPath) ? promptPath : undefined,
};
})
.sort((a, b) => a.index - b.index);
if (slides.length === 0) {
console.error(`No slide images found in: ${dir}`);
console.error("Expected format: 01-slide-*.png, 02-slide-*.png, etc.");
process.exit(1);
}
return slides;
}
async function createPdf(slides: SlideInfo[], outputPath: string) {
const pdfDoc = await PDFDocument.create();
pdfDoc.setAuthor("baoyu-slide-deck");
pdfDoc.setSubject("Generated Slide Deck");
for (const slide of slides) {
const imageData = readFileSync(slide.path);
const ext = slide.filename.toLowerCase();
const image = ext.endsWith(".png")
? await pdfDoc.embedPng(imageData)
: await pdfDoc.embedJpg(imageData);
const { width, height } = image;
const page = pdfDoc.addPage([width, height]);
page.drawImage(image, {
x: 0,
y: 0,
width,
height,
});
console.log(`Added: ${slide.filename}${slide.promptPath ? " (prompt available)" : ""}`);
}
const pdfBytes = await pdfDoc.save();
await Bun.write(outputPath, pdfBytes);
console.log(`\nCreated: ${outputPath}`);
console.log(`Total pages: ${slides.length}`);
}
async function main() {
const { dir, output } = parseArgs();
const slides = findSlideImages(dir);
const dirName = basename(dir) === "slide-deck" ? basename(join(dir, "..")) : basename(dir);
const outputPath = output || join(dir, `${dirName}.pdf`);
console.log(`Found ${slides.length} slides in: ${dir}\n`);
await createPdf(slides, outputPath);
}
main().catch((err) => {
console.error("Error:", err.message);
process.exit(1);
});