mirror of
https://github.com/aculix/huashu-design.git
synced 2026-10-27 07:15:54 +00:00
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:
commit
7d7bab4a82
89 files changed
+19270
No files matched your search
Executable
+108
@@ -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)"
|
||||
Executable
+51
@@ -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"
|
||||
@@ -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); });
|
||||
@@ -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); });
|
||||
Executable
+979
@@ -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;
|
||||
Executable
+254
@@ -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)`);
|
||||
})();
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user