wechat-robot-skills/skills/pptx/scripts/_presentation_builder.js

432 lines
14 KiB
JavaScript

#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const PptxGenJS = require("pptxgenjs");
const LAYOUTS = {
LAYOUT_WIDE: { width: 13.333, height: 7.5 },
LAYOUT_16X9: { width: 10, height: 5.625 },
LAYOUT_4X3: { width: 10, height: 7.5 },
};
function fail(message) {
process.stderr.write(`${message}\n`);
process.exit(1);
}
function parseArgs(argv) {
const values = {};
for (let index = 0; index < argv.length; index += 2) {
const key = argv[index];
const value = argv[index + 1];
if (!key || !key.startsWith("--") || value === undefined) {
fail("参数必须按 --name value 成对提供");
}
values[key.slice(2)] = value;
}
if (!values.spec || !values.output) {
fail("缺少 --spec 或 --output");
}
return values;
}
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function requireObject(value, label) {
if (!isPlainObject(value)) {
throw new Error(`${label} 必须是对象`);
}
return value;
}
function requireArray(value, label, maxLength = 1000) {
if (!Array.isArray(value)) {
throw new Error(`${label} 必须是数组`);
}
if (value.length > maxLength) {
throw new Error(`${label} 超过 ${maxLength} 项限制`);
}
return value;
}
function requireString(value, label, maxLength = 100000) {
if (typeof value !== "string") {
throw new Error(`${label} 必须是字符串`);
}
if (value.length > maxLength) {
throw new Error(`${label} 超过 ${maxLength} 字符限制`);
}
return value;
}
function cleanColor(value, label) {
if (typeof value !== "string" || !/^[0-9A-Fa-f]{6}$/.test(value)) {
throw new Error(`${label} 必须是不带 # 的 6 位十六进制颜色`);
}
return value.toUpperCase();
}
function validateTree(value, label = "options", depth = 0) {
if (depth > 12) {
throw new Error(`${label} 嵌套层级过深`);
}
if (value === null || typeof value === "boolean" || typeof value === "number") {
if (typeof value === "number" && !Number.isFinite(value)) {
throw new Error(`${label} 包含非有限数值`);
}
return;
}
if (typeof value === "string") {
if (value.length > 200000) {
throw new Error(`${label} 字符串过长`);
}
return;
}
if (Array.isArray(value)) {
if (value.length > 5000) {
throw new Error(`${label} 数组过长`);
}
value.forEach((item, index) => validateTree(item, `${label}[${index}]`, depth + 1));
return;
}
if (!isPlainObject(value)) {
throw new Error(`${label} 包含不支持的数据类型`);
}
const entries = Object.entries(value);
if (entries.length > 500) {
throw new Error(`${label} 字段过多`);
}
for (const [key, item] of entries) {
if (["__proto__", "prototype", "constructor"].includes(key)) {
throw new Error(`${label} 包含禁止字段 ${key}`);
}
if (key === "color" && typeof item === "string") {
cleanColor(item, `${label}.${key}`);
}
if (key === "chartColors" && Array.isArray(item)) {
item.forEach((color, index) => cleanColor(color, `${label}.chartColors[${index}]`));
}
if (key === "offset" && label.toLowerCase().includes("shadow")) {
if (typeof item !== "number" || item < 0) {
throw new Error(`${label}.offset 必须是非负数`);
}
}
validateTree(item, `${label}.${key}`, depth + 1);
}
if (isPlainObject(value.fill) && value.fill.type === "gradient") {
throw new Error(`${label}.fill 不支持渐变;请使用渐变背景图片`);
}
}
function requireBox(options, label) {
for (const key of ["x", "y", "w", "h"]) {
if (typeof options[key] !== "number" || !Number.isFinite(options[key])) {
throw new Error(`${label}.${key} 必须是有限数值`);
}
}
}
function normalizeRuns(runs, label) {
return requireArray(runs, label, 2000).map((run, index) => {
requireObject(run, `${label}[${index}]`);
const text = requireString(run.text ?? "", `${label}[${index}].text`);
const options = clone(run.options || {});
requireObject(options, `${label}[${index}].options`);
validateTree(options, `${label}[${index}].options`);
return { text, options };
});
}
function normalizeTableRows(rows, label) {
return requireArray(rows, label, 500).map((row, rowIndex) =>
requireArray(row, `${label}[${rowIndex}]`, 100).map((cell, columnIndex) => {
if (typeof cell === "string" || typeof cell === "number") {
return String(cell);
}
requireObject(cell, `${label}[${rowIndex}][${columnIndex}]`);
const normalized = {
text: requireString(
String(cell.text ?? ""),
`${label}[${rowIndex}][${columnIndex}].text`,
),
options: clone(cell.options || {}),
};
requireObject(
normalized.options,
`${label}[${rowIndex}][${columnIndex}].options`,
);
validateTree(
normalized.options,
`${label}[${rowIndex}][${columnIndex}].options`,
);
return normalized;
}),
);
}
function normalizeChartData(data, label) {
return requireArray(data, label, 100).map((series, index) => {
requireObject(series, `${label}[${index}]`);
const labels = requireArray(series.labels, `${label}[${index}].labels`, 10000);
const values = requireArray(series.values, `${label}[${index}].values`, 10000);
if (labels.length !== values.length) {
throw new Error(`${label}[${index}] 的 labels 与 values 长度必须一致`);
}
return {
name: requireString(String(series.name ?? ""), `${label}[${index}].name`, 500),
labels: labels.map((item) => String(item)),
values: values.map((item, valueIndex) => {
if (typeof item !== "number" || !Number.isFinite(item)) {
throw new Error(`${label}[${index}].values[${valueIndex}] 必须是有限数值`);
}
return item;
}),
};
});
}
function resolveImagePath(value, label) {
const raw = requireString(value, label, 4096);
const resolved = path.resolve(raw);
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
throw new Error(`${label} 文件不存在:${resolved}`);
}
return resolved;
}
function elementBounds(element, options, slideNumber, index, dimensions, warnings) {
if (!["text", "shape", "image", "chart", "table"].includes(element.type)) {
return;
}
requireBox(options, `slides[${slideNumber - 1}].elements[${index}].options`);
const tolerance = 0.002;
if (
options.x < -tolerance ||
options.y < -tolerance ||
options.x + options.w > dimensions.width + tolerance ||
options.y + options.h > dimensions.height + tolerance
) {
warnings.push({
slide: slideNumber,
element: index + 1,
code: "out_of_bounds",
message: "元素超出演示文稿画布",
});
}
}
async function build(spec, outputPath) {
requireObject(spec, "spec");
const slides = requireArray(spec.slides, "slides", 300);
if (slides.length === 0) {
throw new Error("slides 至少需要一页");
}
const layout = spec.layout || "LAYOUT_WIDE";
if (!Object.prototype.hasOwnProperty.call(LAYOUTS, layout)) {
throw new Error(`layout 不支持:${layout}`);
}
const dimensions = LAYOUTS[layout];
const pptx = new PptxGenJS();
pptx.layout = layout;
const properties = spec.properties || {};
requireObject(properties, "properties");
const propertyMap = {
author: "author",
company: "company",
subject: "subject",
title: "title",
comments: "comments",
revision: "revision",
};
for (const [sourceKey, targetKey] of Object.entries(propertyMap)) {
if (properties[sourceKey] !== undefined) {
pptx[targetKey] = requireString(
String(properties[sourceKey]),
`properties.${sourceKey}`,
4000,
);
}
}
const theme = spec.theme || {};
requireObject(theme, "theme");
const headFont = requireString(
String(theme.head_font || "Noto Sans CJK SC"),
"theme.head_font",
200,
);
const bodyFont = requireString(
String(theme.body_font || "Noto Sans CJK SC"),
"theme.body_font",
200,
);
pptx.theme = {
headFontFace: headFont,
bodyFontFace: bodyFont,
lang: requireString(String(theme.language || "zh-CN"), "theme.language", 40),
};
pptx.lang = theme.language || "zh-CN";
const warnings = [];
const elementCounts = {};
for (let slideIndex = 0; slideIndex < slides.length; slideIndex += 1) {
const slideSpec = requireObject(slides[slideIndex], `slides[${slideIndex}]`);
const slide = pptx.addSlide();
if (slideSpec.background !== undefined) {
slide.background = {
color: cleanColor(slideSpec.background, `slides[${slideIndex}].background`),
};
}
const elements = requireArray(
slideSpec.elements || [],
`slides[${slideIndex}].elements`,
1000,
);
if (elements.length === 0) {
warnings.push({
slide: slideIndex + 1,
code: "empty_slide",
message: "页面没有可见元素",
});
}
for (let elementIndex = 0; elementIndex < elements.length; elementIndex += 1) {
const label = `slides[${slideIndex}].elements[${elementIndex}]`;
const element = requireObject(elements[elementIndex], label);
const type = requireString(element.type, `${label}.type`, 50);
const options = clone(element.options || {});
requireObject(options, `${label}.options`);
validateTree(options, `${label}.options`);
elementBounds(
element,
options,
slideIndex + 1,
elementIndex,
dimensions,
warnings,
);
elementCounts[type] = (elementCounts[type] || 0) + 1;
if (type === "text") {
if (options.fontFace === undefined) {
options.fontFace = bodyFont;
}
const content =
element.runs !== undefined
? normalizeRuns(element.runs, `${label}.runs`)
: requireString(String(element.text ?? ""), `${label}.text`);
slide.addText(content, options);
} else if (type === "shape") {
const shapeName = requireString(element.shape || "rect", `${label}.shape`, 100);
const shapeType = pptx.ShapeType[shapeName];
if (!shapeType) {
throw new Error(`${label}.shape 不支持:${shapeName}`);
}
slide.addShape(shapeType, options);
} else if (type === "image") {
if (element.path !== undefined) {
options.path = resolveImagePath(element.path, `${label}.path`);
} else if (element.data !== undefined) {
const data = requireString(element.data, `${label}.data`, 20_000_000);
if (!/^data:image\/(?:png|jpeg|jpg|webp);base64,/.test(data)) {
throw new Error(`${label}.data 必须是受支持图片的 base64 data URL`);
}
options.data = data;
} else {
throw new Error(`${label} 必须提供 path 或 data`);
}
slide.addImage(options);
} else if (type === "chart") {
const chartName = requireString(element.chart_type, `${label}.chart_type`, 50);
const chartType = pptx.ChartType[chartName];
if (!chartType) {
throw new Error(`${label}.chart_type 不支持:${chartName}`);
}
const chartData = normalizeChartData(element.data, `${label}.data`);
const chartOptions = {
showLegend: chartData.length > 1,
showTitle: Boolean(options.title),
showValue: true,
chartColors: ["2563EB", "14B8A6", "F97316", "8B5CF6", "E11D48"],
catAxisLabelColor: "475569",
valAxisLabelColor: "475569",
valGridLine: { color: "E2E8F0", size: 1 },
catAxisLabelFontFace: bodyFont,
valAxisLabelFontFace: bodyFont,
dataLabelFontFace: bodyFont,
legendFontFace: bodyFont,
titleFontFace: headFont,
...options,
};
validateTree(chartOptions, `${label}.options`);
slide.addChart(chartType, chartData, chartOptions);
} else if (type === "table") {
if (options.fontFace === undefined) {
options.fontFace = bodyFont;
}
const rows = normalizeTableRows(element.rows, `${label}.rows`);
slide.addTable(rows, options);
} else {
throw new Error(`${label}.type 不支持:${type}`);
}
}
if (slideSpec.speaker_notes !== undefined) {
const notes = requireString(
String(slideSpec.speaker_notes),
`slides[${slideIndex}].speaker_notes`,
100000,
);
slide.addNotes(notes);
}
}
await pptx.writeFile({ fileName: outputPath });
if (!fs.existsSync(outputPath) || fs.statSync(outputPath).size === 0) {
throw new Error("PptxGenJS 未生成有效输出文件");
}
return {
slide_count: slides.length,
element_counts: elementCounts,
layout,
width_inches: dimensions.width,
height_inches: dimensions.height,
warnings,
};
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const specPath = path.resolve(args.spec);
const outputPath = path.resolve(args.output);
if (!fs.existsSync(specPath) || !fs.statSync(specPath).isFile()) {
fail(`spec 文件不存在:${specPath}`);
}
if (path.extname(outputPath).toLowerCase() !== ".pptx") {
fail("output 必须使用 .pptx 扩展名");
}
let spec;
try {
spec = JSON.parse(fs.readFileSync(specPath, "utf8"));
} catch (error) {
fail(`读取 spec 失败:${error.message}`);
}
try {
const result = await build(spec, outputPath);
process.stdout.write(`${JSON.stringify(result)}\n`);
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}
}
main();