
Baoyu Design
- 3.5k installs
- 2.9k repo stars
- Updated July 26, 2026
- jimliu/baoyu-design
baoyu-design is an agent skill that authors, compiles, checks, and previews portable design systems with token CSS, components, and self-contained HTML exports.
About
baoyu-design is a portable design system authoring skill built around generated artifacts rather than hand-edited bundles. Authors maintain token CSS such as styles.css, PascalCase component .d.ts and .jsx pairs, and @dsCard-tagged HTML cards, then regenerate _ds_bundle.js, _ds_manifest.json, _adherence.oxlintrc.json, and preview.html through node agents/compile-design-system.mjs. The read-only check-design-system.mjs reports namespace, components, cards, starting points, tokens, fonts, and issues without writing files. build-preview.mjs compiles a design system directory into one self-contained interactive preview.html using Shadow DOM card isolation, scaled card frames, and optional offline vendor assets. Built-in sub-skills cover create-design-system, design-system-preview, mobile prototypes, animated video, PPTX export, and fork verification workflows. Starting points and @dsCard groups control what consuming projects see in pickers. Developers invoke it when creating UI kits, compiling component libraries, validating token coverage, or exporting standalone previews from a design system repo.
- Compiles portable design systems via compile-design-system.mjs into _ds_bundle.js and _ds_manifest.json artifacts.
- Uses @dsCard HTML comments and optional @startingPoint tags to expose cards and picker entry points.
- Provides read-only check-design-system.mjs validation that reports tokens, fonts, components, and issues.
- build-preview.mjs emits one self-contained preview.html with Shadow DOM isolated cards and scaled layouts.
- Treats _ds_bundle.js, _ds_manifest.json, and preview.html as generated outputs that must not be hand-edited.
Baoyu Design by the numbers
- 3,466 all-time installs (skills.sh)
- +311 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #101 of 1,896 Design & UI/UX skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
baoyu-design capabilities & compatibility
- Capabilities
- design system compile pipeline · read only design system validation · self contained preview.html generation
- Use cases
- ui design · frontend · documentation
What baoyu-design says it does
_ds_bundle.js`, `_ds_manifest.json`, `_adherence.oxlintrc.json`, and `preview.html` are **generated artifacts** — never hand-edit them.
put `<!-- @dsCard group="<Group>" viewport="<WxH>" name="<Label>" subtitle="…" -->` as the **first line** of any `.html`.
npx skills add https://github.com/jimliu/baoyu-design --skill baoyu-designAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3.5k |
|---|---|
| repo stars | ★ 2.9k |
| Last updated | July 26, 2026 |
| Repository | jimliu/baoyu-design ↗ |
How do I build and validate a portable design system with compiled bundles, token coverage, and an interactive preview consumers can browse?
Author portable design systems with token CSS, React components, @dsCard previews, compile/check scripts, and self-contained preview.html exports.
Who is it for?
Design system authors maintaining token CSS, React components, and generated bundle or preview artifacts for consuming projects.
Skip if: Skip when you only need a one-off landing page mock without a reusable component library or compile/check workflow.
When should I use this skill?
User asks to create a design system, compile components, run the design-system checker, or export an interactive preview.html.
What you get
Regenerated bundle and manifest files, a validation report, and optionally a self-contained preview.html showcasing grouped @dsCard components.
- Compiled _ds_bundle.js and manifest
- Validation report
- Self-contained preview.html
Files
Bud1�ter-costarter-componentsvSrnlong @� @� @� @
#!/usr/bin/env node
// check-design-system.mjs — READ-ONLY validator for a portable design system.
// Usage: node check-design-system.mjs <projectDir> [--verbose]
//
// Reimplements the web product's `check_design_system` tool: it parses the
// design-system folder with ds-core (which writes NOTHING) and prints the
// namespace, components, @dsCard cards (by group), starting points, tokens and
// fonts, followed by any issues to fix. This script imports only ds-core and
// never writes to disk — that is what makes it safe to run as a read-only
// subagent. Run it after edits to confirm an edit registered; fix what it
// reports and run again until clean.
import { buildModel } from './lib/ds-core.mjs';
const args = process.argv.slice(2);
const verbose = args.includes('--verbose') || args.includes('-v') || args.includes('verbose');
const projectDir = args.find((a) => !a.startsWith('-') && a !== 'verbose');
if (!projectDir) {
process.stderr.write('Usage: node check-design-system.mjs <projectDir> [--verbose]\n');
process.exit(64);
}
let model;
try {
model = buildModel(projectDir);
} catch (e) {
process.stderr.write(`check-design-system: ${(e && e.message) || e}\n`);
process.exit(1);
}
const {
namespace, components, cards, startingPoints, tokens, fonts, brandFonts,
unexposedExports, allSources, issues, globalCssEntry, tokenHist,
} = model;
// --- summary line ---
// Cap the inline inventory: a 9000-component .fig import would otherwise bury
// the verdict in one ~150 KB line. --verbose still prints the full export map.
const COMPONENTS_SHOWN = 40;
const constantExports = components.filter((c) => c.kind === 'constant');
const realComponents = components.filter((c) => c.kind !== 'constant');
const constSeg = constantExports.length
? ` (+${constantExports.length} constant export${constantExports.length === 1 ? '' : 's'}: ${constantExports.map((c) => c.name).join(', ')})`
: '';
const compSeg = realComponents.length
? `Components: ${realComponents.length}${constSeg} (${realComponents.slice(0, COMPONENTS_SHOWN).map((c) => c.name).join(', ')}${
realComponents.length > COMPONENTS_SHOWN ? `, … +${realComponents.length - COMPONENTS_SHOWN} more — full list with --verbose` : ''}).`
: `Components: (none)${constSeg}.`;
let cardSeg;
if (cards.length) {
const byGroup = {};
for (const c of cards) byGroup[c.group] = (byGroup[c.group] || 0) + 1;
const groups = Object.keys(byGroup).sort().map((g) => `${g}: ${byGroup[g]}`).join(', ');
cardSeg = `@dsCard cards: ${cards.length} (${groups}).`;
} else {
cardSeg = '@dsCard cards: 0 (none — tag any .html with <!-- @dsCard group="…" --> on line 1).';
}
const spSeg = startingPoints.length
? `Starting points: ${startingPoints.length} (${startingPoints.map((s) => s.name).join(', ')}).`
: 'Starting points: (none).';
const fontSeg = fonts.length
? `Fonts: ${fonts.map((f) => f.family).join(', ')}.`
: 'Fonts: (none).';
const summary =
`Namespace: ${namespace} (use \`const { X } = window.${namespace}\` in @dsCard HTML). ` +
`${compSeg} ${cardSeg} ${spSeg} Tokens: ${tokens.length}. ${fontSeg}`;
const lines = [summary];
if (issues.length) {
lines.push('');
lines.push('Issues to fix:');
lines.push('');
for (const i of issues) lines.push(`• ${i}`);
} else {
lines.push('');
lines.push('No issues — clean. ✅');
}
if (verbose) {
lines.push('');
lines.push('Token kinds: ' + Object.keys(tokenHist).sort()
.map((k) => `${k} ${tokenHist[k]}`).join(', ') + '.');
if (brandFonts.length) {
lines.push('Brand fonts (no @font-face — falls back to a system font): ' +
brandFonts.map((b) => `${b.family} [${b.tokens.join(', ')}]`).join('; ') + '.');
}
lines.push('');
lines.push('Export map (✓ = exposed on window namespace; lowercase names compile but are NOT exposed):');
for (const s of allSources) {
if (!s.exports.length) {
lines.push(` ${s.path}: (no exports${s.isModule ? '' : ', not a component module'})`);
continue;
}
for (const name of s.exports) {
const exposed = s.isModule && /^[A-Z]/.test(name);
lines.push(` ${exposed ? '✓' : '·'} ${name} — ${s.path}`);
}
}
}
process.stdout.write(lines.join('\n') + '\n');
// hard error: nothing to validate without a token entry point
process.exit(globalCssEntry ? 0 : 2);
#!/usr/bin/env node
// compile-design-system.mjs — the WRITE step of the portable design-system
// pipeline. It reimplements the web product's "automated compiler": it reads a
// design-system folder, transpiles every component .jsx/.tsx with the vendored
// @babel/standalone (JSX/TSX → React.createElement, classic runtime), and emits
// the three artifacts the runtime cards load:
//
// _ds_bundle.js — one IIFE that defines window.<Namespace>.<Component>
// _ds_manifest.json — namespace, components, cards, starting points, tokens, fonts
// _adherence.oxlintrc.json — lint config (raw hex/px + per-component prop whitelists)
//
// Usage: node compile-design-system.mjs <projectDir>
//
// Only THIS script writes; the parser (ds-core.mjs) and checker
// (check-design-system.mjs) write nothing.
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { createRequire } from 'node:module';
import { buildModel } from './lib/ds-core.mjs';
const require = createRequire(import.meta.url);
const here = path.dirname(new URL(import.meta.url).pathname);
// --- load vendored Babel (UMD → CJS) ------------------------------------------
let Babel;
try {
Babel = require('./vendor/babel.min.js');
if (!Babel || typeof Babel.transform !== 'function') {
throw new Error('vendored Babel did not expose transform()');
}
} catch (e) {
process.stderr.write(
`compile-design-system: could not load agents/vendor/babel.min.js — ${(e && e.message) || e}\n` +
'Fetch it once with: curl -L https://unpkg.com/@babel/standalone@7.29.0/babel.min.js -o agents/vendor/babel.min.js\n',
);
process.exit(1);
}
// --- Babel plugin: strip ESM, keep React/window globals -----------------------
// The reference sources read deps from globals (`const React = window.React`,
// `const { Icon } = window.<NS>`) and use no ESM imports. Authored components
// may instead `import React from 'react'` and import siblings with relative
// paths — we strip react/react-dom imports and rewrite relative imports to
// read from __ds_scope, so both styles bundle.
function makeStripPlugin({ types: t }) {
return {
visitor: {
ExportNamedDeclaration(p) {
if (p.node.declaration) p.replaceWith(p.node.declaration);
else p.remove();
},
ExportDefaultDeclaration(p) {
const d = p.node.declaration;
if (t.isFunctionDeclaration(d) || t.isClassDeclaration(d)) {
if (d.id) p.replaceWith(d);
else p.remove();
} else {
p.remove();
}
},
ExportAllDeclaration(p) { p.remove(); },
ImportDeclaration(p) {
const src = p.node.source.value;
if (src === 'react' || src === 'react-dom' || src === 'react-dom/client') {
p.remove();
return;
}
if (src.startsWith('.') || src.startsWith('/')) {
// relative sibling import → pull the names out of __ds_scope
const names = [];
for (const s of p.node.specifiers) {
if (t.isImportDefaultSpecifier(s) || t.isImportNamespaceSpecifier(s)) {
names.push(t.objectProperty(t.identifier(s.local.name), t.identifier(s.local.name), false, true));
} else if (t.isImportSpecifier(s)) {
const imported = s.imported.name || s.imported.value;
names.push(t.objectProperty(t.identifier(imported), t.identifier(s.local.name), false, imported === s.local.name));
}
}
if (names.length) {
p.replaceWith(
t.variableDeclaration('const', [
t.variableDeclarator(t.objectPattern(names), t.identifier('__ds_scope')),
]),
);
} else {
p.remove();
}
}
// bare external imports (e.g. a CDN lib) are left as-is for the author to resolve
},
},
};
}
function transpile(code, file) {
const isTsx = /\.tsx$/.test(file);
const isTs = /\.tsx?$/.test(file);
const presets = [['react', { runtime: 'classic' }]];
if (isTs) presets.push(['typescript', { isTSX: isTsx, allExtensions: true }]);
const out = Babel.transform(code, {
filename: file,
presets,
plugins: [makeStripPlugin],
sourceType: 'module',
compact: false,
comments: true,
retainLines: false,
});
return out.code;
}
// --- bundle assembly ----------------------------------------------------------
function sourceHash(code) {
return crypto.createHash('sha256').update(code).digest('hex').slice(0, 12);
}
// The strip plugin turns relative imports into EAGER `const { X } = __ds_scope`
// reads at the top of each file's IIFE block, so a dependency's block must run
// before its importers' — alphabetical order breaks whenever a dependency sorts
// after a dependent (e.g. components/Button.jsx importing ./Icon.jsx works, but
// ./Zicon.jsx would be undefined). DFS post-order over the already-alphabetical
// source list keeps output deterministic; files in an import cycle stay in list
// order (any order is wrong for a cycle — the per-block try/catch contains it).
function orderForBundle(allSources, rawByPath) {
const byPath = new Map(allSources.map((s) => [s.path, s]));
// single-statement match: [^;'"] can't cross a side-effect import's quote or
// a statement boundary, while still spanning multi-line specifier lists
const IMPORT_RE = /import\s+(?:[^;'"]*?\sfrom\s*)?["']([^"']+)["']/g;
const EXTS = ['', '.jsx', '.tsx', '.js', '.ts', '/index.jsx', '/index.tsx', '/index.js', '/index.ts'];
const resolveSpec = (importer, spec) => {
const base = spec.startsWith('/')
? path.posix.normalize(spec.slice(1)) // treat /x/y as DS-root-relative
: path.posix.normalize(path.posix.join(path.posix.dirname(importer), spec));
for (const ext of EXTS) {
if (byPath.has(base + ext)) return base + ext;
}
return null;
};
const deps = new Map();
for (const s of allSources) {
const out = [];
const raw = rawByPath.get(s.path) ?? '';
IMPORT_RE.lastIndex = 0;
let m;
while ((m = IMPORT_RE.exec(raw))) {
const spec = m[1];
if (!spec.startsWith('.') && !spec.startsWith('/')) continue;
const target = resolveSpec(s.path, spec);
if (target && target !== s.path) out.push(target);
}
deps.set(s.path, out);
}
const ordered = [];
const state = new Map(); // 1 = visiting (cycle guard), 2 = emitted
const visit = (p) => {
if (state.get(p)) return;
state.set(p, 1);
for (const d of deps.get(p) ?? []) visit(d);
state.set(p, 2);
ordered.push(byPath.get(p));
};
for (const s of allSources) visit(s.path);
return ordered;
}
function buildBundle(model) {
const { root, namespace, components, unexposedExports, allSources } = model;
const sourceHashes = {};
const blocks = [];
const exposed = new Set(components.map((c) => c.name));
// read every source first: hashes stay in allSources (alphabetical) order so
// the manifest is stable, while blocks emit in dependency order
const rawByPath = new Map();
for (const s of allSources) {
let raw = '';
try { raw = fs.readFileSync(path.join(root, s.path), 'utf8'); } catch { /* skip */ }
rawByPath.set(s.path, raw);
sourceHashes[s.path] = sourceHash(raw);
}
for (const s of orderForBundle(allSources, rawByPath)) {
const raw = rawByPath.get(s.path) ?? '';
let body;
try {
body = transpile(raw, s.path);
} catch (e) {
// a file that fails to transpile becomes a runtime error entry, matching
// the bundle's per-file try/catch contract
body = `throw new Error(${JSON.stringify('transpile failed: ' + ((e && e.message) || e))});`;
}
// a non-module file never writes a .d.ts-backed component's name into
// __ds_scope — the module implementation must win the namespace slot
const assignNames = s.isModule ? s.exports : s.exports.filter((n) => !exposed.has(n));
const assign = assignNames.length
? `\nObject.assign(__ds_scope, { ${assignNames.join(', ')} });`
: '';
blocks.push(
`// ${s.path}\n` +
`try { (() => {\n${body}${assign}\n})(); } catch (e) { ` +
`__ds_ns.__errors.push({ path: ${JSON.stringify(s.path)}, error: String((e && e.message) || e) }); }`,
);
}
const meta = {
format: 3,
namespace,
components: components.map((c) => ({ name: c.name, sourcePath: c.sourcePath })),
sourceHashes,
inlinedExternals: [],
unexposedExports: unexposedExports.map((u) => ({ name: u.name, sourcePath: u.sourcePath })),
};
// .d.ts-backed components first, then PascalCase exports of non-module
// sources — bundled and exposed, but with no props contract / adherence /
// starting-point eligibility. A module component always keeps its name.
const extraExposed = [];
for (const s of allSources) {
if (s.isModule) continue;
for (const name of s.exports) {
if (/^[A-Z]/.test(name) && !exposed.has(name)) {
exposed.add(name);
extraExposed.push(name);
}
}
}
const tail = components
.map((c) => `__ds_ns.${c.name} = __ds_scope.${c.name};`)
.concat(extraExposed.map((n) => `__ds_ns.${n} = __ds_scope.${n};`))
.join('\n\n');
return (
`/* @ds-bundle: ${JSON.stringify(meta)} */\n\n` +
`(() => {\n\n` +
`const __ds_ns = (window.${namespace} = window.${namespace} || {});\n\n` +
`const __ds_scope = {};\n\n` +
`(__ds_ns.__errors = __ds_ns.__errors || []);\n\n` +
blocks.join('\n\n') +
`\n\n` +
(tail ? tail + '\n\n' : '') +
`})();\n`
);
}
// --- manifest -----------------------------------------------------------------
function buildManifest(model) {
return {
namespace: model.namespace,
components: model.components,
startingPoints: model.startingPoints,
cards: model.cards,
globalCssPaths: model.globalCssPaths,
tokens: model.tokens,
themes: model.themes,
fonts: model.fonts,
brandFonts: model.brandFonts,
source: model.source,
};
}
// --- adherence lint config ----------------------------------------------------
// One JSX prop-whitelist per exposed component, from the props contract ds-core
// parses out of the module's .d.ts (`<Name>Props` interface; both quote styles,
// multi-line unions, one alias hop). Enum-typed props additionally get a VALUE
// rule, so a literal like variant="ghost" warns when the union lacks 'ghost'.
// Constant exports (ICON_NAMES) and components whose contract didn't parse get
// no rule — fail open. This is advisory lint config.
const ALWAYS_PROPS = ['key', 'ref', 'className', 'style', 'children'];
const reEsc = (s) => s.replace(/[/\\^$.*+?()[\]{}|]/g, '\\$&');
function buildAdherence(model) {
// import-restriction groups: each directory that holds source files
const dirs = [...new Set(model.allSources.map((s) => path.posix.dirname(s.path)))]
.filter((d) => d && d !== '.')
.sort()
.map((d) => `${d}/**`);
const syntax = [
{
selector: 'Literal[value=/#[0-9a-fA-F]{3,8}\\b/]',
message: 'Raw hex color — use a design-system color token via var().',
},
{
selector: 'Literal[value=/\\b\\d+px\\b/]',
message: 'Raw px value — use a design-system spacing token via var().',
},
];
const components = model.components
.filter((c) => c.kind !== 'constant' && Array.isArray(c.props) && c.props.length)
.slice()
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
for (const { name, props } of components) {
const allowed = [...new Set([...props.map((p) => p.name), ...ALWAYS_PROPS])];
const allowedRe = `^(?:${allowed.join('|')})$`;
syntax.push({
selector: `JSXOpeningElement[name.name='${name}'] > JSXAttribute > JSXIdentifier[name!=/${allowedRe}/]`,
message: `<${name}> doesn't accept that prop. Declared props: ${props.map((p) => p.name).join(', ')}.`,
});
for (const p of props) {
if (!p.values || !p.values.length) continue;
const valRe = `^(?:${p.values.map(reEsc).join('|')})$`;
syntax.push({
selector: `JSXOpeningElement[name.name='${name}'] > JSXAttribute[name.name='${p.name}'] > Literal[value!=/${valRe}/]`,
message: `<${name}> ${p.name} must be one of ${p.values.map((v) => `'${v}'`).join(' | ')}.`,
});
}
}
const tokensSorted = model.tokens.map((t) => t.name).sort();
const tokenKinds = {};
for (const t of model.tokens) tokenKinds[t.name] = t.kind;
const componentsMap = {};
for (const { name } of components) componentsMap[name] = { replaces: [] };
return {
plugins: ['react', 'import'],
rules: {
'react/forbid-elements': ['warn', { forbid: [] }],
'no-restricted-imports': ['warn', {
patterns: [{
group: dirs,
message: "Import design-system components from 'index.js', not component internals.",
}],
}],
'no-restricted-syntax': ['warn', ...syntax],
},
overrides: [{ files: ['**/index.js'], rules: { 'no-restricted-imports': 'off' } }],
'x-omelette': {
components: componentsMap,
tokens: tokensSorted,
tokenKinds,
fontFamilies: model.fonts.map((f) => f.family),
},
};
}
// --- main ---------------------------------------------------------------------
const projectDir = process.argv[2];
if (!projectDir) {
process.stderr.write('Usage: node compile-design-system.mjs <projectDir>\n');
process.exit(64);
}
let model;
try {
model = buildModel(projectDir);
} catch (e) {
process.stderr.write(`compile-design-system: ${(e && e.message) || e}\n`);
process.exit(1);
}
const bundle = buildBundle(model);
const manifest = buildManifest(model);
const adherence = buildAdherence(model);
const w = (name, content) => {
fs.writeFileSync(path.join(model.root, name), content);
return name;
};
w('_ds_bundle.js', bundle);
w('_ds_manifest.json', JSON.stringify(manifest, null, 2));
w('_adherence.oxlintrc.json', JSON.stringify(adherence, null, 2));
const hist = model.tokenHist;
const histStr = Object.keys(hist).sort().map((k) => `${k} ${hist[k]}`).join(', ');
const constCount = model.components.filter((c) => c.kind === 'constant').length;
const constSeg = constCount ? ` (+${constCount} constant export${constCount === 1 ? '' : 's'})` : '';
process.stdout.write(
`Compiled ${model.namespace}: ${model.components.length - constCount} components${constSeg}, ` +
`${model.cards.length} cards, ${model.startingPoints.length} starting points, ` +
`${model.tokens.length} tokens (${histStr}).\n` +
'Wrote _ds_bundle.js, _ds_manifest.json, _adherence.oxlintrc.json.\n' +
(model.unclassified.length
? `Note: ${model.unclassified.length} tokens unclassified by value — add /* @kind */ comments.\n`
: ''),
);
Design-system checker (read-only)
You are a read-only validator for a portable design system. Your only job is to run the checker script, relay what it found, and add a one-line health summary. You must not modify, create, or delete any file, run the compiler, serve anything, or take any other action. You read and report — nothing else.
Input
You are given the project directory of a design system (the folder that holds styles.css / token CSS, components/, *.card.html, and — once compiled — _ds_bundle.js / _ds_manifest.json). You are also given the path to this skill's agents/ directory.
What to do
1. Run the checker (it writes nothing):
node <skill>/agents/check-design-system.mjs "<projectDir>"Add --verbose only if the caller asked for the full export map (every export and its source file, including compiled-but-not-exposed lowercase names).
2. Relay the script's stdout verbatim — the namespace line, components, @dsCard cards by group, starting points, tokens, fonts, and the "Issues to fix" list. Do not summarize away the details; the caller acts on them.
3. Add one final line: a health verdict — either ✅ Clean — no issues. or ⚠️ N issue(s) to fix (see above).
Rules
- Read-only, always. Never edit a token CSS file, a component, a card, the
manifest, or the bundle. If the report says something is wrong, you report it; the main agent fixes it and re-runs you.
- The checker exits non-zero (code 2) when there is no global CSS entry yet
(tokens can't be extracted) — relay that as "no token entry point found (create styles.css / index.css first)", not as a crash.
- If
nodeisn't available or the script path is wrong, say so plainly and
stop — do not attempt a workaround that writes files.
- Do not call the compiler. Compiling is a separate, explicit write step the
main agent runs; you only validate.
Fork verifier (read-only)
You are a read-only verification subagent spawned to check a design deliverable the main agent just built or edited. Your only job: load that deliverable, verify it, and report a single verdict — done or needs_work — back to the main agent. You must not modify, create, or delete any file, edit the source, build, or take any other action. You read, probe, and report — nothing else. Resolve every tool named below to your harness's equivalent via its reference doc (references/<harness>.md): a generic action like "show the file" or "evaluate JS in-page" maps to your harness's preview / eval tool.
Input
You are given the project directory, the path(s) of the HTML file(s) the main agent built or edited, and the served http://localhost:<port>/<project>/<file>.html URL to load (always over HTTP — never file://). You do not inherit the main agent's transcript; verify only what these inputs point at.
What to do
1. Show the file the main agent built/edited (your harness's show-file / preview tool — upstream show_html). 2. Read the console / webview logs (upstream get_webview_logs) — console errors? failed loads? 3. Screenshot — layout / spacing / type / content look right? 4. Evaluate JS in-page (upstream eval_js) to probe if something seems off. For overflow/alignment issues, diagnose the constraint before reporting:
const el = document.querySelector('...'); const p = el.parentElement;
const pick = (e, cs) => ({rect: e.getBoundingClientRect(), boxSizing: cs.boxSizing, display: cs.display, position: cs.position, width: cs.width, height: cs.height, minHeight: cs.minHeight, flexDirection: cs.flexDirection});
JSON.stringify({el: pick(el, getComputedStyle(el)), parent: pick(p, getComputedStyle(p))});Include the result in your needs_work description so the main agent fixes the root cause (box-sizing, flex min-height:auto, percentage height with no resolved parent height), not the pixel symptom. 5. If the authored source uses var(--*): evaluate JS to collect every custom property DEFINED in the loaded stylesheets (any selector / @layer / @media, not just :root):
const defined = new Set();
const walk = rs => { for (const r of rs||[]) { if (r.style) for (const p of r.style) if (p.startsWith('--')) defined.add(p); try { walk(r.cssRules || r.styleSheet?.cssRules); } catch {} } };
for (const ss of document.styleSheets) try { walk(ss.cssRules); } catch {}
JSON.stringify([...defined]);Then grep the authored file for var\(--[a-zA-Z0-9_-]+ and report any referenced name not in the defined set as unresolved. 6. Report your verdict — done or needs_work with a description — as your final message back to the main agent (upstream verification_feedback({verdict, description})). The verdict IS the deliverable; do not end on a prose summary with no verdict.
Rules
- Read-only, always. Never write or edit files, build, serve, or run write
scripts. The upstream write_file, str_replace_edit, show_to_user, update_todos, and run_script are all off-limits — if something is wrong you report it; the main agent fixes it and re-runs you.
- `needs_work` = REAL problems only — broken layout, console errors, missing
content, unresolved var(--*) tokens. Not nitpicks.
- The verdict is the only exit. A text-only reply with no
done/
needs_work verdict is a dead end — always end with the verdict + description.
- Always load over the served
http://localhost:…URL, neverfile://.
node_modules/
*.log
{
"name": "gen-pptx",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gen-pptx",
"version": "1.0.0",
"dependencies": {
"playwright": "^1.49.0",
"pptxgenjs": "3.12.0"
},
"bin": {
"gen-pptx": "dist/cli.mjs"
},
"devDependencies": {
"@types/node": "^22.10.0",
"esbuild": "^0.24.0",
"typescript": "^5.7.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "22.19.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz",
"integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.2",
"@esbuild/android-arm": "0.24.2",
"@esbuild/android-arm64": "0.24.2",
"@esbuild/android-x64": "0.24.2",
"@esbuild/darwin-arm64": "0.24.2",
"@esbuild/darwin-x64": "0.24.2",
"@esbuild/freebsd-arm64": "0.24.2",
"@esbuild/freebsd-x64": "0.24.2",
"@esbuild/linux-arm": "0.24.2",
"@esbuild/linux-arm64": "0.24.2",
"@esbuild/linux-ia32": "0.24.2",
"@esbuild/linux-loong64": "0.24.2",
"@esbuild/linux-mips64el": "0.24.2",
"@esbuild/linux-ppc64": "0.24.2",
"@esbuild/linux-riscv64": "0.24.2",
"@esbuild/linux-s390x": "0.24.2",
"@esbuild/linux-x64": "0.24.2",
"@esbuild/netbsd-arm64": "0.24.2",
"@esbuild/netbsd-x64": "0.24.2",
"@esbuild/openbsd-arm64": "0.24.2",
"@esbuild/openbsd-x64": "0.24.2",
"@esbuild/sunos-x64": "0.24.2",
"@esbuild/win32-arm64": "0.24.2",
"@esbuild/win32-ia32": "0.24.2",
"@esbuild/win32-x64": "0.24.2"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/https": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz",
"integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==",
"license": "ISC"
},
"node_modules/image-size": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
"integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
"license": "MIT",
"dependencies": {
"queue": "6.0.2"
},
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=16.x"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/playwright": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/pptxgenjs": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-3.12.0.tgz",
"integrity": "sha512-ZozkYKWb1MoPR4ucw3/aFYlHkVIJxo9czikEclcUVnS4Iw/M+r+TEwdlB3fyAWO9JY1USxJDt0Y0/r15IR/RUA==",
"license": "MIT",
"dependencies": {
"@types/node": "^18.7.3",
"https": "^1.0.0",
"image-size": "^1.0.0",
"jszip": "^3.7.1"
}
},
"node_modules/pptxgenjs/node_modules/@types/node": {
"version": "18.19.130",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/pptxgenjs/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT"
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
}
}
}
{
"name": "gen-pptx",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Export an HTML slide deck to PPTX (editable native objects or full-bleed screenshots) via a headless browser + PptxGenJS. Clean-TypeScript port of the claude.ai design gen_pptx tool.",
"bin": {
"gen-pptx": "dist/cli.mjs"
},
"scripts": {
"build:cli": "esbuild src/cli.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/cli.mjs --external:playwright --external:pptxgenjs --banner:js=\"#!/usr/bin/env node\"",
"build:page": "esbuild src/browser/entry.ts --bundle --platform=browser --target=es2020 --format=iife --outfile=dist/capture.iife.js",
"build": "npm run build:cli && npm run build:page",
"typecheck": "tsc --noEmit",
"test": "node --test"
},
"dependencies": {
"playwright": "^1.49.0",
"pptxgenjs": "3.12.0"
},
"devDependencies": {
"@types/node": "^22.10.0",
"esbuild": "^0.24.0",
"typescript": "^5.7.0"
}
}
// Browser-context only. Reveals slide N, settles layout, waits for images, then
// walks the live DOM into a JSON tree the Node renderer consumes. (←_t)
import type { FontSwap, Rect, SlideNode, SlideSpec, StyleMap } from "../types.ts";
import {
STYLE_KEYS,
COLOR_KEYS,
makeColorNormalizer,
makeFontResolver,
} from "./dom-style.ts";
export interface EditableCapture {
slide: { rect: Rect; root: SlideNode };
hash: number;
imagesWaited: number;
imagesFailed: number;
}
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
const settleFrame = (): Promise<void> =>
Promise.race([
new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => r()))),
sleep(500),
]);
export async function captureEditable(
spec: SlideSpec,
fontSwaps: FontSwap[],
): Promise<EditableCapture> {
if (spec.showJs) {
try {
new Function(spec.showJs)();
} catch (e) {
throw new Error("showJs threw: " + ((e as Error)?.message || e));
}
}
await settleFrame();
const delay = Number.isFinite(Number(spec.delay)) ? Number(spec.delay ?? 600) : 600;
await sleep(delay);
const rootEl = document.querySelector(spec.selector);
if (!rootEl) throw new Error("selector " + JSON.stringify(spec.selector) + " matched nothing");
// Image decode wait — shared budget that fits inside the eval timeout.
const imgBudget = Math.max(1000, 8500 - delay);
let waited = 0;
let settled = 0;
let failed = 0;
const pending: Promise<void>[] = [];
const queue = (img: HTMLImageElement): void => {
waited++;
pending.push(
img.decode().then(
() => {
settled++;
},
() => {
settled++;
failed++;
},
),
);
};
for (const img of Array.from(rootEl.querySelectorAll("img"))) {
if (img.complete && img.naturalWidth > 0) continue;
queue(img);
}
for (const host of Array.from(rootEl.querySelectorAll("*"))) {
if (!host.shadowRoot || host.firstElementChild || host.shadowRoot.querySelector("slot")) continue;
for (const hImg of Array.from(host.shadowRoot.querySelectorAll("img"))) {
if (!(hImg.currentSrc || hImg.src)) continue;
const cs = getComputedStyle(hImg);
if (cs.display === "none" || cs.visibility === "hidden" || cs.visibility === "collapse") continue;
const rect = hImg.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) continue;
if (hImg.complete && hImg.naturalWidth > 0) continue;
queue(hImg);
}
}
if (pending.length) {
await Promise.race([Promise.all(pending), sleep(imgBudget)]);
failed += pending.length - settled;
}
const { normColor, isTransparentColor } = makeColorNormalizer();
const swapMap: Record<string, string> = {};
for (const s of fontSwaps) swapMap[s.from.toLowerCase()] = s.to;
const resolveFontFace = makeFontResolver(swapMap);
// djb2 — fast, non-crypto; just "did slide N === slide N+1".
let h = 5381;
const hashStr = (s: string): void => {
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
};
const rectOf = (el: Element): Rect => {
const r = el.getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
};
const readStyle = (cs: CSSStyleDeclaration): StyleMap => {
const style: StyleMap = {};
const csm = cs as unknown as Record<string, string>;
for (const k of STYLE_KEYS) {
let v = csm[k];
if (COLOR_KEYS.indexOf(k) >= 0) v = normColor(v) ?? v;
if (k === "fontFamily" && v) v = resolveFontFace(v);
style[k] = v;
}
return style;
};
const walk = (el: Element): SlideNode | null => {
const cs = getComputedStyle(el);
if (cs.display === "none") return null;
const r = rectOf(el);
const kids = el.children;
if (r.w === 0 && r.h === 0 && kids.length === 0) return null;
const style = readStyle(cs);
const node: SlideNode = { tag: el.tagName.toLowerCase(), rect: r, style, children: [] };
hashStr(`${r.x},${r.y},${r.w},${r.h}`);
if (el.tagName === "A") {
const a = el as HTMLAnchorElement;
if (a.href && !(el.getAttribute("href") || "").startsWith("#")) node.href = a.href;
}
if (el.tagName === "LI") {
const lst = cs.listStyleType;
if (lst && lst !== "none" && lst !== "disc" && lst !== "circle" && lst !== "square") {
const sibs: Element[] = [];
for (let s = el.parentElement?.firstElementChild ?? null; s; s = s.nextElementSibling) {
if (s.tagName === "LI") sibs.push(s);
}
node.liIndex = sibs.indexOf(el) + 1;
}
}
// Pre-transform AABB for rotated elements.
if (cs.transform && cs.transform !== "none") {
const ow = (el as HTMLElement).offsetWidth;
const oh = (el as HTMLElement).offsetHeight;
if (ow != null && oh != null && (ow !== r.w || oh !== r.h)) {
node.untransformedRect = { x: r.x + r.w / 2 - ow / 2, y: r.y + r.h / 2 - oh / 2, w: ow, h: oh };
}
}
if (el.tagName === "IMG") {
const img = el as HTMLImageElement;
node.imageUrl = img.currentSrc || img.src;
} else if (el.tagName === "OBJECT" && (el as HTMLObjectElement).data) {
node.imageUrl = (el as HTMLObjectElement).data;
return node;
} else if (el.tagName === "CANVAS") {
try {
node.imageUrl = (el as HTMLCanvasElement).toDataURL("image/png");
} catch {
/* tainted canvas — skip */
}
} else if (el.tagName.toLowerCase() === "svg") {
const clone = el.cloneNode(true) as Element;
for (const ref of Array.from(clone.querySelectorAll("image"))) {
const href =
ref.getAttribute("href") || ref.getAttributeNS("http://www.w3.org/1999/xlink", "href");
if (href && href.indexOf("data:") !== 0) {
try {
ref.setAttribute("href", new URL(href, location.href).href);
ref.removeAttributeNS("http://www.w3.org/1999/xlink", "href");
} catch {
/* leave href as-is */
}
}
}
node.svg = (clone as Element).outerHTML;
return node;
} else {
// Shadow-hosted image (e.g. <image-slot>): a slotless light-empty host
// renders only its shadow img, which IS the content.
if (el.shadowRoot && !el.firstElementChild && !el.shadowRoot.querySelector("slot")) {
for (const simg of Array.from(el.shadowRoot.querySelectorAll("img"))) {
const scs = getComputedStyle(simg);
if (scs.display === "none" || scs.visibility === "hidden" || scs.visibility === "collapse") continue;
const ssrc = simg.currentSrc || simg.src;
if (!ssrc) continue;
const srect = simg.getBoundingClientRect();
if (srect.width === 0 || srect.height === 0) continue;
let sop = parseFloat(scs.opacity);
if (isNaN(sop)) sop = 1;
for (let anc = simg.parentElement; anc && sop > 0; anc = anc.parentElement) {
const aop = parseFloat(getComputedStyle(anc).opacity);
if (!isNaN(aop)) sop *= aop;
}
if (sop === 0) continue;
node.imageUrl = ssrc;
let sfit = el.getAttribute("fit");
if (sfit !== "contain" && sfit !== "fill") {
sfit = scs.objectFit === "contain" ? "contain" : "cover";
}
const override: StyleMap = { objectFit: sfit, backgroundSize: "auto", backgroundImage: "none" };
const sideBorderPaints = (w: string, st: string, col: string): boolean =>
(parseFloat(w) || 0) > 0 && !!st && st !== "none" && !isTransparentColor(normColor(col));
const paintsOutsetShadow = (bs: string): boolean => {
if (!bs || bs === "none") return false;
for (const part of bs.split(/,(?![^(]*\))/)) {
if (/\binset\b/.test(part)) continue;
const shCol = part.match(/rgba?\([^)]*\)/);
if (shCol && isTransparentColor(shCol[0])) continue;
return true;
}
return false;
};
const hostHasBox =
!isTransparentColor(normColor(cs.backgroundColor)) ||
paintsOutsetShadow(cs.boxShadow) ||
sideBorderPaints(cs.borderTopWidth, cs.borderTopStyle, cs.borderTopColor) ||
sideBorderPaints(cs.borderRightWidth, cs.borderRightStyle, cs.borderRightColor) ||
sideBorderPaints(cs.borderBottomWidth, cs.borderBottomStyle, cs.borderBottomColor) ||
sideBorderPaints(cs.borderLeftWidth, cs.borderLeftStyle, cs.borderLeftColor);
if (!hostHasBox) {
const sradius = simg.parentElement ? getComputedStyle(simg.parentElement).borderRadius : "";
if (sradius && sradius !== "0px") override.borderRadius = sradius;
if (sop < 1) {
const hop = parseFloat(cs.opacity);
override.opacity = String((isNaN(hop) ? 1 : hop) * sop);
} else {
const fbg = simg.parentElement
? normColor(getComputedStyle(simg.parentElement).backgroundColor)
: "";
if (fbg && !isTransparentColor(fbg)) override.backgroundColor = fbg;
}
}
node.style = Object.assign({}, node.style, override);
return node;
}
}
const bg = cs.backgroundImage;
if (bg && bg !== "none") {
const m = bg.match(/url\("([^"]*)"\)/);
if (m && m[1].indexOf("data:") !== 0) {
try {
node.imageUrl = new URL(m[1], location.href).href;
} catch {
/* leave unset */
}
} else if (bg.indexOf("gradient(") >= 0) {
// Rasterized later so alpha-fading overlays keep their fade. (←gradient)
node.gradient = bg;
const minSide = Math.min(r.w, r.h);
const corner = (cs.borderTopLeftRadius || "").split(" ")[0] || "";
let rad = 0;
if (corner.endsWith("%")) rad = (parseFloat(corner) / 100) * minSide;
else if (corner.endsWith("px")) rad = parseFloat(corner) || 0;
if (isFinite(rad) && rad > 0) node.gradientRadius = Math.min(rad, minSide / 2);
}
}
}
// Single childNodes pass keeps text interleaved with element children.
const ws = cs.whiteSpace;
const keepWs = ws === "pre" || ws === "pre-wrap" || ws === "pre-line" || ws === "break-spaces";
const parts: SlideNode[] = [];
let elKids = 0;
for (let cn = el.firstChild; cn; cn = cn.nextSibling) {
if (cn.nodeType === 3) {
const raw = cn.textContent ?? "";
const t = keepWs ? raw : raw.trim();
if (!t) continue;
const rg = document.createRange();
if (keepWs) {
rg.selectNodeContents(cn);
} else {
const lead = raw.length - raw.replace(/^\s+/, "").length;
rg.setStart(cn, lead);
rg.setEnd(cn, lead + t.length);
}
const tr = rg.getBoundingClientRect();
parts.push({
tag: "#text",
rect: { x: tr.x, y: tr.y, w: tr.width, h: tr.height },
style,
text: t,
children: [],
});
hashStr(t);
} else if (cn.nodeType === 1) {
const kid = walk(cn as Element);
if (kid) {
parts.push(kid);
elKids++;
}
}
}
if (elKids === 0) {
const txt = parts.map((p) => p.text).join(keepWs ? "" : " ");
if (txt) node.text = txt;
} else {
node.children = parts;
}
return node;
};
const rootRect = rectOf(rootEl);
const rootNode = walk(rootEl);
if (!rootNode) throw new Error("slide root walked to null (display:none?)");
// Promote an ancestor's solid background when the slide root is transparent.
const rootBg = rootNode.style.backgroundColor;
const rootBgImg = rootNode.style.backgroundImage || "";
if (
rootBgImg.indexOf("gradient(") < 0 &&
(!rootBg || rootBg === "transparent" || /rgba?\([^)]*,\s*0\)$/.test(rootBg))
) {
for (let p = rootEl.parentElement; p; p = p.parentElement) {
if (p.shadowRoot) continue;
const pbg = normColor(getComputedStyle(p).backgroundColor);
if (pbg && pbg !== "transparent" && !/rgba?\([^)]*,\s*0\)$/.test(pbg)) {
rootNode.style = Object.assign({}, rootNode.style, { backgroundColor: pbg });
break;
}
}
}
return {
slide: { rect: rootRect, root: rootNode },
hash: h >>> 0,
imagesWaited: waited,
imagesFailed: failed,
};
}
// Browser-context only. Reveals slide N and settles layout/transitions; the
// actual pixel grab is done Node-side via Playwright page.screenshot(). (←Nt)
import type { SlideSpec } from "../types.ts";
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
export async function captureScreenshot(spec: SlideSpec): Promise<void> {
if (spec.showJs) {
try {
new Function(spec.showJs)();
} catch (e) {
throw new Error("showJs threw: " + ((e as Error)?.message || e));
}
}
const delay = Number.isFinite(Number(spec.delay)) ? Number(spec.delay ?? 600) : 600;
await Promise.race([
new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => r()))),
sleep(500),
]);
await sleep(delay);
}
// Browser-context only. Self-contained: imports nothing from core/ or render/
// so esbuild's IIFE bundle stays sealed. Computed-style read list + the
// font/color resolution closures used by the DOM walk. (←Mt/Rt/At + the
// normColor/faceAvailable/resolveFontFace helpers inside _t.)
// Computed-style properties to capture per node. Deduped from the reference's
// Mt (which listed objectFit/textShadow twice — a harmless read bug). (←[...Mt, ...Rt])
export const STYLE_KEYS = [
"color",
"backgroundColor",
"backgroundImage",
"backgroundSize",
"backgroundPosition",
"backgroundRepeat",
"objectFit",
"objectPosition",
"borderTopWidth",
"borderTopStyle",
"borderTopColor",
"borderRightWidth",
"borderRightStyle",
"borderRightColor",
"borderBottomWidth",
"borderBottomStyle",
"borderBottomColor",
"borderLeftWidth",
"borderLeftStyle",
"borderLeftColor",
"borderRadius",
"fontFamily",
"fontSize",
"fontWeight",
"fontStyle",
"textDecoration",
"textDecorationStyle",
"textDecorationColor",
"textAlign",
"textTransform",
"lineHeight",
"letterSpacing",
"opacity",
"textShadow",
"transform",
"boxShadow",
"listStyleType",
"display",
"visibility",
"whiteSpace",
"textOverflow",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
"overflow",
// Rt:
"flexDirection",
"alignItems",
"justifyContent",
"verticalAlign",
"position",
"zIndex",
] as const;
// Keys whose value is a color, normalized through a canvas round-trip. (←At)
export const COLOR_KEYS = [
"color",
"backgroundColor",
"borderTopColor",
"borderRightColor",
"borderBottomColor",
"borderLeftColor",
"textDecorationColor",
];
// CSS generic keywords → PowerPoint-safe family. Never in document.fonts and
// not names PowerPoint recognises. (←GENERIC_MAP inside _t.)
const GENERIC_MAP: Record<string, string> = {
serif: "Georgia",
"sans-serif": "Arial",
monospace: "Courier New",
"system-ui": "Arial",
"-apple-system": "Arial",
blinkmacsystemfont: "Arial",
"ui-serif": "Georgia",
"ui-sans-serif": "Arial",
"ui-monospace": "Courier New",
"ui-rounded": "Arial",
cursive: "Comic Sans MS",
fantasy: "Impact",
math: "Cambria Math",
emoji: "Segoe UI Emoji",
};
export interface ColorNormalizer {
normColor(c: string | null | undefined): string | null | undefined;
isTransparentColor(c: string | null | undefined): boolean;
}
// Canvas fillStyle round-trip resolves oklch()/lab()/named colors to hex/rgb.
// Reset to a known value before assignment so an unparseable input is
// detectable. (←normColor/isTransparentColor inside _t.)
export function makeColorNormalizer(): ColorNormalizer {
const ctx = document.createElement("canvas").getContext("2d");
const isTransparentColor = (c: string | null | undefined): boolean =>
!c || c === "transparent" || (c.indexOf("rgba(") === 0 && /,\s*0\)$/.test(c));
const normColor = (c: string | null | undefined): string | null | undefined => {
if (!c || !ctx) return c;
ctx.fillStyle = "#000";
ctx.fillStyle = c;
return ctx.fillStyle;
};
return { normColor, isTransparentColor };
}
// Resolve a font-family stack to the face the browser actually rendered with —
// applying swaps and generic mappings, and probing availability via canvas
// metrics so an unbacked first name doesn't leak into the .pptx. (←faceAvailable
// + resolveFontFace inside _t.)
export function makeFontResolver(swapMap: Record<string, string>): (stack: string) => string {
const ctx = document.createElement("canvas").getContext("2d");
const cache: Record<string, boolean> = {};
let monoW: number | undefined;
let sansW: number | undefined;
const probe = "BESbswy—MWmi0Il1";
const faceAvailable = (face: string): boolean => {
if (!ctx) return true;
const lc = face.toLowerCase();
if (lc in cache) return cache[lc];
if (monoW == null) {
ctx.font = "72px monospace";
monoW = ctx.measureText(probe).width;
ctx.font = "72px sans-serif";
sansW = ctx.measureText(probe).width;
}
const q = JSON.stringify(face);
ctx.font = `72px ${q}, monospace`;
const wm = ctx.measureText(probe).width;
ctx.font = `72px ${q}, sans-serif`;
const ws = ctx.measureText(probe).width;
return (cache[lc] = Math.abs(wm - (monoW as number)) > 0.01 || Math.abs(ws - (sansW as number)) > 0.01);
};
return (stack: string): string => {
let first: string | null = null;
for (const part of stack.split(",")) {
const face = part.trim().replace(/^['"]|['"]$/g, "");
if (!face) continue;
if (first === null) first = face;
const lc = face.toLowerCase();
if (swapMap[lc]) return swapMap[lc];
if (GENERIC_MAP[lc]) return GENERIC_MAP[lc];
if (faceAvailable(face)) return face;
}
return first || "Arial";
};
}
// esbuild IIFE bundle root. Attaches the capture API to window so the Node
// orchestrator can drive it via page.evaluate(). Imports only sibling browser/
// modules + type-only declarations — never render/ or core/, keeping the bundle
// sealed from the Node graph.
import { setup, type SetupInput } from "./setup.ts";
import { captureEditable, type EditableCapture } from "./capture-editable.ts";
import { captureScreenshot } from "./capture-screenshot.ts";
import { resolveMedia } from "./media-browser.ts";
import type { FontSwap, MediaRef, ResolvedMedia, SetupResult, SlideSpec } from "../types.ts";
export interface GenPptxApi {
setup(input: SetupInput): Promise<SetupResult>;
captureEditable(spec: SlideSpec, fontSwaps: FontSwap[]): Promise<EditableCapture>;
captureScreenshot(spec: SlideSpec): Promise<void>;
resolveMedia(refs: MediaRef[]): Promise<ResolvedMedia[]>;
}
declare global {
interface Window {
__genpptx?: GenPptxApi;
}
}
const api: GenPptxApi = { setup, captureEditable, captureScreenshot, resolveMedia };
window.__genpptx = api;
// Parse a CSS linear/radial gradient and paint it onto a canvas → transparent
// PNG. pptxgenjs has no gradient fill, so the renderer's only other option is a
// flat fill from the first color stop — which turns an alpha-fading scrim into a
// uniform near-opaque rectangle that darkens the whole image. Rasterizing the
// gradient (canvas honors per-stop rgba alpha) preserves the fade exactly.
//
// parseCssGradient / resolveStops are pure (Node-testable); rasterizeGradient
// uses the DOM and only runs in the page. (←gradient overlay fidelity)
export type StopPos = { unit: "%" | "px"; v: number } | null;
export interface GradientStop {
color: string;
pos: StopPos;
}
export interface LinearGradient {
type: "linear";
/** CSS angle in degrees (0 = to top, clockwise). */
angle: number;
stops: GradientStop[];
}
export interface RadialGradient {
type: "radial";
shape: "circle" | "ellipse";
stops: GradientStop[];
}
export type ParsedGradient = LinearGradient | RadialGradient;
const clamp = (n: number, lo: number, hi: number): number => (n < lo ? lo : n > hi ? hi : n);
const ANGLE_RE = /^(-?[\d.]+)(deg|grad|rad|turn)$/;
// Leading color token: hex, rgb()/rgba(), hsl()/hsla(), or a bare keyword.
const COLOR_RE = /^(#[0-9a-fA-F]{3,8}|rgba?\([^)]*\)|hsla?\([^)]*\)|[a-zA-Z]+)/;
function angleUnitToDeg(v: number, unit: string): number {
switch (unit) {
case "rad":
return (v * 180) / Math.PI;
case "grad":
return v * 0.9;
case "turn":
return v * 360;
default:
return v;
}
}
// "to <side|corner>" → CSS angle in degrees. Corners use the box aspect. (←dir)
function directionToDeg(token: string, w: number, h: number): number | null {
const t = token.trim();
if (!t.startsWith("to ")) return null;
const sides = t
.slice(3)
.trim()
.split(/\s+/)
.sort()
.join(" ");
const corner = (Math.atan2(w, h) * 180) / Math.PI;
switch (sides) {
case "top":
return 0;
case "right":
return 90;
case "bottom":
return 180;
case "left":
return 270;
case "right top":
return corner; // to top right
case "bottom right":
return 180 - corner;
case "bottom left":
return 180 + corner;
case "left top":
return 360 - corner; // to top left
default:
return 180;
}
}
// Split on top-level commas (ignoring those inside rgb()/hsl() parens). (←split)
function splitTopLevel(s: string): string[] {
const out: string[] = [];
let depth = 0;
let buf = "";
for (const ch of s) {
if (ch === "(") depth++;
else if (ch === ")") depth--;
if (ch === "," && depth === 0) {
out.push(buf.trim());
buf = "";
} else {
buf += ch;
}
}
if (buf.trim()) out.push(buf.trim());
return out;
}
// One arg → its color stop(s). "color", "color 30%", or the double-position
// "color 10% 20%" form (which expands to two stops). (←stop)
function parseStops(arg: string): GradientStop[] {
const m = arg.match(COLOR_RE);
if (!m) return [];
const color = m[0];
const rest = arg.slice(color.length);
const positions = [...rest.matchAll(/(-?[\d.]+)(%|px)/g)];
if (positions.length === 0) return [{ color, pos: null }];
return positions.map((p) => ({
color,
pos: { unit: p[2] as "%" | "px", v: parseFloat(p[1]) },
}));
}
// Extract the first linear/radial gradient function's body (balanced parens).
// repeating-* and conic-gradient return null → caller falls back to flat fill.
function extractGradient(css: string): { kind: "linear" | "radial"; body: string } | null {
const m = css.match(/(repeating-)?(linear|radial|conic)-gradient\(/);
if (!m || m.index === undefined) return null;
if (m[1] || m[2] === "conic") return null;
const open = m.index + m[0].length - 1;
let depth = 0;
let i = open;
for (; i < css.length; i++) {
if (css[i] === "(") depth++;
else if (css[i] === ")" && --depth === 0) break;
}
if (depth !== 0) return null;
return { kind: m[2] as "linear" | "radial", body: css.slice(open + 1, i) };
}
const RADIAL_CONFIG_RE =
/\b(circle|ellipse|closest-side|closest-corner|farthest-side|farthest-corner|at)\b/;
const RADIAL_SIZE_RE = /^\s*[\d.]+(px|%|em|rem|vw|vh)(\s+[\d.]+(px|%|em|rem|vw|vh))?\s*$/;
export function parseCssGradient(css: string, w: number, h: number): ParsedGradient | null {
const g = extractGradient(css);
if (!g) return null;
const args = splitTopLevel(g.body);
if (args.length < 2) return null;
if (g.kind === "linear") {
let angle = 180; // CSS default direction is "to bottom"
let start = 0;
const first = args[0];
const am = first.match(ANGLE_RE);
if (am) {
angle = ((angleUnitToDeg(parseFloat(am[1]), am[2]) % 360) + 360) % 360;
start = 1;
} else {
const d = directionToDeg(first, w, h);
if (d !== null) {
angle = d;
start = 1;
}
}
const stops: GradientStop[] = [];
for (let i = start; i < args.length; i++) stops.push(...parseStops(args[i]));
return stops.length >= 2 ? { type: "linear", angle, stops } : null;
}
// radial
let start = 0;
const first = args[0];
if (RADIAL_CONFIG_RE.test(first) || RADIAL_SIZE_RE.test(first)) start = 1;
const stops: GradientStop[] = [];
for (let i = start; i < args.length; i++) stops.push(...parseStops(args[i]));
return stops.length >= 2
? { type: "radial", shape: /circle/.test(first) ? "circle" : "ellipse", stops }
: null;
}
// Resolve stop positions to monotonic offsets in [0,1]. px positions divide by
// the gradient line length; bare stops are spaced evenly (CSS rule). (←norm)
export function resolveStops(
stops: GradientStop[],
lengthPx: number,
): { color: string; offset: number }[] {
const n = stops.length;
const off: (number | null)[] = stops.map((s) => {
if (!s.pos) return null;
if (s.pos.unit === "%") return s.pos.v / 100;
return lengthPx > 0 ? s.pos.v / lengthPx : null;
});
if (off[0] === null) off[0] = 0;
if (off[n - 1] === null) off[n - 1] = 1;
// clamp defined positions to be non-decreasing
let prev = off[0] as number;
for (let i = 1; i < n; i++) {
if (off[i] !== null) {
if ((off[i] as number) < prev) off[i] = prev;
prev = off[i] as number;
}
}
// linearly interpolate runs of unspecified positions
let i = 0;
while (i < n) {
if (off[i] === null) {
let j = i;
while (j < n && off[j] === null) j++;
const lo = off[i - 1] as number;
const hi = off[j] as number;
const span = j - (i - 1);
for (let k = i; k < j; k++) off[k] = lo + ((hi - lo) * (k - (i - 1))) / span;
i = j;
} else i++;
}
return stops.map((s, idx) => ({ color: s.color, offset: clamp(off[idx] as number, 0, 1) }));
}
// Gradients are smooth, so a sub-slide raster downscales/upscales invisibly
// while keeping the embedded PNG small.
const MAX_RASTER_DIM = 1280;
function roundRectPath(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
): void {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// Paint a CSS gradient onto a transparent canvas at (capped) box size → PNG data
// URL. radiusPx clips to a rounded rect so gradient cards keep their corners.
// Returns null on any failure → renderer falls back to the flat first-stop fill.
export function rasterizeGradient(
css: string,
w: number,
h: number,
radiusPx: number,
): string | null {
try {
const g = parseCssGradient(css, w, h);
if (!g) return null;
const scale = Math.min(1, MAX_RASTER_DIM / Math.max(w, h, 1));
const cw = Math.max(1, Math.round(w * scale));
const ch = Math.max(1, Math.round(h * scale));
const canvas = document.createElement("canvas");
canvas.width = cw;
canvas.height = ch;
const ctx = canvas.getContext("2d");
if (!ctx) return null;
const rpx = radiusPx > 0 ? Math.min(radiusPx * scale, cw / 2, ch / 2) : 0;
if (rpx > 0) {
roundRectPath(ctx, 0, 0, cw, ch, rpx);
ctx.clip();
}
let grad: CanvasGradient;
if (g.type === "linear") {
const a = (g.angle * Math.PI) / 180;
const dx = Math.sin(a);
const dy = -Math.cos(a);
const len = Math.abs(cw * Math.sin(a)) + Math.abs(ch * Math.cos(a));
const cx = cw / 2;
const cy = ch / 2;
grad = ctx.createLinearGradient(
cx - (dx * len) / 2,
cy - (dy * len) / 2,
cx + (dx * len) / 2,
cy + (dy * len) / 2,
);
for (const s of resolveStops(g.stops, len)) grad.addColorStop(s.offset, s.color);
} else {
const cx = cw / 2;
const cy = ch / 2;
const r = Math.hypot(cw, ch) / 2;
grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
for (const s of resolveStops(g.stops, r)) grad.addColorStop(s.offset, s.color);
}
ctx.fillStyle = grad;
ctx.fillRect(0, 0, cw, ch);
return canvas.toDataURL("image/png");
} catch {
return null;
}
}
// Browser-context only. Resolves media refs in-page so canvas pixels, shadow-
// DOM imgs, and the deck's own credentials/origin all stay reachable. Returns
// base64 data URLs to Node. (←$e/ft/ut/Ie/pt/de + mt's 6-way pool.)
import type { MediaRef, ResolvedMedia, MediaEntry } from "../types.ts";
import { rasterizeGradient } from "./gradient.ts";
const POOL = 6;
const MAX_RASTER = 2048;
// In-page fetch. The deck is served from its own origin under Playwright, so
// the claude.ai host RPC / allowlist (qe/Ge/Ve/Ke) collapses to: same-origin
// credentials when same origin, omit otherwise. (←$e, host plumbing removed.)
async function fetchBlob(url: string): Promise<Blob> {
let u: URL;
try {
u = new URL(url, location.href);
} catch {
throw new Error("blocked host");
}
const sameOrigin = u.origin === location.origin;
const res = await fetch(u.href, { credentials: sameOrigin ? "same-origin" : "omit" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
}
// Blob → data URL. (←de)
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
// Target raster size from intrinsic / viewBox / requested dims. (←pt)
function svgSizeFit(
natW: number | undefined,
natH: number | undefined,
vbW: number | undefined,
vbH: number | undefined,
reqW?: number,
reqH?: number,
): { w: number; h: number } {
if (reqW && reqH) {
const d = Math.max(natW ?? 0, reqW);
const l = Math.max(natH ?? 0, reqH);
const a = Math.min(1, MAX_RASTER / Math.max(d, l));
return { w: Math.max(1, d * a), h: Math.max(1, l * a) };
}
if (vbW && vbH) {
const d = Math.min(1, MAX_RASTER / Math.max(vbW, vbH));
return { w: Math.max(1, vbW * d), h: Math.max(1, vbH * d) };
}
return { w: 300, h: 150 };
}
// Rasterize an SVG blob to a PNG data URL at 2× for crisp scaling. (←Ie)
async function rasterizeSvgBlob(
blob: Blob,
reqW?: number,
reqH?: number,
): Promise<MediaEntry | null> {
const objectUrl = URL.createObjectURL(blob);
try {
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const el = new Image();
el.onload = () => resolve(el);
el.onerror = () => reject(new Error("SVG decode failed"));
el.src = objectUrl;
});
const natW = img.naturalWidth || undefined;
const natH = img.naturalHeight || undefined;
let vbW = natW;
let vbH = natH;
if (!vbW || !vbH) {
try {
const head = ((await blob.text()).match(/<svg\b[^>]*>/i)?.[0] ?? "").match(
/\bviewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)/i,
);
if (head) {
vbW = parseFloat(head[1]) || undefined;
vbH = parseFloat(head[2]) || undefined;
}
} catch {
/* viewBox parse best-effort */
}
}
const { w, h } = svgSizeFit(natW, natH, vbW, vbH, reqW, reqH);
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(w * 2));
canvas.height = Math.max(1, Math.round(h * 2));
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
return { dataUrl: canvas.toDataURL("image/png"), w: vbW, h: vbH };
} catch {
return null;
} finally {
URL.revokeObjectURL(objectUrl);
}
}
// A raster URL or remote .svg → data URL (+ intrinsic size for rasters). (←ft)
async function inlineImageRef(url: string, warnings: string[]): Promise<MediaEntry | null> {
try {
const blob = await fetchBlob(url);
if (blob.type.includes("svg") || url.toLowerCase().endsWith(".svg")) {
const entry = await rasterizeSvgBlob(blob);
if (!entry) warnings.push(`Failed to rasterise SVG from ${url} (decode failed)`);
return entry;
}
const dataUrl = await blobToDataUrl(blob);
let w: number | undefined;
let h: number | undefined;
try {
const bmp = await createImageBitmap(blob);
w = bmp.width;
h = bmp.height;
bmp.close();
} catch {
/* size is optional */
}
return { dataUrl, w, h };
} catch (err) {
warnings.push(`Failed to inline image ${url}: ${err instanceof Error ? err.message : String(err)}`);
return null;
}
}
// Inline <image href> children of an inline <svg>, ensure xmlns, rasterize. (←ut)
async function inlineSvgMarkup(
markup: string,
w: number,
h: number,
warnings: string[],
): Promise<MediaEntry | null> {
const re = /<image\b[^>]*?\b(?:xlink:)?href=["']([^"']+)["']/gi;
const hrefs = new Set<string>();
for (let m: RegExpExecArray | null; (m = re.exec(markup)); ) {
if (!m[1].startsWith("data:")) hrefs.add(m[1]);
}
for (const href of [...hrefs].sort((a, b) => b.length - a.length)) {
try {
const dataUrl = await blobToDataUrl(await fetchBlob(href));
markup = markup.split(href).join(dataUrl);
} catch (err) {
warnings.push(
`Failed to inline <image href="${href}"> in SVG: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
if (!/^\s*<svg\b[^>]*\bxmlns\s*=/i.test(markup)) {
markup = markup.replace(/<svg\b/i, '<svg xmlns="http://www.w3.org/2000/svg"');
}
const blob = new Blob([markup], { type: "image/svg+xml" });
const entry = await rasterizeSvgBlob(blob, w, h);
if (!entry) {
warnings.push(`Failed to rasterise inline <svg> (${w}×${h}px): ${markup.slice(0, 80)}…`);
}
return entry;
}
// Resolve every ref through a 6-way concurrency pool. (←mt's pool body.)
export async function resolveMedia(refs: MediaRef[]): Promise<ResolvedMedia[]> {
const out: ResolvedMedia[] = refs.map((r) => ({ key: r.key, value: null, warnings: [] }));
const tasks = refs.map((ref, idx) => async (): Promise<void> => {
const warnings: string[] = [];
let value: MediaEntry | null;
if (ref.kind === "url") {
value = await inlineImageRef(ref.url, warnings);
} else if (ref.kind === "svg") {
value = await inlineSvgMarkup(ref.svg, ref.w, ref.h, warnings);
} else {
const dataUrl = rasterizeGradient(ref.css, ref.w, ref.h, ref.radius);
value = dataUrl ? { dataUrl } : null;
if (!dataUrl) warnings.push(`Failed to rasterize gradient: ${ref.css.slice(0, 80)}…`);
}
out[idx] = { key: ref.key, value, warnings };
});
let i = 0;
await Promise.all(
Array.from({ length: Math.min(POOL, tasks.length) || 1 }, async () => {
while (i < tasks.length) await tasks[i++]();
}),
);
return out;
}
// Browser-context only. One-time page prep before per-slide capture: hide
// selectors, apply font swaps (local src:local + Google Fonts web fetch), reset
// the slide transform/size for measurement, wait for fonts.ready, and collect
// speaker notes. (←Pt)
import type { FontSwap, Rect, SetupResult } from "../types.ts";
export interface SetupInput {
mode?: "editable" | "screenshots";
width: number;
height: number;
hideSelectors?: string[];
googleFontImports?: string[];
fontSwaps?: FontSwap[];
resetTransformSelector?: string;
}
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
const swapProbeName = (fam: string): string => "__om-swap-probe-" + encodeURIComponent(fam.toLowerCase());
export async function setup(input: SetupInput): Promise<SetupResult> {
const hideSelectors = Array.isArray(input.hideSelectors)
? input.hideSelectors.filter((s) => typeof s === "string")
: [];
const gfonts = Array.isArray(input.googleFontImports)
? input.googleFontImports.filter((s) => typeof s === "string")
: [];
const swaps = (Array.isArray(input.fontSwaps) ? input.fontSwaps : []).filter(
(p) => !!p && typeof p.from === "string" && typeof p.to === "string",
);
const skipWebSafe = (input.mode ?? "editable") === "editable";
const width = Number(input.width) || 0;
const height = Number(input.height) || 0;
for (const sel of hideSelectors) {
try {
document.querySelectorAll(sel).forEach((el) => {
(el as HTMLElement).style.display = "none";
});
} catch {
/* invalid selector — skip */
}
}
// Lowercased — CSS family matching is case-insensitive.
const gfontSet: Record<string, number> = {};
for (const g of gfonts) gfontSet[g.toLowerCase()] = 1;
const localSwaps = swaps.filter((p) => !gfontSet[p.to.toLowerCase()]);
const localSwapCss = localSwaps
.map(
(p) =>
"@font-face{font-family:" +
JSON.stringify(p.from) +
";src:local(" +
JSON.stringify(p.to) +
");}" +
"@font-face{font-family:" +
JSON.stringify(swapProbeName(p.to)) +
";src:local(" +
JSON.stringify(p.to) +
");}",
)
.join("\n");
if (localSwapCss) {
const st = document.createElement("style");
st.setAttribute("data-genpptx", "swap");
st.textContent = localSwapCss;
document.head.appendChild(st);
}
const webSwaps = swaps.filter((p) => gfontSet[p.to.toLowerCase()]);
let swapCancelled = false;
const swapMisses: string[] = [];
const swapMissSet: Record<string, number> = {};
const recordSwapMiss = (fam: string): void => {
const k = fam.toLowerCase();
if (!swapMissSet[k]) {
swapMissSet[k] = 1;
swapMisses.push(fam);
}
};
const webSwapDone = Promise.all(
webSwaps.map((p) => {
const fam = "font-family:" + JSON.stringify(p.from);
const base = "https://fonts.googleapis.com/css2?family=" + encodeURIComponent(p.to);
return fetch(base + ":wght@400;500;600;700&display=swap")
.then((r) => (r.ok ? r.text() : ""))
.then((css) => css || fetch(base + "&display=swap").then((r) => (r.ok ? r.text() : "")))
.then((css) => {
if (!css) {
recordSwapMiss(p.to);
return;
}
if (swapCancelled) return;
const st = document.createElement("style");
st.setAttribute("data-genpptx", "swap");
st.textContent = css.replace(/font-family:\s*['"][^'"]*['"]/gi, () => fam);
document.head.appendChild(st);
})
.catch(() => recordSwapMiss(p.to));
}),
);
for (const family of gfonts) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href =
"https://fonts.googleapis.com/css2?family=" + encodeURIComponent(family) + ":wght@400;500;600;700&display=swap";
link.setAttribute("data-genpptx", "gfont");
link.onerror = () => {
if (swapCancelled) return;
const fb = document.createElement("link");
fb.rel = "stylesheet";
fb.href = "https://fonts.googleapis.com/css2?family=" + encodeURIComponent(family) + "&display=swap";
fb.setAttribute("data-genpptx", "gfont");
document.head.appendChild(fb);
};
document.head.appendChild(link);
}
let resetRect: Rect | null = null;
if (input.resetTransformSelector) {
const resetEl = document.querySelector(input.resetTransformSelector) as HTMLElement | null;
if (resetEl) {
resetEl.setAttribute("noscale", "");
resetEl.setAttribute("width", String(width));
resetEl.setAttribute("height", String(height));
resetEl.style.transform = "none";
resetEl.style.transition = "none";
resetEl.style.width = width + "px";
resetEl.style.height = height + "px";
void resetEl.offsetHeight;
const measureEl = (resetEl.shadowRoot && resetEl.shadowRoot.querySelector(".canvas")) || resetEl;
const r = measureEl.getBoundingClientRect();
resetRect = { x: r.x, y: r.y, w: r.width, h: r.height };
}
}
let fontsReady = false;
try {
await Promise.race([
webSwapDone
.then(() => document.fonts.ready)
.then(() => {
fontsReady = true;
}),
sleep(8000),
]);
} catch {
/* fonts.ready rejection is non-fatal */
}
swapCancelled = true;
if (localSwaps.length && document.fonts && document.fonts.load) {
try {
await Promise.race([
Promise.all(
localSwaps.map((p) =>
document.fonts.load("72px " + JSON.stringify(swapProbeName(p.to))).catch(() => undefined),
),
),
sleep(500),
]);
} catch {
/* probe warming best-effort */
}
}
// Canvas-metric probe for local swaps: a face that matches both generic
// baselines never loaded. Warning-grade only.
try {
const pctx = document.createElement("canvas").getContext("2d");
if (pctx) {
const probeStr = "BESbswy—MWmi0Il1";
pctx.font = "72px monospace";
const probeMonoW = pctx.measureText(probeStr).width;
pctx.font = "72px sans-serif";
const probeSansW = pctx.measureText(probeStr).width;
const genericSkip: Record<string, number> = {
serif: 1,
"sans-serif": 1,
monospace: 1,
"system-ui": 1,
cursive: 1,
fantasy: 1,
"-apple-system": 1,
blinkmacsystemfont: 1,
"ui-serif": 1,
"ui-sans-serif": 1,
"ui-monospace": 1,
"ui-rounded": 1,
math: 1,
emoji: 1,
};
const webSafeFaces: Record<string, number> = {
arial: 1,
helvetica: 1,
georgia: 1,
"times new roman": 1,
times: 1,
"courier new": 1,
courier: 1,
verdana: 1,
tahoma: 1,
"trebuchet ms": 1,
impact: 1,
"comic sans ms": 1,
"segoe ui": 1,
calibri: 1,
cambria: 1,
palatino: 1,
"palatino linotype": 1,
garamond: 1,
"book antiqua": 1,
consolas: 1,
candara: 1,
corbel: 1,
constantia: 1,
"arial narrow": 1,
"arial black": 1,
"century gothic": 1,
"lucida sans": 1,
"lucida console": 1,
"lucida sans unicode": 1,
"cambria math": 1,
"segoe ui emoji": 1,
"microsoft yahei": 1,
simsun: 1,
simhei: 1,
"yu gothic": 1,
"ms gothic": 1,
"ms mincho": 1,
meiryo: 1,
"malgun gothic": 1,
batang: 1,
pmingliu: 1,
mingliu: 1,
"microsoft jhenghei": 1,
};
for (const swap of localSwaps) {
const lfam = swap.to;
const lfamLc = lfam.toLowerCase();
if (genericSkip[lfamLc]) continue;
if (skipWebSafe && webSafeFaces[lfamLc]) continue;
if (swapMissSet[lfamLc]) continue;
const lq = JSON.stringify(swapProbeName(lfam));
pctx.font = "72px " + lq + ", monospace";
const lwm = pctx.measureText(probeStr).width;
pctx.font = "72px " + lq + ", sans-serif";
const lws = pctx.measureText(probeStr).width;
if (Math.abs(lwm - probeMonoW) <= 0.01 && Math.abs(lws - probeSansW) <= 0.01) {
recordSwapMiss(lfam);
}
}
}
} catch {
/* probe best-effort */
}
// Speaker notes: per-slide data-speaker-notes is authoritative; fall through
// to the legacy #speaker-notes JSON array otherwise.
let notes: string[] = [];
let json: string[] = [];
try {
const notesEl = document.getElementById("speaker-notes");
if (notesEl && notesEl.textContent) {
const parsed = JSON.parse(notesEl.textContent);
if (Array.isArray(parsed)) json = parsed.map(String);
}
} catch {
/* malformed notes JSON — ignore */
}
const ds = document.querySelector("deck-stage");
if (ds) {
const slides = Array.prototype.filter.call(
ds.children,
(c: Element) => !/^(template|script|style)$/i.test(c.tagName),
) as Element[];
let anyAttr = false;
notes = slides.map((s, i) => {
const a = s.getAttribute("data-speaker-notes");
if (a !== null) {
anyAttr = true;
return a;
}
return typeof json[i] === "string" ? json[i] : "";
});
if (!anyAttr) notes = json;
} else {
notes = json;
}
return { notes, fontsReady, resetRect, fontSwapMisses: swapMisses };
}
// CLI entry: argv → GenPptxInput, drive the export, write the .pptx, print one
// JSON result line. Shebang is added by esbuild's banner.
//
// gen-pptx --url <servedDeckUrl> --config <jsonPath|-> [--out <dir>]
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { runGenPptx } from "./orchestrator/run.ts";
import { PlaywrightDriver } from "./orchestrator/driver.ts";
import { writeOutput } from "./orchestrator/output.ts";
import type { GenPptxInput } from "./types.ts";
const SETUP_HINT =
"cd <skill>/agents/gen-pptx && npm install && npx playwright install chromium";
interface Args {
url: string;
config: string;
out?: string;
}
function usage(msg: string): never {
process.stderr.write(
`${msg}\n\nUsage: gen-pptx --url <servedDeckUrl> --config <jsonPath|-> [--out <dir>]\n`,
);
process.exit(64);
}
function parseArgs(argv: string[]): Args {
const out: Partial<Args> = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--url") out.url = argv[++i];
else if (a === "--config") out.config = argv[++i];
else if (a === "--out") out.out = argv[++i];
else if (a === "-h" || a === "--help") usage("gen-pptx");
else usage(`Unknown argument: ${a}`);
}
if (!out.url) usage("Missing --url");
if (!out.config) usage("Missing --config");
if (!/^https?:\/\//i.test(out.url)) {
usage("--url must be an http(s) URL (deck-stage / multi-file decks need a served origin, not file://)");
}
return out as Args;
}
async function readStdin(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
return Buffer.concat(chunks).toString("utf8");
}
async function preflight(): Promise<void> {
const major = parseInt(process.versions.node.split(".")[0] ?? "0", 10);
if (major < 18) {
process.stderr.write(`gen-pptx: node >= 18 required (found ${process.versions.node}).\n`);
process.exit(1);
}
let pw: typeof import("playwright");
try {
pw = await import("playwright");
} catch {
process.stderr.write(`gen-pptx: playwright is not installed.\nOne-time setup:\n ${SETUP_HINT}\n`);
process.exit(1);
}
let exe = "";
try {
exe = pw.chromium.executablePath();
} catch {
/* fall through to the not-found message */
}
if (!exe || !existsSync(exe)) {
process.stderr.write(`gen-pptx: Chromium browser is not installed.\nOne-time setup:\n ${SETUP_HINT}\n`);
process.exit(1);
}
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
await preflight();
const raw = args.config === "-" ? await readStdin() : readFileSync(resolve(args.config), "utf8");
let input: GenPptxInput;
try {
input = JSON.parse(raw) as GenPptxInput;
} catch (err) {
usage(`--config is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
}
if (!input || typeof input.width !== "number" || typeof input.height !== "number") {
usage("config must include numeric width and height");
}
const mode = input.mode ?? "editable";
const driver = await PlaywrightDriver.launch(args.url, {
width: input.width,
height: input.height,
deviceScaleFactor: mode === "screenshots" ? 2 : 1,
});
try {
const { result, buffer } = await runGenPptx(input, driver);
const savedPath = await writeOutput(buffer, args.out ?? process.cwd(), input.filename);
process.stdout.write(
JSON.stringify({
ok: true,
file: savedPath,
slides: result.slides,
bytes: result.bytes,
flags: result.validation.map((v) => ({ code: v.kind, message: v.message })),
warnings: result.warnings,
speakerNotes: result.speakerNotes,
}) + "\n",
);
process.exit(0);
} catch (err) {
process.stdout.write(
JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }) + "\n",
);
process.exit(1);
} finally {
await driver.close();
}
}
main().catch((err) => {
process.stdout.write(
JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }) + "\n",
);
process.exit(1);
});
import { clamp } from "./units.ts";
export interface ParsedColor {
/** 6-digit uppercase hex, no leading #. */
hex: string;
/** 0..1 */
alpha: number;
}
// Parse a CSS color to {hex, alpha}. Handles transparent/none, #hex (3/4/6/8
// digit, 8th pair = alpha/255), and rgb()/rgba(). Returns null for transparent,
// fully-transparent (alpha 0), or unparseable input. (←U)
export function parseColor(input: string | null | undefined): ParsedColor | null {
if (!input) return null;
const r = input.trim().toLowerCase();
if (r === "transparent" || r === "none") return null;
if (r[0] === "#") {
let body = r.slice(1);
if (body.length === 3 || body.length === 4) {
body = body
.split("")
.map((c) => c + c)
.join("");
}
let alpha = 1;
if (body.length === 8) {
alpha = parseInt(body.slice(6, 8), 16) / 255;
body = body.slice(0, 6);
}
if (alpha === 0 || body.length !== 6 || /[^0-9a-f]/.test(body)) return null;
return { hex: body.toUpperCase(), alpha };
}
const m = r.match(
/rgba?\(\s*(-?[\d.]+)[\s,]+(-?[\d.]+)[\s,]+(-?[\d.]+)(?:[\s,/]+([\d.]+%?))?\s*\)/,
);
if (m) {
const red = clamp(Math.round(parseFloat(m[1])), 0, 255);
const green = clamp(Math.round(parseFloat(m[2])), 0, 255);
const blue = clamp(Math.round(parseFloat(m[3])), 0, 255);
let alpha = 1;
if (m[4] !== undefined) {
alpha = m[4].endsWith("%") ? parseFloat(m[4]) / 100 : parseFloat(m[4]);
alpha = clamp(alpha, 0, 1);
}
if (alpha === 0) return null;
return {
hex: ((red << 16) | (green << 8) | blue)
.toString(16)
.padStart(6, "0")
.toUpperCase(),
alpha,
};
}
return null;
}
// First color stop of a CSS gradient (best-effort flat fill). (←tt)
export function parseGradient(input: string | null | undefined): ParsedColor | null {
if (!input || !input.includes("gradient(")) return null;
const m = input.match(/(rgba?\([^)]+\)|#[0-9a-fA-F]{3,8})/);
return m ? parseColor(m[0]) : null;
}
// CSS opacity (0..1) → PptxGenJS transparency percent (0..100). (←ee)
export function opacityToTransparency(alpha: number): number {
return clamp(Math.round((1 - alpha) * 100), 0, 100);
}
import { clamp, pxToPoints } from "./units.ts";
import { parseColor } from "./color.ts";
import type { StyleMap } from "../types.ts";
// First px length in a value, else 0. (←T)
export function extractPx(value: string | null | undefined): number {
if (!value) return 0;
const m = value.match(/(-?[\d.]+)px/);
return m ? parseFloat(m[1]) : 0;
}
// font-weight → bold? "bold"/"bolder" or numeric >= 600. (←nt)
export function isBold(weight: string | null | undefined): boolean {
if (!weight) return false;
if (weight === "bold" || weight === "bolder") return true;
const n = parseInt(weight, 10);
return !isNaN(n) && n >= 600;
}
export type Align = "left" | "center" | "right" | "justify";
// text-align → PptxGenJS align. (←we)
export function textAlign(value: string | null | undefined): Align {
switch (value) {
case "center":
return "center";
case "right":
case "end":
return "right";
case "justify":
return "justify";
default:
return "left";
}
}
export type DashType = "dash" | "dot" | "solid" | undefined;
// CSS border-style → PptxGenJS line dashType. double collapses to solid. (←st)
export function borderStyleToDashType(value: string | null | undefined): DashType {
switch (value) {
case "dashed":
return "dash";
case "dotted":
return "dot";
case "double":
return "solid";
default:
return undefined;
}
}
// border-radius → px, clamped to half the shorter side. (←at)
export function parseBorderRadius(value: string | null | undefined, minSide: number): number {
if (!value || minSide <= 0) return 0;
const pct = value.match(/^([\d.]+)%/);
const px = pct ? (parseFloat(pct[1]) / 100) * minSide : extractPx(value);
return px <= 0 ? 0 : clamp(px, 0, minSide / 2);
}
// transform → rotation degrees (rotate() or matrix()), undefined when ~0. (←it)
export function extractRotation(transform: string | null | undefined): number | undefined {
if (!transform || transform === "none") return undefined;
const rot = transform.match(/rotate\((-?[\d.]+)deg\)/);
if (rot) {
const deg = parseFloat(rot[1]);
return deg === 0 ? undefined : deg;
}
const mat = transform.match(/matrix\(\s*([^,\s]+),\s*([^,\s]+),/);
if (mat) {
const a = parseFloat(mat[1]);
const b = parseFloat(mat[2]);
const deg = Math.atan2(b, a) * (180 / Math.PI);
return Math.abs(deg) < 0.01 ? undefined : deg;
}
return undefined;
}
export interface ShadowSpec {
type: "outer";
color: string;
opacity: number;
blur: number;
offset: number;
angle: number;
}
// First non-inset box/text shadow → PptxGenJS shadow spec. (←se)
export function parseShadow(value: string | null | undefined): ShadowSpec | null {
if (!value || value === "none") return null;
// Split on top-level commas (color funcs contain their own commas).
const parts: string[] = [];
let depth = 0;
let buf = "";
for (const ch of value) {
if (ch === "(") depth++;
else if (ch === ")") depth--;
if (ch === "," && depth === 0) {
parts.push(buf);
buf = "";
} else {
buf += ch;
}
}
if (buf) parts.push(buf);
for (const part of parts) {
const s = part.trim();
if (/\binset\b/.test(s)) continue;
const colorMatch = s.match(/(rgba?\([^)]+\)|#[0-9a-fA-F]{3,8})/);
const color = colorMatch ? parseColor(colorMatch[0]) : { hex: "000000", alpha: 0.3 };
if (!color) continue;
const lengths = s.replace(colorMatch?.[0] ?? "", "").match(/-?[\d.]+px/g);
if (!lengths || lengths.length < 2) continue;
const offsetX = parseFloat(lengths[0]);
const offsetY = parseFloat(lengths[1]);
const blur = lengths[2] ? parseFloat(lengths[2]) : 0;
const dist = Math.sqrt(offsetX * offsetX + offsetY * offsetY);
let angle = Math.atan2(offsetY, offsetX) * (180 / Math.PI);
if (angle < 0) angle += 360;
return {
type: "outer",
color: color.hex,
opacity: clamp(color.alpha, 0, 1),
blur: clamp(pxToPoints(blur), 0, 100),
offset: clamp(pxToPoints(dist), 0, 200),
angle: Math.round(angle),
};
}
return null;
}
// line-height → PptxGenJS lineSpacingMultiple. px values normalize against the
// 1.3333 default leading; unitless multipliers pass through. (←be)
export function lineSpacingMultiple(
lineHeight: string | null | undefined,
fontSizePx: number,
): number | undefined {
if (!lineHeight || lineHeight === "normal") return undefined;
const px = extractPx(lineHeight);
if (px > 0) {
const baseline = fontSizePx * 1.3333333333333333;
return baseline <= 0 ? undefined : clamp(px / baseline, 0.5, 5);
}
const mult = parseFloat(lineHeight);
if (!isNaN(mult) && mult > 0 && mult < 10) return clamp(mult, 0.5, 5);
return undefined;
}
// letter-spacing px → points. (←ye)
export function letterSpacingPoints(value: string | null | undefined): number | undefined {
if (!value || value === "normal") return undefined;
const px = extractPx(value);
if (px !== 0) return clamp(pxToPoints(px), -20, 100);
return undefined;
}
// text-decoration-style → PptxGenJS underline style. (←gt)
export function underlineStyle(value: string | null | undefined): string {
switch (value) {
case "double":
return "dbl";
case "dotted":
return "dotted";
case "dashed":
return "dash";
case "wavy":
return "wavy";
default:
return "sng";
}
}
// White-space modes that preserve both spaces and newlines verbatim. (←keepWs)
export function isPreserveWhitespace(whiteSpace: string | null | undefined): boolean {
return whiteSpace === "pre" || whiteSpace === "pre-wrap" || whiteSpace === "break-spaces";
}
// Drop newlines that only lead/trail a whole assembled block — the
// `<pre>\n …\n</pre>` idiom — without touching interior line breaks.
export function trimBlockNewlines(text: string): string {
return text.replace(/^\n+|\n+$/g, "");
}
// Normalize text per white-space. Newlines (and, for pre/pre-wrap, spaces) are
// preserved verbatim; trimming of block-leading/trailing newlines is left to the
// caller so interior line breaks between sibling runs survive. (←he)
export function normalizeText(text: string, whiteSpace: string | null | undefined): string {
if (isPreserveWhitespace(whiteSpace)) {
return text;
}
if (whiteSpace === "pre-line") {
return text
.split("\n")
.map((line) => line.replace(/[ \t]+/g, " ").trim())
.join("\n");
}
return text.replace(/\s+/g, " ").trim();
}
// Should this box suppress wrapping? (←ve — render uses `wrap: !noWrap(style)`)
export function noWrap(style: StyleMap): boolean {
if (style.whiteSpace === "pre") return true;
if (style.whiteSpace !== "nowrap") return false;
const overflow = (style.overflow ?? "").trim().split(/\s+/)[0] ?? "";
const scrollable = overflow === "auto" || overflow === "scroll" || overflow === "overlay";
const ellipsis =
(overflow === "hidden" || overflow === "clip") &&
(style.textOverflow ?? "").includes("ellipsis");
return !scrollable && !ellipsis;
}
// Sanitize a user-supplied basename (no extension) into a filesystem-safe name.
// Preserves Unicode letters/numbers (CJK, Cyrillic, Arabic, accented Latin);
// anything outside [letters, numbers, - _ . space] becomes "_". Never empty.
export function safeBasename(filename: string | undefined, fallback: string): string {
const cleaned = (filename ?? "")
.normalize("NFC")
.replace(/[^\p{L}\p{N}\-_. ]/gu, "_")
.replace(/\s+/g, " ")
.replace(/^[.\s]+|[.\s]+$/g, "");
return cleaned || fallback;
}
// CSS generic family keywords → PowerPoint-safe faces. Used Node-side; the
// in-page walk has its own GENERIC_MAP for capture-time resolution. (←rt)
export const GENERIC_FONT_MAP: Record<string, string> = {
serif: "Georgia",
"sans-serif": "Arial",
monospace: "Courier New",
"system-ui": "Arial",
"-apple-system": "Arial",
blinkmacsystemfont: "Arial",
"ui-serif": "Georgia",
"ui-sans-serif": "Arial",
"ui-monospace": "Courier New",
"ui-rounded": "Arial",
cursive: "Comic Sans MS",
fantasy: "Impact",
math: "Cambria Math",
emoji: "Segoe UI Emoji",
};
// Resolve a font-family value to a single face name. The capture step has
// usually already collapsed the stack to the rendered face; this maps any
// remaining swap/generic and strips quotes. (←ot)
export function resolveFontFamily(
family: string | null | undefined,
swapMap?: Record<string, string>,
): string {
if (!family) return "Arial";
const first = family
.split(",")[0]
.trim()
.replace(/^['"]|['"]$/g, "");
if (!first) return "Arial";
const lc = first.toLowerCase();
if (swapMap) {
for (const key in swapMap) if (key.toLowerCase() === lc) return swapMap[key];
}
return GENERIC_FONT_MAP[lc] ?? first;
}
import type { FontSwap } from "../types.ts";
// Input guards applied before values cross into the page. (←ze/Ne)
export function sanitizeStrings(value: unknown): string[] {
return Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : [];
}
export function sanitizeFontSwaps(value: unknown): FontSwap[] {
return Array.isArray(value)
? value.filter(
(v): v is FontSwap =>
!!v && typeof v.from === "string" && typeof v.to === "string",
)
: [];
}
// Unit conversions. Browsers report CSS px; PptxGenJS positions in inches and
// sizes fonts/borders in points. (←Ze/et/g/K/S in the bundle.)
export const DPI = 96;
export const PX_TO_PT = 0.75;
/** CSS px → inches. */
export const pxToInches = (px: number): number => px / DPI;
/** CSS px → points. */
export const pxToPoints = (px: number): number => px * PX_TO_PT;
/** Clamp to [lo, hi]; non-finite falls back to lo. */
export function clamp(value: number, lo: number, hi: number): number {
return Number.isFinite(value) ? Math.max(lo, Math.min(hi, value)) : lo;
}
// Public surface for programmatic use. (←Dt runGenPptx + Fe validate.)
export { runGenPptx, type RunOutput } from "./orchestrator/run.ts";
export { PlaywrightDriver, type DriverOptions } from "./orchestrator/driver.ts";
export { writeOutput } from "./orchestrator/output.ts";
export { validate, type SlideHashResult } from "./validate/validate.ts";
export type * from "./types.ts";
// Headless-browser driver. Replaces the claude.ai __omUserViewerHandle
// (executeJavaScript / capturePage / lockViewportSize / reload) with a
// Playwright Chromium page. The orchestrator talks only to this interface.
import type { Browser, BrowserContext, Page } from "playwright";
export interface DriverOptions {
width: number;
height: number;
/** 2 for crisp screenshots; 1 for editable (coords only). */
deviceScaleFactor?: number;
/** ms for navigation + default action timeout (default 30000). */
timeout?: number;
}
export class PlaywrightDriver {
private browser: Browser;
private context: BrowserContext;
readonly page: Page;
private constructor(browser: Browser, context: BrowserContext, page: Page) {
this.browser = browser;
this.context = context;
this.page = page;
}
static async launch(url: string, opts: DriverOptions): Promise<PlaywrightDriver> {
// Dynamic import keeps playwright out of the hoisted top-level imports so
// the CLI preflight can surface a friendly "run npm install" hint instead
// of a raw module-not-found crash at load.
const { chromium } = await import("playwright");
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: opts.width, height: opts.height },
deviceScaleFactor: opts.deviceScaleFactor ?? 1,
});
const page = await context.newPage();
page.setDefaultTimeout(opts.timeout ?? 30000);
await page.goto(url, { waitUntil: "load", timeout: opts.timeout ?? 30000 });
return new PlaywrightDriver(browser, context, page);
}
/** Full-viewport PNG as a base64 data URL. (←capturePage().toDataURL()) */
async screenshot(): Promise<string> {
const buf = await this.page.screenshot({ type: "png" });
return `data:image/png;base64,${buf.toString("base64")}`;
}
async setViewportSize(width: number, height: number): Promise<void> {
await this.page.setViewportSize({ width, height });
}
async close(): Promise<void> {
try {
await this.context.close();
} catch {
/* best-effort */
}
try {
await this.browser.close();
} catch {
/* best-effort */
}
}
}
// Error helpers. (←ge error-stringify, ←ke timeout-hint, reworded for the
// headless-node context — there's no claude.ai preview iframe here.)
export function stringifyError(err: unknown): string {
if (err instanceof Error) return err.message;
try {
return String(err);
} catch {
return "unknown error";
}
}
export function timeoutHint(
message: string,
phase: "setup" | "editable" | "screenshots",
slideNum?: number,
): string {
if (!/timed?\s*out|timeout/i.test(message)) return "";
if (phase === "setup") {
return ". The page didn't finish loading the deck in time — confirm the URL serves the deck, then retry.";
}
const where = `slide ${slideNum}`;
return phase === "editable"
? `. Capture stalled on ${where} (slide too heavy for editable capture). Retrying identically will stall again — tell the user, then retry with mode:'screenshots' for a pixel-perfect non-editable fallback.`
: `. Capture stalled on ${where}. Retrying identically may stall again — tell the user and consider a longer delay.`;
}
// Loads the compiled capture IIFE into the page once, then exposes typed
// wrappers that drive window.__genpptx via page.evaluate(). Replaces the
// original's string-template executeJavaScript with a bundled, typed surface.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import type { Page } from "playwright";
import type {
FontSwap,
MediaRef,
ResolvedMedia,
SetupResult,
SlideSpec,
} from "../types.ts";
import type { SetupInput } from "../browser/setup.ts";
import type { EditableCapture } from "../browser/capture-editable.ts";
let cached: string | null = null;
function bundleSource(): string {
if (cached == null) {
// At runtime this module is bundled into dist/cli.mjs; capture.iife.js
// sits beside it.
const path = fileURLToPath(new URL("./capture.iife.js", import.meta.url));
cached = readFileSync(path, "utf8");
}
return cached;
}
export async function injectCaptureBundle(page: Page): Promise<void> {
await page.addScriptTag({ content: bundleSource() });
const ok = await page.evaluate(
() => typeof (window as unknown as { __genpptx?: unknown }).__genpptx === "object",
);
if (!ok) throw new Error("capture bundle failed to initialise window.__genpptx");
}
type Win = { __genpptx: import("../browser/entry.ts").GenPptxApi };
// Bound each in-page call so a never-settling slide (runaway showJs/animation)
// can't hang the export. (←the original wraps every executeJavaScript in a
// per-call timeout: setup 15000ms, editable/screenshots 15000 + (delay ?? 600).)
// The message must contain "timed out" so errors.ts timeoutHint() fires the
// retry guidance.
function evaluateWithTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
const guard = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
});
return Promise.race([p, guard]).finally(() => clearTimeout(timer)) as Promise<T>;
}
// Mirrors the original's `delay ?? 600` added on top of the 15s base budget.
function captureBudget(spec: SlideSpec): number {
const d = Number((spec as { delay?: unknown }).delay);
return 15000 + (Number.isFinite(d) ? d : 600);
}
export function callSetup(page: Page, input: SetupInput): Promise<SetupResult> {
return evaluateWithTimeout(
page.evaluate((arg) => (window as unknown as Win).__genpptx.setup(arg), input),
15000,
"setup",
);
}
export function callCaptureEditable(
page: Page,
spec: SlideSpec,
fontSwaps: FontSwap[],
): Promise<EditableCapture> {
return evaluateWithTimeout(
page.evaluate(
([s, f]) => (window as unknown as Win).__genpptx.captureEditable(s, f),
[spec, fontSwaps] as [SlideSpec, FontSwap[]],
),
captureBudget(spec),
"editable capture",
);
}
export function callCaptureScreenshot(page: Page, spec: SlideSpec): Promise<void> {
return evaluateWithTimeout(
page.evaluate((s) => (window as unknown as Win).__genpptx.captureScreenshot(s), spec),
captureBudget(spec),
"screenshot capture",
);
}
export function callResolveMedia(page: Page, refs: MediaRef[]): Promise<ResolvedMedia[]> {
return page.evaluate((r) => (window as unknown as Win).__genpptx.resolveMedia(r), refs);
}
// Write the .pptx Buffer to disk. Replaces the claude.ai host download (Ye) /
// connectrpc upload (Je/He/Xe).
import { mkdir, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { safeBasename } from "../core/filename.ts";
export async function writeOutput(
buffer: Buffer,
outDir: string,
filename: string | undefined,
): Promise<string> {
const base = safeBasename(filename, "deck").replace(/\.pptx$/i, "") || "deck";
const name = `${base}.pptx`;
const dir = resolve(outDir);
await mkdir(dir, { recursive: true });
const path = join(dir, name);
await writeFile(path, buffer);
return path;
}
// Orchestrates a full export: inject → setup → per-slide capture → assemble →
// validate. Returns the .pptx bytes + a structured result; the caller writes to
// disk. (←Dt, with the viewer handle swapped for PlaywrightDriver and host
// download/upload moved to the caller.)
import { pxToInches } from "../core/units.ts";
import { sanitizeStrings, sanitizeFontSwaps } from "../core/sanitize.ts";
import { buildEditablePptx } from "../render/build-editable.ts";
import { buildScreenshotPptx, type ImageTile } from "../render/build-screenshot.ts";
import { validate, type SlideHashResult } from "../validate/validate.ts";
import type { CapturedSlide, GenPptxInput, GenPptxResult, SetupResult } from "../types.ts";
import type { EditableCapture } from "../browser/capture-editable.ts";
import { PlaywrightDriver } from "./driver.ts";
import {
injectCaptureBundle,
callSetup,
callCaptureEditable,
callCaptureScreenshot,
callResolveMedia,
} from "./inject.ts";
import { stringifyError, timeoutHint } from "./errors.ts";
export interface RunOutput {
result: GenPptxResult;
buffer: Buffer;
}
export async function runGenPptx(rawInput: GenPptxInput, driver: PlaywrightDriver): Promise<RunOutput> {
const mode = rawInput.mode ?? "editable";
const input: GenPptxInput = {
...rawInput,
hideSelectors: sanitizeStrings(rawInput.hideSelectors),
googleFontImports: sanitizeStrings(rawInput.googleFontImports),
fontSwaps: sanitizeFontSwaps(rawInput.fontSwaps),
};
if (!Array.isArray(input.slides) || input.slides.length === 0) {
throw new Error("genPptx: slides[] is empty");
}
const { page } = driver;
await injectCaptureBundle(page);
// Screenshots: lock the viewport to the slide size, drop the reset selector,
// and let one frame settle before setup. (←Dt screenshots preamble.)
if (mode === "screenshots") {
await driver.setViewportSize(input.width, input.height);
await page.evaluate(
() =>
new Promise<void>((r) =>
requestAnimationFrame(() => requestAnimationFrame(() => r())),
),
);
}
let setupRes: SetupResult;
try {
setupRes = await callSetup(page, {
mode,
width: input.width,
height: input.height,
hideSelectors: input.hideSelectors,
googleFontImports: input.googleFontImports,
fontSwaps: input.fontSwaps,
resetTransformSelector: mode === "screenshots" ? undefined : input.resetTransformSelector,
});
} catch (err) {
const msg = stringifyError(err);
throw new Error(`genPptx: setup failed: ${msg}${timeoutHint(msg, "setup")}`);
}
if (mode === "editable") {
const captures: SlideHashResult[] = [];
const capturedSlides: CapturedSlide[] = [];
for (let i = 0; i < input.slides.length; i++) {
let cap: EditableCapture;
try {
cap = await callCaptureEditable(page, input.slides[i], input.fontSwaps ?? []);
} catch (err) {
const msg = stringifyError(err);
throw new Error(
`genPptx: slide ${i + 1}/${input.slides.length} capture failed: ${msg}${timeoutHint(msg, "editable", i + 1)}`,
);
}
const captured: CapturedSlide = { rect: cap.slide.rect, root: cap.slide.root };
const note = setupRes.notes[i];
if (note?.trim()) captured.notes = note;
capturedSlides.push(captured);
captures.push({ hash: cap.hash, imagesWaited: cap.imagesWaited, imagesFailed: cap.imagesFailed });
}
const build = await buildEditablePptx(
{ width: input.width, height: input.height, slides: capturedSlides },
(refs) => callResolveMedia(page, refs),
);
const validation = validate(setupRes, captures, capturedSlides, input, "editable");
return {
buffer: build.buffer,
result: {
bytes: build.bytes,
slides: build.slides,
warnings: build.warnings,
validation,
speakerNotes: setupRes.notes.slice(0, input.slides.length),
},
};
}
// Screenshots mode.
const wIn = pxToInches(input.width);
const hIn = pxToInches(input.height);
const tiles: ImageTile[][] = [];
const hashes: SlideHashResult[] = [];
for (let i = 0; i < input.slides.length; i++) {
let dataUrl: string;
try {
await callCaptureScreenshot(page, input.slides[i]);
dataUrl = await driver.screenshot();
} catch (err) {
const msg = stringifyError(err);
throw new Error(
`genPptx: slide ${i + 1}/${input.slides.length} capture failed: ${msg}${timeoutHint(msg, "screenshots", i + 1)}`,
);
}
// djb2 over the middle 4096 chars of the data URL. (←Dt hashing.)
let v = 5381;
const mid = dataUrl.length >> 1;
const slice = dataUrl.slice(mid, mid + 4096);
for (let k = 0; k < slice.length; k++) v = ((v << 5) + v + slice.charCodeAt(k)) | 0;
hashes.push({ hash: v >>> 0, imagesWaited: 0, imagesFailed: 0 });
tiles.push([{ data: dataUrl, x: 0, y: 0, w: wIn, h: hIn }]);
}
const build = await buildScreenshotPptx(tiles, setupRes.notes, input);
const validation = validate(setupRes, hashes, [], input, "screenshots");
return {
buffer: build.buffer,
result: {
bytes: build.bytes,
slides: build.slides,
warnings: build.warnings,
validation,
speakerNotes: setupRes.notes.slice(0, input.slides.length),
},
};
}
import PptxGenJS from "pptxgenjs";
import type {
CapturedSlide,
MediaRef,
ResolvedMedia,
} from "../types.ts";
import { pxToInches } from "../core/units.ts";
import { parseColor } from "../core/color.ts";
import type { PptxSlide } from "./context.ts";
import { renderNodeToPptx } from "./render-node.ts";
import { buildMediaCache } from "./media-cache.ts";
export interface EditableBuildInput {
width: number;
height: number;
slides: CapturedSlide[];
fontMap?: Record<string, string>;
}
export interface BuildResult {
buffer: Buffer;
bytes: number;
slides: number;
warnings: string[];
}
// Assemble the editable .pptx from captured slide trees: define the custom
// layout, resolve media via the page, then render each slide's tree into native
// shapes/text. (←Et, with browser write({outputType:"blob"}) → node nodebuffer.)
export async function buildEditablePptx(
input: EditableBuildInput,
resolveMedia: (refs: MediaRef[]) => Promise<ResolvedMedia[]>,
): Promise<BuildResult> {
if (!Array.isArray(input.slides) || input.slides.length === 0) {
throw new Error("buildEditablePptx: deck has no slides");
}
const warnings: string[] = [];
const pptx = new PptxGenJS();
pptx.defineLayout({ name: "CUSTOM", width: pxToInches(input.width), height: pxToInches(input.height) });
pptx.layout = "CUSTOM";
const mediaCache = await buildMediaCache(input.slides, warnings, resolveMedia);
for (const captured of input.slides) {
const slide = pptx.addSlide() as unknown as PptxSlide;
const rootBg = parseColor(captured.root.style.backgroundColor);
if (rootBg && rootBg.alpha === 1) {
slide.background = { color: rootBg.hex };
captured.root.style.backgroundColor = "transparent";
}
try {
renderNodeToPptx(captured.root, {
slide,
slideW: input.width,
slideH: input.height,
originX: captured.rect.x,
originY: captured.rect.y,
mediaCache,
warnings,
fontMap: input.fontMap,
});
} catch (err) {
warnings.push(`Slide render aborted: ${err instanceof Error ? err.message : String(err)}`);
}
if (captured.notes?.trim()) {
try {
slide.addNotes(captured.notes);
} catch (err) {
warnings.push(`addNotes failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
}
const buffer = (await pptx.write({ outputType: "nodebuffer" })) as Buffer;
return { buffer, bytes: buffer.length, slides: input.slides.length, warnings };
}
import PptxGenJS from "pptxgenjs";
import { pxToInches } from "../core/units.ts";
import type { BuildResult } from "./build-editable.ts";
/** One placed image tile on a slide; coords/size already in inches. */
export interface ImageTile {
data: string;
x: number;
y: number;
w: number;
h: number;
}
// Assemble the screenshots .pptx: one slide per image set, each a full-bleed
// (or tiled) raster, plus speaker notes by index. (←jt, write→nodebuffer.)
export async function buildScreenshotPptx(
slideTiles: ImageTile[][],
notes: string[],
input: { width: number; height: number },
): Promise<BuildResult> {
const warnings: string[] = [];
const pptx = new PptxGenJS();
pptx.defineLayout({ name: "CUSTOM", width: pxToInches(input.width), height: pxToInches(input.height) });
pptx.layout = "CUSTOM";
for (let i = 0; i < slideTiles.length; i++) {
const slide = pptx.addSlide();
for (const tile of slideTiles[i]) {
slide.addImage({ data: tile.data, x: tile.x, y: tile.y, w: tile.w, h: tile.h });
}
const note = notes[i];
if (note?.trim()) {
try {
slide.addNotes(note);
} catch (err) {
warnings.push(`addNotes slide ${i + 1}: ${err instanceof Error ? err.message : String(err)}`);
}
}
}
const buffer = (await pptx.write({ outputType: "nodebuffer" })) as Buffer;
return { buffer, bytes: buffer.length, slides: slideTiles.length, warnings };
}
import { pxToInches, clamp } from "../core/units.ts";
import type { Rect, MediaCache } from "../types.ts";
// Minimal structural view of a PptxGenJS slide — the renderer builds dynamic
// option bags (faithful to the original), so option args stay loosely typed.
export interface PptxSlide {
addText(text: unknown, opts: Record<string, unknown>): void;
addShape(shapeName: string, opts: Record<string, unknown>): void;
addImage(opts: Record<string, unknown>): void;
addNotes(notes: string): void;
background?: { color: string };
}
export interface RenderContext {
slide: PptxSlide;
slideW: number;
slideH: number;
originX: number;
originY: number;
mediaCache: MediaCache;
warnings: string[];
/** Optional from→to font swap map (usually unused; capture already resolved). */
fontMap?: Record<string, string>;
}
// Page rect → slide-relative inches, clamped to a sane band around the slide. (←Ae)
export function rectToPptx(rect: Rect, ctx: RenderContext): Rect {
const x = rect.x - ctx.originX;
const y = rect.y - ctx.originY;
return {
x: pxToInches(clamp(x, -ctx.slideW, ctx.slideW * 2)),
y: pxToInches(clamp(y, -ctx.slideH, ctx.slideH * 2)),
w: pxToInches(Math.max(clamp(rect.w, 0, ctx.slideW * 2), 1)),
h: pxToInches(Math.max(clamp(rect.h, 0, ctx.slideH * 2), 1)),
};
}
import type { SlideNode } from "../types.ts";
import { parseColor } from "../core/color.ts";
import { normalizeText } from "../core/css.ts";
import { imageKey } from "./media-cache.ts";
export interface ListStyleConfig {
isNum: boolean;
numberType?: string;
characterCode?: string;
}
// list-style-type → PptxGenJS bullet config. (←Pe)
export function listStyleConfig(value: string): ListStyleConfig {
switch (value) {
case "decimal":
case "decimal-leading-zero":
return { isNum: true };
case "lower-alpha":
case "lower-latin":
return { isNum: true, numberType: "alphaLcPeriod" };
case "upper-alpha":
case "upper-latin":
return { isNum: true, numberType: "alphaUcPeriod" };
case "lower-roman":
return { isNum: true, numberType: "romanLcPeriod" };
case "upper-roman":
return { isNum: true, numberType: "romanUcPeriod" };
case "circle":
return { isNum: false, characterCode: "25CB" };
case "square":
return { isNum: false, characterCode: "25A0" };
default:
return { isNum: false };
}
}
// Style keys that must match across <li> for the list to render as one box. (←Ct)
const LIST_UNIFORM_KEYS = [
"color",
"fontWeight",
"fontStyle",
"fontFamily",
"fontSize",
"textDecorationLine",
"textDecoration",
"textDecorationStyle",
"textDecorationColor",
"textTransform",
"letterSpacing",
"textShadow",
];
// A <ul>/<ol> of uniformly-styled plain-text <li> → array of item strings,
// else null (render each child normally). (←kt)
export function detectList(node: SlideNode): string[] | null {
if (node.tag !== "ul" && node.tag !== "ol") return null;
const items: string[] = [];
const first = node.children[0];
for (const li of node.children) {
if (
li.tag !== "li" ||
li.style.visibility === "hidden" ||
li.style.opacity === "0" ||
li.children.length > 0 ||
imageKey(li) ||
parseColor(li.style.backgroundColor)
) {
return null;
}
for (const k of LIST_UNIFORM_KEYS) {
if ((li.style[k] || "") !== (first.style[k] || "")) return null;
}
const text = li.text ? normalizeText(li.text, li.style.whiteSpace) : "";
if (!text) return null;
items.push(text);
}
return items.length > 0 ? items : null;
}
node_modules/
*.log
Related skills
How it compares
Portable design system compile and preview pipeline, not a single static mockup generator.
FAQ
Which files are generated and should not be hand-edited?
_ds_bundle.js, _ds_manifest.json, _adherence.oxlintrc.json, and preview.html are generated artifacts.
How are design system cards exposed?
Add <!-- @dsCard group="..." viewport="WxH" name="..." --> as the first line of an HTML file; optional @startingPoint tags opt components into pickers.
How do I validate a design system without writing files?
Run node agents/check-design-system.mjs on the project directory; it reports components, tokens, fonts, and issues read-only.