Skip to content

Commit 78b1a7d

Browse files
tomtevclaude
andcommitted
v0.26.0 — PPTX/PDF rendering, update checker uses GitHub
- Add PPTX renderer (hybrid PNG background + editable text overlays) - Add PDF renderer for image templates - Version check now fetches from GitHub releases instead of npm - Update install instructions to use curl installer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 374a268 commit 78b1a7d

File tree

12 files changed

+781
-97
lines changed

12 files changed

+781
-97
lines changed

package-lock.json

Lines changed: 206 additions & 65 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "loopwind",
3-
"version": "0.25.11",
3+
"version": "0.26.0",
44
"description": "A CLI tool for generating images and videos from JSX templates using Tailwind CSS.",
55
"type": "module",
66
"bin": {
@@ -50,6 +50,7 @@
5050
"loopwind": "^0.25.7",
5151
"open": "^10.0.0",
5252
"ora": "^8.0.1",
53+
"pdf-lib": "^1.17.1",
5354
"pptxgenjs": "^4.0.1",
5455
"qrcode": "^1.5.4",
5556
"react": "^18.2.0",

src/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ program
4242
.description('Render a template file to an image or video')
4343
.option('-p, --props <file>', 'Props file (JSON) - deprecated, use positional argument instead')
4444
.option('-o, --out <file>', 'Output file path')
45-
.option('--format <format>', 'Output format (png, svg, webp, jpg, jpeg)', 'png')
45+
.option('--format <format>', 'Output format (png, svg, webp, jpg, jpeg, pptx, pdf)', 'png')
4646
.option('--frames-only', 'Render video frames only (no encoding)')
4747
.option('--crf <number>', 'Video quality (0-51, lower = better quality)', '23')
4848
.option('--ffmpeg', 'Use FFmpeg for faster encoding (requires FFmpeg installed)')

src/commands/render.ts

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { checkForUpdates } from '../lib/version-check.js';
1212
interface RenderOptions {
1313
props?: string;
1414
out?: string;
15-
format?: 'png' | 'svg' | 'webp' | 'jpg' | 'jpeg' | 'mp4' | 'gif';
15+
format?: 'png' | 'svg' | 'webp' | 'jpg' | 'jpeg' | 'mp4' | 'gif' | 'pptx' | 'pdf';
1616
framesOnly?: boolean;
1717
crf?: number;
1818
ffmpeg?: boolean;
@@ -32,7 +32,7 @@ export async function renderCommand(
3232
const result = await updateCheck;
3333
if (result?.isOutdated) {
3434
console.log(chalk.dim(`Update available: ${result.currentVersion}${chalk.green(result.latestVersion)}`));
35-
console.log(chalk.dim(`Run: ${chalk.cyan('npm install -g loopwind@latest')}\n`));
35+
console.log(chalk.dim(`Run: ${chalk.cyan('curl -fsSL https://loopwind.dev/install.sh | bash')}\n`));
3636
}
3737
};
3838

@@ -66,14 +66,68 @@ export async function renderCommand(
6666
const meta = await loadTemplateMeta(templateName);
6767
const templateType = meta.type || 'image';
6868

69-
// Check for unsupported types
70-
if (templateType === 'presentation') {
71-
spinner.fail(chalk.yellow('Presentation rendering is not yet implemented'));
72-
console.log(chalk.dim(`\nPresentation rendering will be available in a future release.`));
73-
console.log(chalk.dim(`Presentations will export multi-page PDFs with React + Tailwind.\n`));
69+
// Guard: --format pptx on non-presentation template
70+
if (options.format === 'pptx' && templateType !== 'presentation') {
71+
spinner.fail(chalk.red(`--format pptx is only supported for presentation templates`));
72+
console.log(chalk.dim(`\nTemplate "${templateName}" is type "${templateType}".`));
73+
console.log(chalk.dim(`To create a presentation, set meta.type = "presentation" and define meta.presentation.slides.\n`));
7474
process.exit(1);
7575
}
7676

77+
// Handle presentation templates
78+
if (templateType === 'presentation') {
79+
const { renderPptx } = await import('../lib/pptx-renderer.js');
80+
81+
// Parse props
82+
spinner.text = 'Loading props...';
83+
const propsInput = propsArg || options.props;
84+
const props = await parseProps(propsInput);
85+
86+
// Validate template and props
87+
spinner.text = 'Validating template...';
88+
const validation = await validateTemplateForRendering(templateName, props);
89+
90+
if (!validation.valid) {
91+
spinner.fail(chalk.red('Template validation failed'));
92+
console.log();
93+
for (const error of validation.errors) {
94+
console.log(chalk.red(` ✗ ${error.field}: ${error.message}`));
95+
if (error.suggestion) {
96+
console.log(chalk.dim(` → ${error.suggestion}`));
97+
}
98+
console.log();
99+
}
100+
console.log(chalk.yellow('Fix the errors above and try again.\n'));
101+
process.exit(1);
102+
}
103+
104+
const baseName = templateName.endsWith('.tsx')
105+
? path.basename(templateName, '.tsx')
106+
: templateName;
107+
const outputPath = options.out || `${baseName}.pptx`;
108+
109+
spinner.text = 'Rendering presentation slides...';
110+
const slideCount = meta.presentation?.slides?.length ?? 0;
111+
112+
await renderPptx(templateName, props, outputPath, {
113+
onProgress: (slide, total, phase) => {
114+
if (phase === 'render') {
115+
spinner.text = `Rendering slide ${slide}/${total}...`;
116+
} else {
117+
spinner.text = `Assembling PPTX (${total} slides)...`;
118+
}
119+
},
120+
});
121+
122+
spinner.succeed(chalk.green(`Successfully rendered PPTX to ${chalk.bold(outputPath)}`));
123+
console.log(chalk.dim(`\nTemplate: ${templateName}`));
124+
console.log(chalk.dim(`Slides: ${slideCount}`));
125+
console.log(chalk.dim(`Output: ${path.resolve(outputPath)}\n`));
126+
127+
await showUpdateNotification();
128+
return;
129+
}
130+
77131
if (templateType === 'website') {
78132
spinner.fail(chalk.yellow('Website rendering is not yet implemented'));
79133
console.log(chalk.dim(`\nWebsite rendering will be available in a future release.`));
@@ -210,9 +264,27 @@ export async function renderCommand(
210264
return;
211265
}
212266

267+
// Handle PDF format for image templates
268+
if (options.format === 'pdf') {
269+
const { renderPdf } = await import('../lib/pdf-renderer.js');
270+
271+
const outputPath = options.out || `${baseName}.pdf`;
272+
273+
spinner.text = 'Rendering to PDF...';
274+
await renderPdf(templateName, props, outputPath);
275+
276+
spinner.succeed(chalk.green(`Successfully rendered PDF to ${chalk.bold(outputPath)}`));
277+
console.log(chalk.dim(`\nTemplate: ${templateName}`));
278+
console.log(chalk.dim(`Format: PDF`));
279+
console.log(chalk.dim(`Output: ${path.resolve(outputPath)}\n`));
280+
281+
await showUpdateNotification();
282+
return;
283+
}
284+
213285
// Handle image templates
214286
// Filter out video formats for image templates
215-
const imageFormat = (options.format === 'mp4' || options.format === 'gif')
287+
const imageFormat = (options.format === 'mp4' || options.format === 'gif' || options.format === 'pptx')
216288
? 'png'
217289
: (options.format || 'png');
218290
const defaultFileName = `${baseName}.${imageFormat}`;

src/lib/pdf-renderer.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { PDFDocument } from 'pdf-lib';
2+
import fs from 'fs/promises';
3+
import { renderToPNG } from './renderer.js';
4+
import { loadTemplateMeta } from './utils.js';
5+
import type { TemplateProps } from '../types/template.js';
6+
7+
/**
8+
* Render a template to a PDF file.
9+
*
10+
* Renders the template to PNG via the existing pipeline,
11+
* then embeds the PNG into a single-page PDF sized to match
12+
* the template dimensions.
13+
*/
14+
export async function renderPdf(
15+
templateName: string,
16+
props: TemplateProps,
17+
outputPath: string,
18+
): Promise<void> {
19+
const meta = await loadTemplateMeta(templateName);
20+
const width = meta.size?.width ?? 1920;
21+
const height = meta.size?.height ?? 1080;
22+
23+
// Render template to PNG buffer
24+
const pngBuffer = await renderToPNG(templateName, props);
25+
26+
// Create PDF with page sized to template dimensions (72 DPI points)
27+
const pdf = await PDFDocument.create();
28+
const pngImage = await pdf.embedPng(pngBuffer);
29+
const page = pdf.addPage([width, height]);
30+
page.drawImage(pngImage, { x: 0, y: 0, width, height });
31+
32+
const pdfBytes = await pdf.save();
33+
await fs.writeFile(outputPath, pdfBytes);
34+
}

0 commit comments

Comments
 (0)