432 lines
14 KiB
JavaScript
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();
|