init: huashu-design skill v1.0

花叔设计工作台 —— AI 驱动的 HTML 设计工作流,支持 Claude Code / Codex /
Cursor / Trae / OpenClaw / Hermes Agent 等任意 skill-compatible agent。

四条主干能力:
- HTML 高保真视觉产出(原型/App/幻灯片/动画/变体/信息图)
- 设计方向顾问 Fallback(5 流派 × 20 设计哲学 × 24 预制 showcase)
- Junior Designer 工作流(先 show 假设再迭代)
- 专家级 5 维度评审(哲学/层级/执行/功能/创新)

工程工具链:
- HTML → 25fps/60fps MP4 + palette GIF,含 6 首场景化 BGM
- HTML → PDF / PPTX 导出
- Playwright 截图 + 控制台错误验证
- iOS/Android/macOS/Browser 设备边框 + Stage/Sprite 动画引擎

初版包含 SKILL.md + 13 份 references + 7 个 scripts + 18 类 assets。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
alchainandClaude Opus 4.7 committed 2026-04-19 22:41:51 +08:00
commit 7d7bab4a82
89 files changed
+19270

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Mix a BGM track into an MP4 video.
#
# Usage:
# bash add-music.sh <input.mp4> [--mood=<name>] [--music=<path>] [--out=<path>]
#
# Mood library (in ../assets/, matching bgm-<mood>.mp3):
# tech — Apple Silicon / product keynote vibe, minimal synth+piano (default)
# ad — upbeat modern, clear build + drop, social-media ad energy
# educational — warm, patient, inviting learning tone
# educational-alt — alternate take of educational
# tutorial — lo-fi background, stays out of voiceover's way
# tutorial-alt — alternate take of tutorial
#
# Flags (all optional):
# --mood=<name> pick a preset from the library (default: tech)
# --music=<path> override with your own audio file (wins over --mood)
# --out=<path> output path (default: <input-basename>-bgm.mp4)
#
# Legacy positional form still works: bash add-music.sh in.mp4 music.mp3 out.mp4
#
# Behavior:
# - Music is trimmed to match video duration
# - 0.3s fade in, 1.0s fade out (avoids hard cuts)
# - Video stream copied (no re-encode), audio AAC 192k
#
# Examples:
# bash add-music.sh my.mp4 # default: tech mood
# bash add-music.sh my.mp4 --mood=ad # switch mood
# bash add-music.sh my.mp4 --mood=educational --out=final.mp4
# bash add-music.sh my.mp4 --music=~/Downloads/song.mp3 # bring your own
#
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ASSETS_DIR="$SCRIPT_DIR/../assets"
# ── Parse args ───────────────────────────────────────────────────────
INPUT=""
MOOD="tech"
CUSTOM_MUSIC=""
OUTPUT=""
POSITIONAL=()
for arg in "$@"; do
case "$arg" in
--mood=*) MOOD="${arg#*=}" ;;
--music=*) CUSTOM_MUSIC="${arg#*=}" ;;
--out=*) OUTPUT="${arg#*=}" ;;
*) POSITIONAL+=("$arg") ;;
esac
done
# Legacy positional: <input> [music] [output]
INPUT="${POSITIONAL[0]}"
[ -z "$CUSTOM_MUSIC" ] && [ -n "${POSITIONAL[1]}" ] && CUSTOM_MUSIC="${POSITIONAL[1]}"
[ -z "$OUTPUT" ] && [ -n "${POSITIONAL[2]}" ] && OUTPUT="${POSITIONAL[2]}"
if [ -z "$INPUT" ] || [ ! -f "$INPUT" ]; then
echo "Usage: bash add-music.sh <input.mp4> [--mood=<name>] [--music=<path>] [--out=<path>]" >&2
echo "Moods available: $(ls "$ASSETS_DIR" | grep -E '^bgm-.*\.mp3$' | sed 's/^bgm-//;s/\.mp3$//' | tr '\n' ' ')" >&2
exit 1
fi
# ── Resolve music source: --music wins, else --mood ─────────────────
if [ -n "$CUSTOM_MUSIC" ]; then
MUSIC="$CUSTOM_MUSIC"
SOURCE_LABEL="custom: $MUSIC"
else
MUSIC="$ASSETS_DIR/bgm-${MOOD}.mp3"
SOURCE_LABEL="mood: $MOOD"
fi
if [ ! -f "$MUSIC" ]; then
echo "✗ Music not found: $MUSIC" >&2
echo " Available moods: $(ls "$ASSETS_DIR" | grep -E '^bgm-.*\.mp3$' | sed 's/^bgm-//;s/\.mp3$//' | tr '\n' ' ')" >&2
exit 1
fi
# ── Resolve output path ─────────────────────────────────────────────
INPUT_DIR="$(cd "$(dirname "$INPUT")" && pwd)"
INPUT_NAME="$(basename "$INPUT" .mp4)"
[ -z "$OUTPUT" ] && OUTPUT="$INPUT_DIR/$INPUT_NAME-bgm.mp4"
# ── Measure video duration, compute fade-out start ──────────────────
DURATION=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$INPUT")
if [ -z "$DURATION" ]; then
echo "✗ Could not read video duration" >&2
exit 1
fi
FADE_OUT_START=$(awk "BEGIN { d = $DURATION - 1; if (d < 0) d = 0; print d }")
echo "▸ Mixing BGM into video"
echo " input: $INPUT"
echo " music: $SOURCE_LABEL"
echo " duration: ${DURATION}s"
echo " output: $OUTPUT"
ffmpeg -y -loglevel error \
-i "$INPUT" \
-i "$MUSIC" \
-filter_complex "[1:a]atrim=0:${DURATION},asetpts=PTS-STARTPTS,afade=t=in:st=0:d=0.3,afade=t=out:st=${FADE_OUT_START}:d=1[a]" \
-map 0:v -map "[a]" \
-c:v copy -c:a aac -b:a 192k -shortest \
"$OUTPUT"
SIZE=$(du -h "$OUTPUT" | cut -f1)
echo "✓ Done: $OUTPUT ($SIZE)"
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
# Convert MP4 animations to 60fps MP4 (via minterpolate) and optimized GIF.
#
# Usage:
# ./convert-formats.sh input.mp4 [gif_width]
#
# Produces next to the input:
# <name>-60fps.mp4 (1920x1080, 60fps, motion-interpolated)
# <name>.gif (scaled width, 15fps, palette-optimized)
#
# minterpolate flags:
# mi_mode=mci motion compensation interpolation
# mc_mode=aobmc adaptive overlapped block motion comp
# me_mode=bidir bidirectional motion estimation
# vsbmc=1 variable-size block motion comp
# GIF uses two-pass palette:
# pass 1: palettegen with stats_mode=diff (per-video optimal palette)
# pass 2: paletteuse with bayer dither + rectangle diff
# This keeps 30s/1080p animations GIF under ~4MB with good color fidelity.
set -e
INPUT="${1:?Usage: $0 input.mp4 [gif_width]}"
GIF_WIDTH="${2:-960}"
DIR=$(dirname "$INPUT")
BASE=$(basename "$INPUT" .mp4)
OUT60="$DIR/$BASE-60fps.mp4"
OUTGIF="$DIR/$BASE.gif"
PAL="$DIR/.palette-$BASE.png"
echo "▸ 60fps interpolate: $OUT60"
ffmpeg -y -loglevel error -i "$INPUT" \
-vf "minterpolate=fps=60:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1" \
-c:v libx264 -pix_fmt yuv420p -crf 18 -preset medium -movflags +faststart \
"$OUT60"
MP4_SIZE=$(du -h "$OUT60" | cut -f1)
echo " ✓ $MP4_SIZE"
echo "▸ GIF (${GIF_WIDTH}w, 15fps, palette-optimized): $OUTGIF"
# Pass 1: generate palette tailored to this video
ffmpeg -y -loglevel error -i "$INPUT" \
-vf "fps=15,scale=${GIF_WIDTH}:-1:flags=lanczos,palettegen=stats_mode=diff" \
"$PAL"
# Pass 2: apply palette with dithering
ffmpeg -y -loglevel error -i "$INPUT" -i "$PAL" \
-lavfi "fps=15,scale=${GIF_WIDTH}:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" \
"$OUTGIF"
rm -f "$PAL"
GIF_SIZE=$(du -h "$OUTGIF" | cut -f1)
echo " ✓ $GIF_SIZE"
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env node
/**
* export_deck_pdf.mjs — 把多文件 slide deck 导出为单个矢量 PDF
*
* 用法:
* node export_deck_pdf.mjs --slides <dir> --out <file.pdf> [--width 1920] [--height 1080]
*
* 特点:
* - 文字保留矢量(可复制、可搜索)
* - 背景/图形 1:1 保真(Playwright 内嵌 Chromium 渲染)
* - 不需要对 HTML 做任何改造
* - 视觉损失 = 0(PDF 就是浏览器打印出来的)
*
* trade-off:
* - PDF 不可再编辑文字(要改回到 HTML 改)
*
* 依赖:playwright pdf-lib
* npm install playwright pdf-lib
*
* 会按文件名排序(01-xxx.html → 02-xxx.html → ...)
*/
import { chromium } from 'playwright';
import { PDFDocument } from 'pdf-lib';
import fs from 'fs/promises';
import path from 'path';
function parseArgs() {
const args = { width: 1920, height: 1080 };
const a = process.argv.slice(2);
for (let i = 0; i < a.length; i += 2) {
const k = a[i].replace(/^--/, '');
args[k] = a[i + 1];
}
if (!args.slides || !args.out) {
console.error('用法: node export_deck_pdf.mjs --slides <dir> --out <file.pdf> [--width 1920] [--height 1080]');
process.exit(1);
}
args.width = parseInt(args.width);
args.height = parseInt(args.height);
return args;
}
async function main() {
const { slides, out, width, height } = parseArgs();
const slidesDir = path.resolve(slides);
const outFile = path.resolve(out);
const files = (await fs.readdir(slidesDir))
.filter(f => f.endsWith('.html'))
.sort();
if (!files.length) {
console.error(`No .html files found in ${slidesDir}`);
process.exit(1);
}
console.log(`Found ${files.length} slides in ${slidesDir}`);
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width, height } });
// 1) Render each HTML to its own PDF buffer
const pageBuffers = [];
for (const f of files) {
const page = await ctx.newPage();
const url = 'file://' + path.join(slidesDir, f);
await page.goto(url, { waitUntil: 'networkidle' }).catch(() => page.goto(url));
await page.waitForTimeout(1200); // web-font paint
// emulate "screen" so CSS colors/backgrounds render the same as browser
await page.emulateMedia({ media: 'screen' });
const buf = await page.pdf({
width: `${width}px`,
height: `${height}px`,
printBackground: true,
margin: { top: 0, right: 0, bottom: 0, left: 0 },
preferCSSPageSize: false,
});
pageBuffers.push(buf);
await page.close();
console.log(` [${pageBuffers.length}/${files.length}] ${f}`);
}
await browser.close();
// 2) Merge into a single PDF
const merged = await PDFDocument.create();
for (const buf of pageBuffers) {
const src = await PDFDocument.load(buf);
const copied = await merged.copyPages(src, src.getPageIndices());
copied.forEach(p => merged.addPage(p));
}
const bytes = await merged.save();
await fs.writeFile(outFile, bytes);
const kb = (bytes.byteLength / 1024).toFixed(0);
console.log(`\n✓ Wrote ${outFile} (${kb} KB, ${files.length} pages, vector)`);
}
main().catch(e => { console.error(e); process.exit(1); });
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env node
/**
* export_deck_pptx.mjs — 把多文件 slide deck 导出为 PPTX(图片铺底)
*
* 用法:
* node export_deck_pptx.mjs --slides <dir> --out <file.pptx> [--width 1920] [--height 1080]
*
* 特点:
* - 每张 slide 截图成 PNG,满铺一张 PPTX 页面
* - 视觉 100% 保真(因为就是图片)
* - ⚠️ 文字不可编辑(文字变成了图片)
*
* 如果用户需要「可编辑文字」的 PPTX:
* ❌ 不要在 claude-design 的 HTML 上硬上 html2pptx——claude-design 的 HTML 视觉自由度高,
* 很少能满足 html2pptx 的严格约束(p 标签语法、无 ::before、无 span margin 等)。
* 实测 32 页里能 pass 的不到 30%,剩下的要逐页改造 + 逐页修字体溢出——工时失控。
* ✅ 正确做法:切换到 **huashu-slides** skill 的 Path A,按它的 HTML 格式**从头重构**
* 每一页。huashu-slides 的 HTML 从一开始就符合 html2pptx 约束,可 100% 导出可编辑 PPTX。
*
* 依赖:playwright pptxgenjs
* npm install playwright pptxgenjs
*
* 会按文件名排序(01-xxx.html → 02-xxx.html → ...)
*/
import { chromium } from 'playwright';
import pptxgen from 'pptxgenjs';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
function parseArgs() {
const args = { width: 1920, height: 1080 };
const a = process.argv.slice(2);
for (let i = 0; i < a.length; i += 2) {
const k = a[i].replace(/^--/, '');
args[k] = a[i + 1];
}
if (!args.slides || !args.out) {
console.error('用法: node export_deck_pptx.mjs --slides <dir> --out <file.pptx> [--width 1920] [--height 1080]');
process.exit(1);
}
args.width = parseInt(args.width);
args.height = parseInt(args.height);
return args;
}
async function main() {
const { slides, out, width, height } = parseArgs();
const slidesDir = path.resolve(slides);
const outFile = path.resolve(out);
const files = (await fs.readdir(slidesDir))
.filter(f => f.endsWith('.html'))
.sort();
if (!files.length) {
console.error(`No .html files found in ${slidesDir}`);
process.exit(1);
}
console.log(`Found ${files.length} slides, rendering to PNG...`);
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width, height } });
const page = await ctx.newPage();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'deck-pptx-'));
const pngs = [];
for (const f of files) {
const url = 'file://' + path.join(slidesDir, f);
await page.goto(url, { waitUntil: 'networkidle' }).catch(() => page.goto(url));
await page.waitForTimeout(1200);
const out = path.join(tmpDir, f.replace(/\.html$/, '.png'));
await page.screenshot({ path: out, fullPage: false });
pngs.push(out);
console.log(` [${pngs.length}/${files.length}] ${f}`);
}
await browser.close();
// Build PPTX
const pres = new pptxgen();
pres.defineLayout({ name: 'DECK', width: width / 96, height: height / 96 });
pres.layout = 'DECK';
for (const png of pngs) {
const s = pres.addSlide();
s.addImage({ path: png, x: 0, y: 0, w: pres.width, h: pres.height });
}
await pres.writeFile({ fileName: outFile });
// cleanup
for (const p of pngs) await fs.unlink(p).catch(() => {});
await fs.rmdir(tmpDir).catch(() => {});
console.log(`\n✓ Wrote ${outFile} (${files.length} slides, image mode)`);
console.log(` 要可编辑?改走 huashu-slides 的 Path A 从头重构 HTML。`);
}
main().catch(e => { console.error(e); process.exit(1); });
+979
View File
@@ -0,0 +1,979 @@
/**
* html2pptx - Convert HTML slide to pptxgenjs slide with positioned elements
*
* USAGE:
* const pptx = new pptxgen();
* pptx.layout = 'LAYOUT_16x9'; // Must match HTML body dimensions
*
* const { slide, placeholders } = await html2pptx('slide.html', pptx);
* slide.addChart(pptx.charts.LINE, data, placeholders[0]);
*
* await pptx.writeFile('output.pptx');
*
* FEATURES:
* - Converts HTML to PowerPoint with accurate positioning
* - Supports text, images, shapes, and bullet lists
* - Extracts placeholder elements (class="placeholder") with positions
* - Handles CSS gradients, borders, and margins
*
* VALIDATION:
* - Uses body width/height from HTML for viewport sizing
* - Throws error if HTML dimensions don't match presentation layout
* - Throws error if content overflows body (with overflow details)
*
* RETURNS:
* { slide, placeholders } where placeholders is an array of { id, x, y, w, h }
*/
const { chromium } = require('playwright');
const path = require('path');
const sharp = require('sharp');
const PT_PER_PX = 0.75;
const PX_PER_IN = 96;
const EMU_PER_IN = 914400;
// Helper: Get body dimensions and check for overflow
async function getBodyDimensions(page) {
const bodyDimensions = await page.evaluate(() => {
const body = document.body;
const style = window.getComputedStyle(body);
return {
width: parseFloat(style.width),
height: parseFloat(style.height),
scrollWidth: body.scrollWidth,
scrollHeight: body.scrollHeight
};
});
const errors = [];
const widthOverflowPx = Math.max(0, bodyDimensions.scrollWidth - bodyDimensions.width - 1);
const heightOverflowPx = Math.max(0, bodyDimensions.scrollHeight - bodyDimensions.height - 1);
const widthOverflowPt = widthOverflowPx * PT_PER_PX;
const heightOverflowPt = heightOverflowPx * PT_PER_PX;
if (widthOverflowPt > 0 || heightOverflowPt > 0) {
const directions = [];
if (widthOverflowPt > 0) directions.push(`${widthOverflowPt.toFixed(1)}pt horizontally`);
if (heightOverflowPt > 0) directions.push(`${heightOverflowPt.toFixed(1)}pt vertically`);
const reminder = heightOverflowPt > 0 ? ' (Remember: leave 0.5" margin at bottom of slide)' : '';
errors.push(`HTML content overflows body by ${directions.join(' and ')}${reminder}`);
}
return { ...bodyDimensions, errors };
}
// Helper: Validate dimensions match presentation layout
function validateDimensions(bodyDimensions, pres) {
const errors = [];
const widthInches = bodyDimensions.width / PX_PER_IN;
const heightInches = bodyDimensions.height / PX_PER_IN;
if (pres.presLayout) {
const layoutWidth = pres.presLayout.width / EMU_PER_IN;
const layoutHeight = pres.presLayout.height / EMU_PER_IN;
if (Math.abs(layoutWidth - widthInches) > 0.1 || Math.abs(layoutHeight - heightInches) > 0.1) {
errors.push(
`HTML dimensions (${widthInches.toFixed(1)}" × ${heightInches.toFixed(1)}") ` +
`don't match presentation layout (${layoutWidth.toFixed(1)}" × ${layoutHeight.toFixed(1)}")`
);
}
}
return errors;
}
function validateTextBoxPosition(slideData, bodyDimensions) {
const errors = [];
const slideHeightInches = bodyDimensions.height / PX_PER_IN;
const minBottomMargin = 0.5; // 0.5 inches from bottom
for (const el of slideData.elements) {
// Check text elements (p, h1-h6, list)
if (['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'list'].includes(el.type)) {
const fontSize = el.style?.fontSize || 0;
const bottomEdge = el.position.y + el.position.h;
const distanceFromBottom = slideHeightInches - bottomEdge;
if (fontSize > 12 && distanceFromBottom < minBottomMargin) {
const getText = () => {
if (typeof el.text === 'string') return el.text;
if (Array.isArray(el.text)) return el.text.find(t => t.text)?.text || '';
if (Array.isArray(el.items)) return el.items.find(item => item.text)?.text || '';
return '';
};
const textPrefix = getText().substring(0, 50) + (getText().length > 50 ? '...' : '');
errors.push(
`Text box "${textPrefix}" ends too close to bottom edge ` +
`(${distanceFromBottom.toFixed(2)}" from bottom, minimum ${minBottomMargin}" required)`
);
}
}
}
return errors;
}
// Helper: Add background to slide
async function addBackground(slideData, targetSlide, tmpDir) {
if (slideData.background.type === 'image' && slideData.background.path) {
let imagePath = slideData.background.path.startsWith('file://')
? slideData.background.path.replace('file://', '')
: slideData.background.path;
targetSlide.background = { path: imagePath };
} else if (slideData.background.type === 'color' && slideData.background.value) {
targetSlide.background = { color: slideData.background.value };
}
}
// Helper: Add elements to slide
function addElements(slideData, targetSlide, pres) {
for (const el of slideData.elements) {
if (el.type === 'image') {
let imagePath = el.src.startsWith('file://') ? el.src.replace('file://', '') : el.src;
targetSlide.addImage({
path: imagePath,
x: el.position.x,
y: el.position.y,
w: el.position.w,
h: el.position.h
});
} else if (el.type === 'line') {
targetSlide.addShape(pres.ShapeType.line, {
x: el.x1,
y: el.y1,
w: el.x2 - el.x1,
h: el.y2 - el.y1,
line: { color: el.color, width: el.width }
});
} else if (el.type === 'shape') {
const shapeOptions = {
x: el.position.x,
y: el.position.y,
w: el.position.w,
h: el.position.h,
shape: el.shape.rectRadius > 0 ? pres.ShapeType.roundRect : pres.ShapeType.rect
};
if (el.shape.fill) {
shapeOptions.fill = { color: el.shape.fill };
if (el.shape.transparency != null) shapeOptions.fill.transparency = el.shape.transparency;
}
if (el.shape.line) shapeOptions.line = el.shape.line;
if (el.shape.rectRadius > 0) shapeOptions.rectRadius = el.shape.rectRadius;
if (el.shape.shadow) shapeOptions.shadow = el.shape.shadow;
targetSlide.addText(el.text || '', shapeOptions);
} else if (el.type === 'list') {
const listOptions = {
x: el.position.x,
y: el.position.y,
w: el.position.w,
h: el.position.h,
fontSize: el.style.fontSize,
fontFace: el.style.fontFace,
color: el.style.color,
align: el.style.align,
valign: 'top',
lineSpacing: el.style.lineSpacing,
paraSpaceBefore: el.style.paraSpaceBefore,
paraSpaceAfter: el.style.paraSpaceAfter,
margin: el.style.margin
};
if (el.style.margin) listOptions.margin = el.style.margin;
targetSlide.addText(el.items, listOptions);
} else {
// Check if text is single-line (height suggests one line)
const lineHeight = el.style.lineSpacing || el.style.fontSize * 1.2;
const isSingleLine = el.position.h <= lineHeight * 1.5;
let adjustedX = el.position.x;
let adjustedW = el.position.w;
// Make single-line text 2% wider to account for underestimate
if (isSingleLine) {
const widthIncrease = el.position.w * 0.02;
const align = el.style.align;
if (align === 'center') {
// Center: expand both sides
adjustedX = el.position.x - (widthIncrease / 2);
adjustedW = el.position.w + widthIncrease;
} else if (align === 'right') {
// Right: expand to the left
adjustedX = el.position.x - widthIncrease;
adjustedW = el.position.w + widthIncrease;
} else {
// Left (default): expand to the right
adjustedW = el.position.w + widthIncrease;
}
}
const textOptions = {
x: adjustedX,
y: el.position.y,
w: adjustedW,
h: el.position.h,
fontSize: el.style.fontSize,
fontFace: el.style.fontFace,
color: el.style.color,
bold: el.style.bold,
italic: el.style.italic,
underline: el.style.underline,
valign: 'top',
lineSpacing: el.style.lineSpacing,
paraSpaceBefore: el.style.paraSpaceBefore,
paraSpaceAfter: el.style.paraSpaceAfter,
inset: 0 // Remove default PowerPoint internal padding
};
if (el.style.align) textOptions.align = el.style.align;
if (el.style.margin) textOptions.margin = el.style.margin;
if (el.style.rotate !== undefined) textOptions.rotate = el.style.rotate;
if (el.style.transparency !== null && el.style.transparency !== undefined) textOptions.transparency = el.style.transparency;
targetSlide.addText(el.text, textOptions);
}
}
}
// Helper: Extract slide data from HTML page
async function extractSlideData(page) {
return await page.evaluate(() => {
const PT_PER_PX = 0.75;
const PX_PER_IN = 96;
// Fonts that are single-weight and should not have bold applied
// (applying bold causes PowerPoint to use faux bold which makes text wider)
const SINGLE_WEIGHT_FONTS = ['impact'];
// Helper: Check if a font should skip bold formatting
const shouldSkipBold = (fontFamily) => {
if (!fontFamily) return false;
const normalizedFont = fontFamily.toLowerCase().replace(/['"]/g, '').split(',')[0].trim();
return SINGLE_WEIGHT_FONTS.includes(normalizedFont);
};
// Unit conversion helpers
const pxToInch = (px) => px / PX_PER_IN;
const pxToPoints = (pxStr) => parseFloat(pxStr) * PT_PER_PX;
const rgbToHex = (rgbStr) => {
// Handle transparent backgrounds by defaulting to white
if (rgbStr === 'rgba(0, 0, 0, 0)' || rgbStr === 'transparent') return 'FFFFFF';
const match = rgbStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (!match) return 'FFFFFF';
return match.slice(1).map(n => parseInt(n).toString(16).padStart(2, '0')).join('');
};
const extractAlpha = (rgbStr) => {
const match = rgbStr.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)/);
if (!match || !match[4]) return null;
const alpha = parseFloat(match[4]);
return Math.round((1 - alpha) * 100);
};
const applyTextTransform = (text, textTransform) => {
if (textTransform === 'uppercase') return text.toUpperCase();
if (textTransform === 'lowercase') return text.toLowerCase();
if (textTransform === 'capitalize') {
return text.replace(/\b\w/g, c => c.toUpperCase());
}
return text;
};
// Extract rotation angle from CSS transform and writing-mode
const getRotation = (transform, writingMode) => {
let angle = 0;
// Handle writing-mode first
// PowerPoint: 90° = text rotated 90° clockwise (reads top to bottom, letters upright)
// PowerPoint: 270° = text rotated 270° clockwise (reads bottom to top, letters upright)
if (writingMode === 'vertical-rl') {
// vertical-rl alone = text reads top to bottom = 90° in PowerPoint
angle = 90;
} else if (writingMode === 'vertical-lr') {
// vertical-lr alone = text reads bottom to top = 270° in PowerPoint
angle = 270;
}
// Then add any transform rotation
if (transform && transform !== 'none') {
// Try to match rotate() function
const rotateMatch = transform.match(/rotate\((-?\d+(?:\.\d+)?)deg\)/);
if (rotateMatch) {
angle += parseFloat(rotateMatch[1]);
} else {
// Browser may compute as matrix - extract rotation from matrix
const matrixMatch = transform.match(/matrix\(([^)]+)\)/);
if (matrixMatch) {
const values = matrixMatch[1].split(',').map(parseFloat);
// matrix(a, b, c, d, e, f) where rotation = atan2(b, a)
const matrixAngle = Math.atan2(values[1], values[0]) * (180 / Math.PI);
angle += Math.round(matrixAngle);
}
}
}
// Normalize to 0-359 range
angle = angle % 360;
if (angle < 0) angle += 360;
return angle === 0 ? null : angle;
};
// Get position/dimensions accounting for rotation
const getPositionAndSize = (el, rect, rotation) => {
if (rotation === null) {
return { x: rect.left, y: rect.top, w: rect.width, h: rect.height };
}
// For 90° or 270° rotations, swap width and height
// because PowerPoint applies rotation to the original (unrotated) box
const isVertical = rotation === 90 || rotation === 270;
if (isVertical) {
// The browser shows us the rotated dimensions (tall box for vertical text)
// But PowerPoint needs the pre-rotation dimensions (wide box that will be rotated)
// So we swap: browser's height becomes PPT's width, browser's width becomes PPT's height
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
return {
x: centerX - rect.height / 2,
y: centerY - rect.width / 2,
w: rect.height,
h: rect.width
};
}
// For other rotations, use element's offset dimensions
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
return {
x: centerX - el.offsetWidth / 2,
y: centerY - el.offsetHeight / 2,
w: el.offsetWidth,
h: el.offsetHeight
};
};
// Parse CSS box-shadow into PptxGenJS shadow properties
const parseBoxShadow = (boxShadow) => {
if (!boxShadow || boxShadow === 'none') return null;
// Browser computed style format: "rgba(0, 0, 0, 0.3) 2px 2px 8px 0px [inset]"
// CSS format: "[inset] 2px 2px 8px 0px rgba(0, 0, 0, 0.3)"
const insetMatch = boxShadow.match(/inset/);
// IMPORTANT: PptxGenJS/PowerPoint doesn't properly support inset shadows
// Only process outer shadows to avoid file corruption
if (insetMatch) return null;
// Extract color first (rgba or rgb at start)
const colorMatch = boxShadow.match(/rgba?\([^)]+\)/);
// Extract numeric values (handles both px and pt units)
const parts = boxShadow.match(/([-\d.]+)(px|pt)/g);
if (!parts || parts.length < 2) return null;
const offsetX = parseFloat(parts[0]);
const offsetY = parseFloat(parts[1]);
const blur = parts.length > 2 ? parseFloat(parts[2]) : 0;
// Calculate angle from offsets (in degrees, 0 = right, 90 = down)
let angle = 0;
if (offsetX !== 0 || offsetY !== 0) {
angle = Math.atan2(offsetY, offsetX) * (180 / Math.PI);
if (angle < 0) angle += 360;
}
// Calculate offset distance (hypotenuse)
const offset = Math.sqrt(offsetX * offsetX + offsetY * offsetY) * PT_PER_PX;
// Extract opacity from rgba
let opacity = 0.5;
if (colorMatch) {
const opacityMatch = colorMatch[0].match(/[\d.]+\)$/);
if (opacityMatch) {
opacity = parseFloat(opacityMatch[0].replace(')', ''));
}
}
return {
type: 'outer',
angle: Math.round(angle),
blur: blur * 0.75, // Convert to points
color: colorMatch ? rgbToHex(colorMatch[0]) : '000000',
offset: offset,
opacity
};
};
// Parse inline formatting tags (<b>, <i>, <u>, <strong>, <em>, <span>) into text runs
const parseInlineFormatting = (element, baseOptions = {}, runs = [], baseTextTransform = (x) => x) => {
let prevNodeIsText = false;
element.childNodes.forEach((node) => {
let textTransform = baseTextTransform;
const isText = node.nodeType === Node.TEXT_NODE || node.tagName === 'BR';
if (isText) {
const text = node.tagName === 'BR' ? '\n' : textTransform(node.textContent.replace(/\s+/g, ' '));
const prevRun = runs[runs.length - 1];
if (prevNodeIsText && prevRun) {
prevRun.text += text;
} else {
runs.push({ text, options: { ...baseOptions } });
}
} else if (node.nodeType === Node.ELEMENT_NODE && node.textContent.trim()) {
const options = { ...baseOptions };
const computed = window.getComputedStyle(node);
// Handle inline elements with computed styles
if (node.tagName === 'SPAN' || node.tagName === 'B' || node.tagName === 'STRONG' || node.tagName === 'I' || node.tagName === 'EM' || node.tagName === 'U') {
const isBold = computed.fontWeight === 'bold' || parseInt(computed.fontWeight) >= 600;
if (isBold && !shouldSkipBold(computed.fontFamily)) options.bold = true;
if (computed.fontStyle === 'italic') options.italic = true;
if (computed.textDecoration && computed.textDecoration.includes('underline')) options.underline = true;
if (computed.color && computed.color !== 'rgb(0, 0, 0)') {
options.color = rgbToHex(computed.color);
const transparency = extractAlpha(computed.color);
if (transparency !== null) options.transparency = transparency;
}
if (computed.fontSize) options.fontSize = pxToPoints(computed.fontSize);
// Apply text-transform on the span element itself
if (computed.textTransform && computed.textTransform !== 'none') {
const transformStr = computed.textTransform;
textTransform = (text) => applyTextTransform(text, transformStr);
}
// Validate: Check for margins on inline elements
if (computed.marginLeft && parseFloat(computed.marginLeft) > 0) {
errors.push(`Inline element <${node.tagName.toLowerCase()}> has margin-left which is not supported in PowerPoint. Remove margin from inline elements.`);
}
if (computed.marginRight && parseFloat(computed.marginRight) > 0) {
errors.push(`Inline element <${node.tagName.toLowerCase()}> has margin-right which is not supported in PowerPoint. Remove margin from inline elements.`);
}
if (computed.marginTop && parseFloat(computed.marginTop) > 0) {
errors.push(`Inline element <${node.tagName.toLowerCase()}> has margin-top which is not supported in PowerPoint. Remove margin from inline elements.`);
}
if (computed.marginBottom && parseFloat(computed.marginBottom) > 0) {
errors.push(`Inline element <${node.tagName.toLowerCase()}> has margin-bottom which is not supported in PowerPoint. Remove margin from inline elements.`);
}
// Recursively process the child node. This will flatten nested spans into multiple runs.
parseInlineFormatting(node, options, runs, textTransform);
}
}
prevNodeIsText = isText;
});
// Trim leading space from first run and trailing space from last run
if (runs.length > 0) {
runs[0].text = runs[0].text.replace(/^\s+/, '');
runs[runs.length - 1].text = runs[runs.length - 1].text.replace(/\s+$/, '');
}
return runs.filter(r => r.text.length > 0);
};
// Extract background from body (image or color)
const body = document.body;
const bodyStyle = window.getComputedStyle(body);
const bgImage = bodyStyle.backgroundImage;
const bgColor = bodyStyle.backgroundColor;
// Collect validation errors
const errors = [];
// Validate: Check for CSS gradients
if (bgImage && (bgImage.includes('linear-gradient') || bgImage.includes('radial-gradient'))) {
errors.push(
'CSS gradients are not supported. Use Sharp to rasterize gradients as PNG images first, ' +
'then reference with background-image: url(\'gradient.png\')'
);
}
let background;
if (bgImage && bgImage !== 'none') {
// Extract URL from url("...") or url(...)
const urlMatch = bgImage.match(/url\(["']?([^"')]+)["']?\)/);
if (urlMatch) {
background = {
type: 'image',
path: urlMatch[1]
};
} else {
background = {
type: 'color',
value: rgbToHex(bgColor)
};
}
} else {
background = {
type: 'color',
value: rgbToHex(bgColor)
};
}
// Process all elements
const elements = [];
const placeholders = [];
const textTags = ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'UL', 'OL', 'LI'];
const processed = new Set();
document.querySelectorAll('*').forEach((el) => {
if (processed.has(el)) return;
// Validate text elements don't have backgrounds, borders, or shadows
if (textTags.includes(el.tagName)) {
const computed = window.getComputedStyle(el);
const hasBg = computed.backgroundColor && computed.backgroundColor !== 'rgba(0, 0, 0, 0)';
const hasBorder = (computed.borderWidth && parseFloat(computed.borderWidth) > 0) ||
(computed.borderTopWidth && parseFloat(computed.borderTopWidth) > 0) ||
(computed.borderRightWidth && parseFloat(computed.borderRightWidth) > 0) ||
(computed.borderBottomWidth && parseFloat(computed.borderBottomWidth) > 0) ||
(computed.borderLeftWidth && parseFloat(computed.borderLeftWidth) > 0);
const hasShadow = computed.boxShadow && computed.boxShadow !== 'none';
if (hasBg || hasBorder || hasShadow) {
errors.push(
`Text element <${el.tagName.toLowerCase()}> has ${hasBg ? 'background' : hasBorder ? 'border' : 'shadow'}. ` +
'Backgrounds, borders, and shadows are only supported on <div> elements, not text elements.'
);
return;
}
}
// Extract placeholder elements (for charts, etc.)
if (el.className && el.className.includes('placeholder')) {
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
errors.push(
`Placeholder "${el.id || 'unnamed'}" has ${rect.width === 0 ? 'width: 0' : 'height: 0'}. Check the layout CSS.`
);
} else {
placeholders.push({
id: el.id || `placeholder-${placeholders.length}`,
x: pxToInch(rect.left),
y: pxToInch(rect.top),
w: pxToInch(rect.width),
h: pxToInch(rect.height)
});
}
processed.add(el);
return;
}
// Extract images
if (el.tagName === 'IMG') {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
elements.push({
type: 'image',
src: el.src,
position: {
x: pxToInch(rect.left),
y: pxToInch(rect.top),
w: pxToInch(rect.width),
h: pxToInch(rect.height)
}
});
processed.add(el);
return;
}
}
// Extract DIVs with backgrounds/borders as shapes
const isContainer = el.tagName === 'DIV' && !textTags.includes(el.tagName);
if (isContainer) {
const computed = window.getComputedStyle(el);
const hasBg = computed.backgroundColor && computed.backgroundColor !== 'rgba(0, 0, 0, 0)';
// Validate: Check for unwrapped text content in DIV
for (const node of el.childNodes) {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent.trim();
if (text) {
errors.push(
`DIV element contains unwrapped text "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}". ` +
'All text must be wrapped in <p>, <h1>-<h6>, <ul>, or <ol> tags to appear in PowerPoint.'
);
}
}
}
// Check for background images on shapes
const bgImage = computed.backgroundImage;
if (bgImage && bgImage !== 'none') {
errors.push(
'Background images on DIV elements are not supported. ' +
'Use solid colors or borders for shapes, or use slide.addImage() in PptxGenJS to layer images.'
);
return;
}
// Check for borders - both uniform and partial
const borderTop = computed.borderTopWidth;
const borderRight = computed.borderRightWidth;
const borderBottom = computed.borderBottomWidth;
const borderLeft = computed.borderLeftWidth;
const borders = [borderTop, borderRight, borderBottom, borderLeft].map(b => parseFloat(b) || 0);
const hasBorder = borders.some(b => b > 0);
const hasUniformBorder = hasBorder && borders.every(b => b === borders[0]);
const borderLines = [];
if (hasBorder && !hasUniformBorder) {
const rect = el.getBoundingClientRect();
const x = pxToInch(rect.left);
const y = pxToInch(rect.top);
const w = pxToInch(rect.width);
const h = pxToInch(rect.height);
// Collect lines to add after shape (inset by half the line width to center on edge)
if (parseFloat(borderTop) > 0) {
const widthPt = pxToPoints(borderTop);
const inset = (widthPt / 72) / 2; // Convert points to inches, then half
borderLines.push({
type: 'line',
x1: x, y1: y + inset, x2: x + w, y2: y + inset,
width: widthPt,
color: rgbToHex(computed.borderTopColor)
});
}
if (parseFloat(borderRight) > 0) {
const widthPt = pxToPoints(borderRight);
const inset = (widthPt / 72) / 2;
borderLines.push({
type: 'line',
x1: x + w - inset, y1: y, x2: x + w - inset, y2: y + h,
width: widthPt,
color: rgbToHex(computed.borderRightColor)
});
}
if (parseFloat(borderBottom) > 0) {
const widthPt = pxToPoints(borderBottom);
const inset = (widthPt / 72) / 2;
borderLines.push({
type: 'line',
x1: x, y1: y + h - inset, x2: x + w, y2: y + h - inset,
width: widthPt,
color: rgbToHex(computed.borderBottomColor)
});
}
if (parseFloat(borderLeft) > 0) {
const widthPt = pxToPoints(borderLeft);
const inset = (widthPt / 72) / 2;
borderLines.push({
type: 'line',
x1: x + inset, y1: y, x2: x + inset, y2: y + h,
width: widthPt,
color: rgbToHex(computed.borderLeftColor)
});
}
}
if (hasBg || hasBorder) {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
const shadow = parseBoxShadow(computed.boxShadow);
// Only add shape if there's background or uniform border
if (hasBg || hasUniformBorder) {
elements.push({
type: 'shape',
text: '', // Shape only - child text elements render on top
position: {
x: pxToInch(rect.left),
y: pxToInch(rect.top),
w: pxToInch(rect.width),
h: pxToInch(rect.height)
},
shape: {
fill: hasBg ? rgbToHex(computed.backgroundColor) : null,
transparency: hasBg ? extractAlpha(computed.backgroundColor) : null,
line: hasUniformBorder ? {
color: rgbToHex(computed.borderColor),
width: pxToPoints(computed.borderWidth)
} : null,
// Convert border-radius to rectRadius (in inches)
// % values: 50%+ = circle (1), <50% = percentage of min dimension
// pt values: divide by 72 (72pt = 1 inch)
// px values: divide by 96 (96px = 1 inch)
rectRadius: (() => {
const radius = computed.borderRadius;
const radiusValue = parseFloat(radius);
if (radiusValue === 0) return 0;
if (radius.includes('%')) {
if (radiusValue >= 50) return 1;
// Calculate percentage of smaller dimension
const minDim = Math.min(rect.width, rect.height);
return (radiusValue / 100) * pxToInch(minDim);
}
if (radius.includes('pt')) return radiusValue / 72;
return radiusValue / PX_PER_IN;
})(),
shadow: shadow
}
});
}
// Add partial border lines
elements.push(...borderLines);
processed.add(el);
return;
}
}
}
// Extract bullet lists as single text block
if (el.tagName === 'UL' || el.tagName === 'OL') {
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const liElements = Array.from(el.querySelectorAll('li'));
const items = [];
const ulComputed = window.getComputedStyle(el);
const ulPaddingLeftPt = pxToPoints(ulComputed.paddingLeft);
// Split: margin-left for bullet position, indent for text position
// margin-left + indent = ul padding-left
const marginLeft = ulPaddingLeftPt * 0.5;
const textIndent = ulPaddingLeftPt * 0.5;
liElements.forEach((li, idx) => {
const isLast = idx === liElements.length - 1;
const runs = parseInlineFormatting(li, { breakLine: false });
// Clean manual bullets from first run
if (runs.length > 0) {
runs[0].text = runs[0].text.replace(/^[•\-\*▪▸]\s*/, '');
runs[0].options.bullet = { indent: textIndent };
}
// Set breakLine on last run
if (runs.length > 0 && !isLast) {
runs[runs.length - 1].options.breakLine = true;
}
items.push(...runs);
});
const computed = window.getComputedStyle(liElements[0] || el);
elements.push({
type: 'list',
items: items,
position: {
x: pxToInch(rect.left),
y: pxToInch(rect.top),
w: pxToInch(rect.width),
h: pxToInch(rect.height)
},
style: {
fontSize: pxToPoints(computed.fontSize),
fontFace: computed.fontFamily.split(',')[0].replace(/['"]/g, '').trim(),
color: rgbToHex(computed.color),
transparency: extractAlpha(computed.color),
align: computed.textAlign === 'start' ? 'left' : computed.textAlign,
lineSpacing: computed.lineHeight && computed.lineHeight !== 'normal' ? pxToPoints(computed.lineHeight) : null,
paraSpaceBefore: 0,
paraSpaceAfter: pxToPoints(computed.marginBottom),
// PptxGenJS margin array is [left, right, bottom, top]
margin: [marginLeft, 0, 0, 0]
}
});
liElements.forEach(li => processed.add(li));
processed.add(el);
return;
}
// Extract text elements (P, H1, H2, etc.)
if (!textTags.includes(el.tagName)) return;
const rect = el.getBoundingClientRect();
const text = el.textContent.trim();
if (rect.width === 0 || rect.height === 0 || !text) return;
// Validate: Check for manual bullet symbols in text elements (not in lists)
if (el.tagName !== 'LI' && /^[•\-\*▪▸○●◆◇■□]\s/.test(text.trimStart())) {
errors.push(
`Text element <${el.tagName.toLowerCase()}> starts with bullet symbol "${text.substring(0, 20)}...". ` +
'Use <ul> or <ol> lists instead of manual bullet symbols.'
);
return;
}
const computed = window.getComputedStyle(el);
const rotation = getRotation(computed.transform, computed.writingMode);
const { x, y, w, h } = getPositionAndSize(el, rect, rotation);
const baseStyle = {
fontSize: pxToPoints(computed.fontSize),
fontFace: computed.fontFamily.split(',')[0].replace(/['"]/g, '').trim(),
color: rgbToHex(computed.color),
align: computed.textAlign === 'start' ? 'left' : computed.textAlign,
lineSpacing: pxToPoints(computed.lineHeight),
paraSpaceBefore: pxToPoints(computed.marginTop),
paraSpaceAfter: pxToPoints(computed.marginBottom),
// PptxGenJS margin array is [left, right, bottom, top] (not [top, right, bottom, left] as documented)
margin: [
pxToPoints(computed.paddingLeft),
pxToPoints(computed.paddingRight),
pxToPoints(computed.paddingBottom),
pxToPoints(computed.paddingTop)
]
};
const transparency = extractAlpha(computed.color);
if (transparency !== null) baseStyle.transparency = transparency;
if (rotation !== null) baseStyle.rotate = rotation;
const hasFormatting = el.querySelector('b, i, u, strong, em, span, br');
if (hasFormatting) {
// Text with inline formatting
const transformStr = computed.textTransform;
const runs = parseInlineFormatting(el, {}, [], (str) => applyTextTransform(str, transformStr));
// Adjust lineSpacing based on largest fontSize in runs
const adjustedStyle = { ...baseStyle };
if (adjustedStyle.lineSpacing) {
const maxFontSize = Math.max(
adjustedStyle.fontSize,
...runs.map(r => r.options?.fontSize || 0)
);
if (maxFontSize > adjustedStyle.fontSize) {
const lineHeightMultiplier = adjustedStyle.lineSpacing / adjustedStyle.fontSize;
adjustedStyle.lineSpacing = maxFontSize * lineHeightMultiplier;
}
}
elements.push({
type: el.tagName.toLowerCase(),
text: runs,
position: { x: pxToInch(x), y: pxToInch(y), w: pxToInch(w), h: pxToInch(h) },
style: adjustedStyle
});
} else {
// Plain text - inherit CSS formatting
const textTransform = computed.textTransform;
const transformedText = applyTextTransform(text, textTransform);
const isBold = computed.fontWeight === 'bold' || parseInt(computed.fontWeight) >= 600;
elements.push({
type: el.tagName.toLowerCase(),
text: transformedText,
position: { x: pxToInch(x), y: pxToInch(y), w: pxToInch(w), h: pxToInch(h) },
style: {
...baseStyle,
bold: isBold && !shouldSkipBold(computed.fontFamily),
italic: computed.fontStyle === 'italic',
underline: computed.textDecoration.includes('underline')
}
});
}
processed.add(el);
});
return { background, elements, placeholders, errors };
});
}
async function html2pptx(htmlFile, pres, options = {}) {
const {
tmpDir = process.env.TMPDIR || '/tmp',
slide = null
} = options;
try {
// Use Chrome on macOS, default Chromium on Unix
const launchOptions = { env: { TMPDIR: tmpDir } };
if (process.platform === 'darwin') {
launchOptions.channel = 'chrome';
}
const browser = await chromium.launch(launchOptions);
let bodyDimensions;
let slideData;
const filePath = path.isAbsolute(htmlFile) ? htmlFile : path.join(process.cwd(), htmlFile);
const validationErrors = [];
try {
const page = await browser.newPage();
page.on('console', (msg) => {
// Log the message text to your test runner's console
console.log(`Browser console: ${msg.text()}`);
});
await page.goto(`file://${filePath}`);
bodyDimensions = await getBodyDimensions(page);
await page.setViewportSize({
width: Math.round(bodyDimensions.width),
height: Math.round(bodyDimensions.height)
});
slideData = await extractSlideData(page);
} finally {
await browser.close();
}
// Collect all validation errors
if (bodyDimensions.errors && bodyDimensions.errors.length > 0) {
validationErrors.push(...bodyDimensions.errors);
}
const dimensionErrors = validateDimensions(bodyDimensions, pres);
if (dimensionErrors.length > 0) {
validationErrors.push(...dimensionErrors);
}
const textBoxPositionErrors = validateTextBoxPosition(slideData, bodyDimensions);
if (textBoxPositionErrors.length > 0) {
validationErrors.push(...textBoxPositionErrors);
}
if (slideData.errors && slideData.errors.length > 0) {
validationErrors.push(...slideData.errors);
}
// Throw all errors at once if any exist
if (validationErrors.length > 0) {
const errorMessage = validationErrors.length === 1
? validationErrors[0]
: `Multiple validation errors found:\n${validationErrors.map((e, i) => ` ${i + 1}. ${e}`).join('\n')}`;
throw new Error(errorMessage);
}
const targetSlide = slide || pres.addSlide();
await addBackground(slideData, targetSlide, tmpDir);
addElements(slideData, targetSlide, pres);
return { slide: targetSlide, placeholders: slideData.placeholders };
} catch (error) {
if (!error.message.startsWith(htmlFile)) {
throw new Error(`${htmlFile}: ${error.message}`);
}
throw error;
}
}
module.exports = html2pptx;
+254
View File
@@ -0,0 +1,254 @@
#!/usr/bin/env node
/**
* HTML animation → MP4 via Playwright recordVideo + ffmpeg.
*
* Requires: global playwright (`npm install -g playwright`), ffmpeg on PATH.
*
* Usage:
* NODE_PATH=$(npm root -g) node render-video.js <html-file> \
* [--duration=30] [--width=1920] [--height=1080] \
* [--trim=<seconds>] [--fontwait=1.5] [--readytimeout=8] \
* [--keep-chrome]
*
* Design:
* 1. Warmup context (no record) — caches fonts/assets, closes cleanly
* 2. Record context (fresh, recordVideo ON) — WebM starts writing at
* context creation. Babel-standalone compile + React mount +
* fonts.ready can take 1.5-3s, during which WebM writes black frames.
* We measure this by waiting for window.__ready (set by animations.jsx
* Stage component after first paint), then trim exactly that offset.
* 3. addInitScript injects CSS hiding "chrome" elements (progress bar,
* replay button, masthead, footer, etc.) that are fine for human
* debugging but shouldn't appear in exported video.
*
* Animation-ready signal:
* Set `window.__ready = true` in your HTML after first paint. This tells
* the recorder "animation has started rendering — treat now as t=0".
* If you use animations.jsx, Stage does this automatically. Otherwise
* add: `document.fonts.ready.then(() => requestAnimationFrame(() => { window.__ready = true }));`
* after your first render call.
*
* Without __ready, falls back to --fontwait=1.5s (may leave 1-2s of black
* at the start). Pass --trim=<seconds> to override manually.
*
* Chrome elements hidden by default (all common class names + `.no-record`
* convention). Pass --keep-chrome to disable this and see raw HTML.
*
* Output: next to the HTML file, same basename with .mp4 suffix.
*/
const { chromium } = require('playwright');
const path = require('path');
const fs = require('fs');
const { spawnSync } = require('child_process');
function arg(name, def) {
const p = process.argv.find(a => a.startsWith('--' + name + '='));
return p ? p.slice(name.length + 3) : def;
}
function hasFlag(name) {
return process.argv.includes('--' + name);
}
const HTML_FILE = process.argv[2];
if (!HTML_FILE || HTML_FILE.startsWith('--')) {
console.error('Usage: node render-video.js <html-file>');
console.error('Example: NODE_PATH=$(npm root -g) node render-video.js my-animation.html');
process.exit(1);
}
const DURATION = parseFloat(arg('duration', '30'));
const WIDTH = parseInt(arg('width', '1920'));
const HEIGHT = parseInt(arg('height', '1080'));
const TRIM_OVERRIDE = arg('trim', null); // manual override (seconds). If unset, auto-detected.
const FONT_WAIT = parseFloat(arg('fontwait', '1.5')); // fallback when no __ready signal
const READY_TIMEOUT = parseFloat(arg('readytimeout', '8'));
const KEEP_CHROME = hasFlag('keep-chrome');
const HTML_ABS = path.resolve(HTML_FILE);
const BASENAME = path.basename(HTML_FILE, path.extname(HTML_FILE));
const DIR = path.dirname(HTML_ABS);
const TMP_DIR = path.join(DIR, '.video-tmp-' + Date.now() + '-' + process.pid);
const MP4_OUT = path.join(DIR, BASENAME + '.mp4');
// CSS to hide "chrome" elements during recording.
// Covers class-name conventions seen across skill-built animations,
// plus a `.no-record` explicit opt-out class.
const HIDE_CHROME_CSS = `
.no-record,
.progress, .progress-bar,
.counter, .tCur,
.phases, .phase-label, .phase,
.replay, button.replay,
.masthead, .kicker, .title,
.footer,
[data-role="chrome"], [data-record="hidden"] {
display: none !important;
}
`;
console.log(`▸ Rendering: ${HTML_FILE}`);
console.log(` size: ${WIDTH}x${HEIGHT} · duration: ${DURATION}s · hide-chrome: ${!KEEP_CHROME}`);
console.log(` output: ${MP4_OUT}`);
(async () => {
fs.mkdirSync(TMP_DIR, { recursive: true });
const browser = await chromium.launch();
const url = 'file://' + HTML_ABS;
// ── Phase 1: WARMUP (no recording, caches fonts/assets) ─────────────
console.log('▸ Warmup (caching fonts)…');
const warmupCtx = await browser.newContext({
viewport: { width: WIDTH, height: HEIGHT },
});
const warmupPage = await warmupCtx.newPage();
await warmupPage.goto(url, { waitUntil: 'networkidle' });
await warmupPage.waitForTimeout(FONT_WAIT * 1000);
await warmupCtx.close();
// ── Phase 2: RECORD (fresh context, animation from t=0) ─────────────
console.log('▸ Recording (clean start)…');
const recordCtx = await browser.newContext({
viewport: { width: WIDTH, height: HEIGHT },
deviceScaleFactor: 1,
recordVideo: {
dir: TMP_DIR,
size: { width: WIDTH, height: HEIGHT },
},
});
// Inject CSS + JS heuristic to hide "chrome" elements.
// Two layers:
// A. CSS selectors for common class-name conventions (cheap)
// B. JS heuristic for fixed-position bars containing buttons or time
// readouts (catches inline-styled chrome like <Stage> controls)
// Persists across reloads via addInitScript.
if (!KEEP_CHROME) {
await recordCtx.addInitScript(css => {
const HIDE_MARK = 'data-video-hidden';
function injectStyle() {
const style = document.createElement('style');
style.setAttribute('data-inject', 'render-video-chrome-hide');
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
}
function hideChromeBars() {
const vh = window.innerHeight;
document.querySelectorAll('div, nav, header, footer, section, aside')
.forEach(el => {
if (el.hasAttribute(HIDE_MARK)) return;
if (el.dataset.recordKeep === 'true') return;
const s = getComputedStyle(el);
if (s.position !== 'fixed' && s.position !== 'sticky') return;
const r = el.getBoundingClientRect();
// Only skinny bars (not full-screen overlays)
if (r.height > vh * 0.25) return;
const atBottom = r.bottom >= vh - 30;
const atTop = r.top <= 30 && r.height < 80;
if (!atBottom && !atTop) return;
// Chrome-like: contains button or scrubber/time glyphs
const txt = el.textContent || '';
const hasBtn = !!el.querySelector('button, [role="button"]');
const hasCtrls = /[⏸▶⏮⏭↻↺↩↪]|\d+\.\d+\s*s/.test(txt);
if (hasBtn || hasCtrls) {
el.style.setProperty('display', 'none', 'important');
el.setAttribute(HIDE_MARK, '1');
}
});
}
const start = () => {
injectStyle();
hideChromeBars();
// Re-run as React/Vue commits DOM changes
const obs = new MutationObserver(hideChromeBars);
obs.observe(document.body, { childList: true, subtree: true });
setTimeout(() => obs.disconnect(), 6000);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start, { once: true });
} else {
start();
}
}, HIDE_CHROME_CSS);
}
// Record context opens page. The WebM starts writing the moment the
// context is created — so we track T0 here and measure how many seconds
// elapse before the animation is actually ready (Babel compile + React
// mount + fonts.ready). That elapsed time = exact trim offset.
const T0 = Date.now();
const page = await recordCtx.newPage();
await page.goto(url, { waitUntil: 'networkidle' });
// Wait for animation ready signal. Stage component (animations.jsx) sets
// window.__ready = true on its first rAF after mount + fonts.ready.
// Fallback: if HTML doesn't set __ready within READY_TIMEOUT, use fontwait.
let animationStartSec;
const hasReady = await page.waitForFunction(
() => window.__ready === true,
{ timeout: READY_TIMEOUT * 1000 },
).then(() => true).catch(() => false);
if (hasReady) {
animationStartSec = (Date.now() - T0) / 1000;
console.log(`▸ Ready at ${animationStartSec.toFixed(2)}s (from window.__ready)`);
} else {
await page.waitForTimeout(FONT_WAIT * 1000);
animationStartSec = (Date.now() - T0) / 1000;
console.log(`▸ No window.__ready signal; using fallback wait (${animationStartSec.toFixed(2)}s)`);
console.log(` tip: in animations.jsx-based HTML this is automatic;`);
console.log(` otherwise set window.__ready=true after your first paint.`);
}
// Now let the animation play out its full duration
await page.waitForTimeout(DURATION * 1000 + 300);
await page.close();
await recordCtx.close();
await browser.close();
const webmFiles = fs.readdirSync(TMP_DIR).filter(f => f.endsWith('.webm'));
if (webmFiles.length === 0) {
console.error('✗ No webm produced');
process.exit(1);
}
const webmPath = path.join(TMP_DIR, webmFiles[0]);
console.log(`▸ WebM: ${(fs.statSync(webmPath).size / 1024 / 1024).toFixed(1)} MB`);
// Resolve final trim offset:
// - manual --trim=X → use X (explicit user override)
// - otherwise → use animationStartSec (measured), with a tiny
// +0.05s nudge to clear the first Babel-commit frame
const resolvedTrim = TRIM_OVERRIDE !== null
? parseFloat(TRIM_OVERRIDE)
: animationStartSec + 0.05;
console.log(`▸ ffmpeg: trim=${resolvedTrim.toFixed(2)}s${TRIM_OVERRIDE !== null ? ' (manual)' : ' (auto)'}, encode H.264…`);
const ffmpeg = spawnSync('ffmpeg', [
'-y',
'-ss', String(resolvedTrim),
'-i', webmPath,
'-t', String(DURATION),
'-c:v', 'libx264',
'-pix_fmt', 'yuv420p',
'-crf', '18',
'-preset', 'medium',
'-movflags', '+faststart',
MP4_OUT,
], { stdio: ['ignore', 'ignore', 'pipe'] });
if (ffmpeg.status !== 0) {
console.error('✗ ffmpeg failed:\n' + ffmpeg.stderr.toString().slice(-2000));
process.exit(1);
}
fs.rmSync(TMP_DIR, { recursive: true, force: true });
const mp4Size = (fs.statSync(MP4_OUT).size / 1024 / 1024).toFixed(1);
console.log(`✓ Done: ${MP4_OUT} (${mp4Size} MB)`);
})();
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""
verify.py — Playwright封装,用于验证claude-design产出的HTML
Usage:
python verify.py path/to/design.html # 基础:打开+截图+抓控制台错误
python verify.py design.html --viewports 1920x1080,375x667 # 多viewport
python verify.py deck.html --slides 10 # 幻灯片逐页截(前10张)
python verify.py design.html --output ./screenshots/ # 输出目录
python verify.py design.html --show # 非headless,打开真实浏览器
依赖:
pip install playwright
playwright install chromium
"""
import argparse
import sys
import os
import time
from pathlib import Path
def parse_viewport(s):
w, h = s.split('x')
return {'width': int(w), 'height': int(h)}
def verify_html(html_path, viewports=None, slides=0, output_dir=None, show=False, wait=2000):
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("ERROR: playwright未安装。")
print("运行: pip install playwright && playwright install chromium")
sys.exit(1)
html_path = Path(html_path).resolve()
if not html_path.exists():
print(f"ERROR: 文件不存在: {html_path}")
sys.exit(1)
if output_dir is None:
output_dir = html_path.parent / 'screenshots'
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
file_url = html_path.as_uri()
stem = html_path.stem
if viewports is None:
viewports = [{'width': 1440, 'height': 900}]
console_errors = []
page_errors = []
with sync_playwright() as p:
browser = p.chromium.launch(headless=not show)
for viewport in viewports:
context = browser.new_context(viewport=viewport, device_scale_factor=2)
page = context.new_page()
page.on("console", lambda msg: console_errors.append(f"[{msg.type}] {msg.text}") if msg.type in ("error", "warning") else None)
page.on("pageerror", lambda err: page_errors.append(str(err)))
print(f"\n→ 打开 {file_url} @ {viewport['width']}x{viewport['height']}")
page.goto(file_url, wait_until='networkidle')
page.wait_for_timeout(wait)
if slides > 0:
for i in range(slides):
screenshot_path = output_dir / f"{stem}-slide-{str(i + 1).zfill(2)}.png"
page.screenshot(path=str(screenshot_path), full_page=False)
print(f" ✓ slide {i+1} → {screenshot_path.name}")
if i < slides - 1:
page.keyboard.press('ArrowRight')
page.wait_for_timeout(500)
else:
suffix = f"-{viewport['width']}x{viewport['height']}" if len(viewports) > 1 else ""
screenshot_path = output_dir / f"{stem}{suffix}.png"
page.screenshot(path=str(screenshot_path), full_page=False)
print(f" ✓ 截图 → {screenshot_path.name}")
full_path = output_dir / f"{stem}{suffix}-full.png"
page.screenshot(path=str(full_path), full_page=True)
print(f" ✓ 完整页 → {full_path.name}")
if show:
print(" (浏览器窗口保持打开,按Enter关闭...)")
input()
context.close()
browser.close()
print("\n" + "=" * 50)
print("验证报告")
print("=" * 50)
if page_errors:
print(f"\n❌ Page Errors ({len(page_errors)}):")
for e in page_errors:
print(f" - {e}")
else:
print("\n✅ 无JavaScript错误")
if console_errors:
print(f"\n⚠️ Console Errors/Warnings ({len(console_errors)}):")
for e in console_errors[:20]:
print(f" - {e}")
if len(console_errors) > 20:
print(f" ... 还有{len(console_errors) - 20}条")
else:
print("✅ Console干净")
print(f"\n📸 截图保存至: {output_dir}")
return 0 if not page_errors else 1
def main():
parser = argparse.ArgumentParser(
description="Verify HTML design outputs with Playwright",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("html_path", help="HTML file path")
parser.add_argument("--viewports", default="1440x900",
help="逗号分隔的viewport列表,格式 WxH(默认 1440x900)")
parser.add_argument("--slides", type=int, default=0,
help="幻灯片模式:截取前N张(需要HTML支持ArrowRight翻页)")
parser.add_argument("--output", default=None,
help="输出目录(默认HTML所在目录的screenshots/)")
parser.add_argument("--show", action="store_true",
help="非headless,打开真实浏览器窗口")
parser.add_argument("--wait", type=int, default=2000,
help="打开页面后等待的毫秒数(默认2000)")
args = parser.parse_args()
viewports = [parse_viewport(v) for v in args.viewports.split(",")]
return verify_html(
html_path=args.html_path,
viewports=viewports,
slides=args.slides,
output_dir=args.output,
show=args.show,
wait=args.wait,
)
if __name__ == "__main__":
sys.exit(main())